switchroom 0.18.11 → 0.18.13
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 +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -162,14 +162,26 @@ export interface DrainIo {
|
|
|
162
162
|
* both a model AND an effort command; breaking after re-enqueueing only the
|
|
163
163
|
* current one would silently drop the other with its ack card stuck at
|
|
164
164
|
* "queued" forever.
|
|
165
|
+
*
|
|
166
|
+
* Restart-pending is SNAPSHOTTED once at drain entry (#3021): the caller's
|
|
167
|
+
* `restartPending()` reads a `pendingRestarts` map that the turn-end idle gate
|
|
168
|
+
* empties in a synchronous loop dispatched right alongside this (async) drain.
|
|
169
|
+
* For the FIRST command the per-iteration read still sees the entry, but by the
|
|
170
|
+
* time an `await` yields to the second command the sync loop has emptied the
|
|
171
|
+
* map — so a naive per-command re-check would let the second command apply live
|
|
172
|
+
* into a session that is ~100ms from triggerSelfRestart and falsely confirm.
|
|
173
|
+
* Latching the entry state fixes that. We still OR in the live re-check so a
|
|
174
|
+
* restart that BECOMES pending mid-drain (a model apply can itself enqueue one)
|
|
175
|
+
* is honored for the commands that follow it.
|
|
165
176
|
*/
|
|
166
177
|
export async function drainTakenCommands(
|
|
167
178
|
taken: readonly PendingSessionCommand[],
|
|
168
179
|
io: DrainIo,
|
|
169
180
|
): Promise<void> {
|
|
181
|
+
const restartPendingAtEntry = io.restartPending()
|
|
170
182
|
for (let i = 0; i < taken.length; i++) {
|
|
171
183
|
const cmd = taken[i]
|
|
172
|
-
if (io.restartPending()) {
|
|
184
|
+
if (restartPendingAtEntry || io.restartPending()) {
|
|
173
185
|
// The live session is going away — carry the choice across the bounce
|
|
174
186
|
// via the durable carriers (or the re-issue fallback).
|
|
175
187
|
await io.editCard(cmd, io.resolveForRestartText(cmd))
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The permission TTL sweep — the code path that used to auto-deny approvals the
|
|
3
|
+
* operator never saw.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS A MODULE AND NOT A LOOP INSIDE gateway.ts:
|
|
6
|
+
*
|
|
7
|
+
* gateway.ts has top-level side effects and is not importable from a test. The
|
|
8
|
+
* first version of the outcome test worked around that by letting its harness
|
|
9
|
+
* carry a PRIVATE re-implementation of this sweep. The result was a placebo:
|
|
10
|
+
* deleting the real guard from gateway.ts left every behavioural assertion GREEN,
|
|
11
|
+
* because the double silently compensated — only a source-text grep noticed.
|
|
12
|
+
*
|
|
13
|
+
* A test that cannot fail is not a test, and this is the sweep that implements the
|
|
14
|
+
* `no-self-escalation` / `on-leash` invariant. So the DECISION lives here, in one
|
|
15
|
+
* importable place, and both gateway.ts and the outcome test drive this exact
|
|
16
|
+
* function. Delete the leash check inside `shouldExpirePermission` and the outcome
|
|
17
|
+
* test goes red — because there is only one implementation to delete.
|
|
18
|
+
*
|
|
19
|
+
* @see reference/jobs/approve-what-my-agent-can-touch.md
|
|
20
|
+
* @see reference/invariants.md § no-self-escalation, § on-leash
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { shouldExpirePermission, type UndeliverableMark } from './approval-hold.js'
|
|
24
|
+
|
|
25
|
+
/** The fields of a pending permission this sweep actually reads. */
|
|
26
|
+
export interface TtlSweepEntry {
|
|
27
|
+
tool_name: string
|
|
28
|
+
startedAt: number
|
|
29
|
+
undeliverable?: UndeliverableMark | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TtlSweepDeps<T extends TtlSweepEntry> {
|
|
33
|
+
/** The live pending-permission map. */
|
|
34
|
+
entries: Iterable<readonly [string, T]>
|
|
35
|
+
now: number
|
|
36
|
+
/** Per-tool TTL (hostd fleet-mutation verbs get a longer human-scale window). */
|
|
37
|
+
ttlForTool: (toolName: string) => number
|
|
38
|
+
/**
|
|
39
|
+
* Expire this request: dispatch the deny verdict, strip the card's keyboard,
|
|
40
|
+
* wake the parked turn, record the miss, and drop the entry. Everything the
|
|
41
|
+
* gateway does on a timeout — the sweep decides WHETHER, the caller decides HOW.
|
|
42
|
+
*/
|
|
43
|
+
onExpire: (requestId: string, pend: T, ttlMs: number) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Expire every pending permission past its TTL — except the HELD ones, which
|
|
48
|
+
* never expire.
|
|
49
|
+
*
|
|
50
|
+
* Returns the ids it expired (for logging/tests).
|
|
51
|
+
*/
|
|
52
|
+
export function sweepPermissionTtl<T extends TtlSweepEntry>(
|
|
53
|
+
deps: TtlSweepDeps<T>,
|
|
54
|
+
): string[] {
|
|
55
|
+
const expired: string[] = []
|
|
56
|
+
for (const [requestId, pend] of deps.entries) {
|
|
57
|
+
const ttlMs = deps.ttlForTool(pend.tool_name)
|
|
58
|
+
// THE LEASH. `shouldExpirePermission` returns false for any entry marked
|
|
59
|
+
// undeliverable — an ask the operator was never shown. Never auto-approve,
|
|
60
|
+
// never auto-deny, not even on a deadline.
|
|
61
|
+
if (!shouldExpirePermission(pend, deps.now, ttlMs)) continue
|
|
62
|
+
deps.onExpire(requestId, pend, ttlMs)
|
|
63
|
+
expired.push(requestId)
|
|
64
|
+
}
|
|
65
|
+
return expired
|
|
66
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// #2975 Stage 2 — read-only pre-approval predicate.
|
|
2
|
+
//
|
|
3
|
+
// hostd asks (over the approval-gateway socket, via `check_pre_approved`)
|
|
4
|
+
// whether an EXACT `(agent, diff)` pair is already operator-consented, so it
|
|
5
|
+
// can skip the `config_propose_edit` per-hour rate limit for that persist. This
|
|
6
|
+
// is the READ-ONLY sibling of the `tryAutoResolve` auto-resolve hook wired into
|
|
7
|
+
// `onRequestConfigApproval`: it runs the SAME forge-resistance gate — an EXACT
|
|
8
|
+
// byte-match of the incoming diff against a diff the gateway itself
|
|
9
|
+
// pre-registered when the operator tapped Approve — but it MUST NOT delete the
|
|
10
|
+
// correlation. The single-use delete stays on the real
|
|
11
|
+
// `request_config_approval` auto-resolve, so a peek can never consume the
|
|
12
|
+
// consent (and a rate-limit bypass can never spend a correlation that the
|
|
13
|
+
// actual persist then needs).
|
|
14
|
+
//
|
|
15
|
+
// Extracted as a pure function (stores + helpers injected) so the exact
|
|
16
|
+
// contract — true only for a registered byte-exact pair, and NEVER a mutation —
|
|
17
|
+
// is unit-testable without importing the whole gateway module.
|
|
18
|
+
|
|
19
|
+
/** Minimal read surface this predicate needs from a correlation store. It only
|
|
20
|
+
* ever calls `sweep` (evict-expired, which is not a semantic mutation of a
|
|
21
|
+
* live entry) and `get` (read). It deliberately has NO `delete`/`set` so a
|
|
22
|
+
* mis-edit that mutates the store won't typecheck here. */
|
|
23
|
+
export interface ReadonlyCorrelationStore {
|
|
24
|
+
sweep(now: number): void;
|
|
25
|
+
get(key: string): { unifiedDiff: string } | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PreApprovalCheckDeps {
|
|
29
|
+
/** "🔁 Always allow" correlations, keyed `${agentName}::${rule}`. */
|
|
30
|
+
alwaysAllow: ReadonlyCorrelationStore;
|
|
31
|
+
/** Mental-model-proposal correlations, keyed by mentalModelCorrelationKey. */
|
|
32
|
+
mentalModel: ReadonlyCorrelationStore;
|
|
33
|
+
/** Locate a CANDIDATE always-allow entry by the rule token the diff adds.
|
|
34
|
+
* Shape-based, NOT the security gate — the gate is the byte-match below. */
|
|
35
|
+
extractAddedAllowRule: (unifiedDiff: string) => string | null;
|
|
36
|
+
/** Compose the mental-model correlation key for `(agent, diff)`. */
|
|
37
|
+
mentalModelCorrelationKey: (agentName: string, unifiedDiff: string) => string;
|
|
38
|
+
/** Clock seam (defaults to Date.now()). */
|
|
39
|
+
now?: () => number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns true iff `(agentName, unifiedDiff)` byte-matches a still-live
|
|
44
|
+
* pre-registered correlation (always-allow OR mental-model). Read-only: it
|
|
45
|
+
* never deletes a correlation. A forged diff — the consented token under a
|
|
46
|
+
* different field, or ANY byte difference — finds no byte-exact entry and
|
|
47
|
+
* returns false, so hostd falls back to the ordinary rate-limited path.
|
|
48
|
+
*/
|
|
49
|
+
export function isDiffPreApproved(
|
|
50
|
+
agentName: string,
|
|
51
|
+
unifiedDiff: string,
|
|
52
|
+
deps: PreApprovalCheckDeps,
|
|
53
|
+
): boolean {
|
|
54
|
+
const now = (deps.now ?? Date.now)();
|
|
55
|
+
|
|
56
|
+
// "🔁 Always allow": locate a candidate by the added rule token, then require
|
|
57
|
+
// an EXACT byte-match of the diff (the token match alone is NOT the gate).
|
|
58
|
+
deps.alwaysAllow.sweep(now);
|
|
59
|
+
const added = deps.extractAddedAllowRule(unifiedDiff);
|
|
60
|
+
if (added) {
|
|
61
|
+
const entry = deps.alwaysAllow.get(`${agentName}::${added}`);
|
|
62
|
+
if (entry && entry.unifiedDiff === unifiedDiff) return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Mental-model proposal the operator approved on the proposal card: byte-exact
|
|
66
|
+
// match against the pre-registered diff (the security gate).
|
|
67
|
+
deps.mentalModel.sweep(now);
|
|
68
|
+
const mmEntry = deps.mentalModel.get(
|
|
69
|
+
deps.mentalModelCorrelationKey(agentName, unifiedDiff),
|
|
70
|
+
);
|
|
71
|
+
if (mmEntry && mmEntry.unifiedDiff === unifiedDiff) return true;
|
|
72
|
+
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queued-card-store.ts — durable handle for the component-5 queued-status
|
|
3
|
+
* placeholder and the #2995 mid-flight busy-ack card, so a gateway restart
|
|
4
|
+
* mid-turn can reap the orphaned card instead of leaving a permanent stale
|
|
5
|
+
* "⏳ Queued…" line in the chat.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists (#3002): both cards track their sent Telegram message id ONLY
|
|
8
|
+
* in the in-memory `queuedStatusMsgIds` Map in gateway.ts. A gateway/container
|
|
9
|
+
* restart between posting a card and its in-process promote/reap empties that
|
|
10
|
+
* Map, so the card is stranded: a permanent "⏳ Queued — replying in another
|
|
11
|
+
* topic first…" (or busy-ack) line that never gets promoted or deleted, sitting
|
|
12
|
+
* above the real answer.
|
|
13
|
+
*
|
|
14
|
+
* The fix mirrors `activity-card-store.ts` / `status-pin-store.ts` in shape: a
|
|
15
|
+
* tiny durable snapshot on the per-agent state volume (STATE_DIR), written
|
|
16
|
+
* whenever a card is POSTED, cleared the moment it is reaped/deleted in-process.
|
|
17
|
+
* On boot, AFTER this gateway wins the startup mutex (same ordering constraint
|
|
18
|
+
* as the status-pin/activity-card cleanups — this is a shared per-agent file, so
|
|
19
|
+
* a losing double-boot must never touch it), a one-shot reaper DELETES any
|
|
20
|
+
* leftover card via the Bot API and clears the store.
|
|
21
|
+
*
|
|
22
|
+
* DELETION is the honest terminal (deliberate — reap-on-boot only, NO
|
|
23
|
+
* promote-across-restart): a "Queued" / "On it — replying now" claim is ALWAYS
|
|
24
|
+
* wrong after a restart (the turn it referred to is over or being re-run from
|
|
25
|
+
* scratch), so the only correct move is to remove the card. We do NOT attempt to
|
|
26
|
+
* re-promote or re-adopt it across the restart; the resumed/replayed turn owns
|
|
27
|
+
* fresh surfaces.
|
|
28
|
+
*
|
|
29
|
+
* Crash-safety: write-tmp + atomic rename (identical convention to
|
|
30
|
+
* activity-card-store.ts / status-pin-store.ts). A corrupt or unreadable file
|
|
31
|
+
* fails open to `[]` — never crashes boot, worst case an orphan isn't reaped
|
|
32
|
+
* this boot, no worse than pre-fix behaviour.
|
|
33
|
+
*
|
|
34
|
+
* IDEMPOTENCY: the reaper deletes each record from the on-disk store BEFORE
|
|
35
|
+
* attempting its Telegram delete (delete-first, mirroring the activity-card
|
|
36
|
+
* reaper), so a crash mid-reap or a second boot re-reading the file can never
|
|
37
|
+
* re-issue a delete for a message already handled. A Telegram delete of an
|
|
38
|
+
* already-gone message is itself harmless (the caller tolerates "message not
|
|
39
|
+
* found"); delete-first simply keeps the store honest.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export interface QueuedCardStoreFsSeam {
|
|
43
|
+
readFileSync: (path: string) => string
|
|
44
|
+
writeFileSync: (path: string, data: string) => void
|
|
45
|
+
/** Atomic same-dir replace (POSIX rename) so a crash mid-write can't tear
|
|
46
|
+
* the snapshot. */
|
|
47
|
+
renameSync: (from: string, to: string) => void
|
|
48
|
+
existsSync: (path: string) => boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One persisted queued/busy-ack card: enough to delete the orphaned Telegram
|
|
52
|
+
* message on a future boot, without any live turn state. */
|
|
53
|
+
export interface QueuedCardRecord {
|
|
54
|
+
/** Stable per-topic key (`statusKey(chatId, threadId)` shape) — also the
|
|
55
|
+
* upsert key: a fresh post for the same topic replaces any stale row. */
|
|
56
|
+
key: string
|
|
57
|
+
chatId: string
|
|
58
|
+
/** Forum topic id, or null for a DM / General-topic card. */
|
|
59
|
+
threadId: number | null
|
|
60
|
+
messageId: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface SnapshotEnvelope {
|
|
64
|
+
v: 1
|
|
65
|
+
cards: QueuedCardRecord[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isCardRow(x: unknown): x is QueuedCardRecord {
|
|
69
|
+
if (x == null || typeof x !== 'object') return false
|
|
70
|
+
const o = x as Record<string, unknown>
|
|
71
|
+
return (
|
|
72
|
+
typeof o.key === 'string' &&
|
|
73
|
+
o.key.length > 0 &&
|
|
74
|
+
typeof o.chatId === 'string' &&
|
|
75
|
+
o.chatId.length > 0 &&
|
|
76
|
+
(o.threadId === null || typeof o.threadId === 'number') &&
|
|
77
|
+
typeof o.messageId === 'number'
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Load the persisted card set. Returns [] on a missing, unreadable, or
|
|
83
|
+
* malformed file — fail-open, never throws.
|
|
84
|
+
*/
|
|
85
|
+
export function loadQueuedCards(
|
|
86
|
+
path: string,
|
|
87
|
+
fs: QueuedCardStoreFsSeam,
|
|
88
|
+
): QueuedCardRecord[] {
|
|
89
|
+
if (!fs.existsSync(path)) return []
|
|
90
|
+
let raw = ''
|
|
91
|
+
try {
|
|
92
|
+
raw = fs.readFileSync(path)
|
|
93
|
+
} catch {
|
|
94
|
+
return []
|
|
95
|
+
}
|
|
96
|
+
let parsed: unknown
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(raw)
|
|
99
|
+
} catch {
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
if (parsed == null || typeof parsed !== 'object') return []
|
|
103
|
+
const env = parsed as Record<string, unknown>
|
|
104
|
+
if (env.v !== 1 || !Array.isArray(env.cards)) return []
|
|
105
|
+
return env.cards.filter(isCardRow)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Persist the card set atomically (write sibling tmp → rename). Never throws —
|
|
110
|
+
* a write failure is logged and the store degrades to in-memory-only for this
|
|
111
|
+
* process (same contract as `activity-card-store.ts`).
|
|
112
|
+
*/
|
|
113
|
+
export function persistQueuedCards(
|
|
114
|
+
path: string,
|
|
115
|
+
fs: QueuedCardStoreFsSeam,
|
|
116
|
+
snapshot: readonly QueuedCardRecord[],
|
|
117
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
118
|
+
): void {
|
|
119
|
+
const env: SnapshotEnvelope = { v: 1, cards: [...snapshot] }
|
|
120
|
+
const tmp = path + '.tmp'
|
|
121
|
+
try {
|
|
122
|
+
fs.writeFileSync(tmp, JSON.stringify(env))
|
|
123
|
+
fs.renameSync(tmp, path)
|
|
124
|
+
} catch (err) {
|
|
125
|
+
log(
|
|
126
|
+
`queued-card-store: persist FAILED path=${path}: ${(err as Error).message} — ` +
|
|
127
|
+
`durability degraded to in-memory\n`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Upsert the card row for `key` (replacing any stale row for the same topic).
|
|
134
|
+
* Called the moment a queued-status / busy-ack card is POSTED (its message id
|
|
135
|
+
* is recorded into the in-memory `queuedStatusMsgIds` Map).
|
|
136
|
+
*/
|
|
137
|
+
export function writeQueuedCardRecord(
|
|
138
|
+
path: string,
|
|
139
|
+
fs: QueuedCardStoreFsSeam,
|
|
140
|
+
record: QueuedCardRecord,
|
|
141
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
142
|
+
): void {
|
|
143
|
+
const current = loadQueuedCards(path, fs)
|
|
144
|
+
const others = current.filter((c) => c.key !== record.key)
|
|
145
|
+
persistQueuedCards(path, fs, [...others, record], log)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Remove the card row for `key`, if present. Called the moment the card is
|
|
150
|
+
* reaped/deleted in-process (reapQueuedStatus), and by the boot reaper (BEFORE
|
|
151
|
+
* attempting the Telegram delete — the idempotency guard).
|
|
152
|
+
*
|
|
153
|
+
* When `messageId` is supplied, only a row matching BOTH `key` AND `messageId`
|
|
154
|
+
* is removed (reap-race guard): during a slow multi-record reap a fresh live
|
|
155
|
+
* turn could post a NEW card under the same key (its own, different messageId);
|
|
156
|
+
* a key-only clear would then drop the live card's durable protection. Omit
|
|
157
|
+
* `messageId` only when you mean "clear whatever row exists for this topic".
|
|
158
|
+
*/
|
|
159
|
+
export function clearQueuedCardRecord(
|
|
160
|
+
path: string,
|
|
161
|
+
fs: QueuedCardStoreFsSeam,
|
|
162
|
+
key: string,
|
|
163
|
+
messageId?: number,
|
|
164
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
165
|
+
): void {
|
|
166
|
+
const current = loadQueuedCards(path, fs)
|
|
167
|
+
if (current.length === 0) return
|
|
168
|
+
const next = current.filter(
|
|
169
|
+
(c) => c.key !== key || (messageId !== undefined && c.messageId !== messageId),
|
|
170
|
+
)
|
|
171
|
+
if (next.length === current.length) return
|
|
172
|
+
persistQueuedCards(path, fs, next, log)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Boot-time orphan reaper, extracted as a pure routine over injected seams
|
|
177
|
+
* (mirrors `runActivityCardBootReaper`). The gateway's thin wrapper binds the
|
|
178
|
+
* live fs, a Telegram delete call, and the logger.
|
|
179
|
+
*
|
|
180
|
+
* CRITICAL ordering, identical constraint to the status-pin / activity-card
|
|
181
|
+
* cleanups: the caller MUST only invoke this AFTER winning the startup mutex.
|
|
182
|
+
* The store is a shared per-agent file; a losing double-boot running this would
|
|
183
|
+
* delete a card the still-alive holder's in-flight turn still legitimately owns.
|
|
184
|
+
*
|
|
185
|
+
* Deletes each record from disk BEFORE attempting its Telegram delete (not
|
|
186
|
+
* after), so a crash mid-reap, or a second boot re-reading the file, can never
|
|
187
|
+
* re-issue a delete. A `deleteCard` failure (message already gone, chat
|
|
188
|
+
* unreachable, permissions changed) is swallowed — non-fatal, logged, and never
|
|
189
|
+
* re-attempted since the record is already gone. Returns counts for
|
|
190
|
+
* logging/testing.
|
|
191
|
+
*/
|
|
192
|
+
export async function runQueuedCardBootReaper(args: {
|
|
193
|
+
path: string
|
|
194
|
+
fs: QueuedCardStoreFsSeam
|
|
195
|
+
deleteCard: (record: QueuedCardRecord) => Promise<unknown>
|
|
196
|
+
log?: (line: string) => void
|
|
197
|
+
}): Promise<{ deleted: number; total: number }> {
|
|
198
|
+
const log = args.log ?? ((l: string) => process.stderr.write(l))
|
|
199
|
+
const persisted = loadQueuedCards(args.path, args.fs)
|
|
200
|
+
if (persisted.length === 0) return { deleted: 0, total: 0 }
|
|
201
|
+
let deleted = 0
|
|
202
|
+
for (const record of persisted) {
|
|
203
|
+
// Delete BEFORE attempting the Telegram delete — the idempotency guard.
|
|
204
|
+
clearQueuedCardRecord(args.path, args.fs, record.key, record.messageId, log)
|
|
205
|
+
try {
|
|
206
|
+
await args.deleteCard(record)
|
|
207
|
+
deleted++
|
|
208
|
+
} catch (err) {
|
|
209
|
+
log(
|
|
210
|
+
`queued-card-store: boot reaper delete failed ` +
|
|
211
|
+
`(chat=${record.chatId} msg=${record.messageId}): ` +
|
|
212
|
+
`${(err as Error).message}\n`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { deleted, total: persisted.length }
|
|
217
|
+
}
|
|
@@ -40,6 +40,8 @@ import { isValidModelArg } from './model-command.js'
|
|
|
40
40
|
export const SESSION_MODEL_FILE = '.session-model'
|
|
41
41
|
export const RELAUNCH_MODEL_INTENT_FILE = '.relaunch-model-intent'
|
|
42
42
|
export const CONFIGURED_DEFAULT_MODEL_FILE = '.configured-default-model'
|
|
43
|
+
/** Crashloop self-heal counter (start.sh stamps `<count> <epoch>` per fast boot). */
|
|
44
|
+
export const SESSION_MODEL_BOOT_ATTEMPTS_FILE = '.session-model-boot-attempts'
|
|
43
45
|
|
|
44
46
|
export type RelaunchModelIntent = 'keep' | 'revert'
|
|
45
47
|
|
|
@@ -142,7 +144,30 @@ export function clearSessionModelFile(agentDir: string): void {
|
|
|
142
144
|
// #3042 item 4: also drop the kept-alert dedup sentinel so a future
|
|
143
145
|
// override of the same name re-alerts on its first kept boot.
|
|
144
146
|
rmSync(join(agentDir, '.session-model-kept-notified'), { force: true })
|
|
145
|
-
rmSync(join(agentDir,
|
|
147
|
+
rmSync(join(agentDir, SESSION_MODEL_BOOT_ATTEMPTS_FILE), { force: true })
|
|
148
|
+
} catch {
|
|
149
|
+
/* best-effort */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* #3043 item 2: clear ONLY the crashloop boot-attempts counter — a positive
|
|
155
|
+
* health signal from the gateway, not a carrier change.
|
|
156
|
+
*
|
|
157
|
+
* start.sh's self-heal (start.sh.hbs "Override crashloop self-heal") increments
|
|
158
|
+
* the counter on every boot that re-enters within 150s with the override still
|
|
159
|
+
* active, and clears a healthy override after 3 fast boots. That window can't
|
|
160
|
+
* tell a genuine crashloop from three quick OPERATOR hand-bounces of a healthy
|
|
161
|
+
* agent — both look like fast successive boots — so three deliberate restarts
|
|
162
|
+
* would spuriously wipe a working override. A boot that reaches bridge
|
|
163
|
+
* registration is proven healthy (the session came all the way up and the
|
|
164
|
+
* bridge connected), so the gateway deletes the counter there. Only boots that
|
|
165
|
+
* genuinely FAIL before the bridge registers now accumulate toward the 3-strike
|
|
166
|
+
* clear. Best-effort; absent file is fine.
|
|
167
|
+
*/
|
|
168
|
+
export function clearSessionModelBootAttempts(agentDir: string): void {
|
|
169
|
+
try {
|
|
170
|
+
rmSync(join(agentDir, SESSION_MODEL_BOOT_ATTEMPTS_FILE), { force: true })
|
|
146
171
|
} catch {
|
|
147
172
|
/* best-effort */
|
|
148
173
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turn-end gate-wedge backstop (#2094 finding 1).
|
|
3
|
+
*
|
|
4
|
+
* The `turn_end` session-event handler in `gateway.ts` runs a long
|
|
5
|
+
* synchronous prelude (narrative dedup, answer-stream finalize,
|
|
6
|
+
* `redactOutboundText`, `progressDriver?.takeOverCard`, …) before it reaches
|
|
7
|
+
* the branch that calls `endCurrentTurnAtomic` → `purgeReactionTracking` —
|
|
8
|
+
* the canonical op that deletes this turn's `activeTurnStartedAt` entry and
|
|
9
|
+
* re-opens the #1556 inbound buffer gate.
|
|
10
|
+
*
|
|
11
|
+
* If ANY of those pre-purge ops throws, the handler exits before the canonical
|
|
12
|
+
* purge runs, leaving `activeTurnStartedAt` (and the mirrored `claudeBusyKeys`
|
|
13
|
+
* that `purgeReactionTracking` drains) populated. The #1556 inbound gate then
|
|
14
|
+
* wedges CLOSED: every subsequent inbound buffers, and only the 300 s
|
|
15
|
+
* silence-poke fallback eventually recovers it (degraded, not permanent).
|
|
16
|
+
*
|
|
17
|
+
* `withTurnEndGateBackstop` wraps the handler body in a guarded `try/finally`.
|
|
18
|
+
* The `finally` acts ONLY when a throw skipped the canonical purge — detected
|
|
19
|
+
* by the turn's key still being present in `activeTurnStartedAt`. On the happy
|
|
20
|
+
* path the canonical purge already deleted the key, so the backstop is a
|
|
21
|
+
* no-op. The original error is NOT swallowed: `finally` re-raises it after the
|
|
22
|
+
* backstop purge, so upstream error handling / unhandled-rejection policy is
|
|
23
|
+
* unchanged.
|
|
24
|
+
*/
|
|
25
|
+
export interface TurnEndGateBackstopDeps<T> {
|
|
26
|
+
/** True while `key`'s `activeTurnStartedAt` entry is still populated. */
|
|
27
|
+
hasActiveTurn: (key: string) => boolean
|
|
28
|
+
/** The canonical purge (production wires this to `purgeReactionTracking`). */
|
|
29
|
+
purge: (key: string, endingTurn: T | undefined) => void
|
|
30
|
+
/** Optional structured log emitted only when the backstop actually fires. */
|
|
31
|
+
log?: (msg: string) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run `body` (the synchronous turn_end handler body) under a guarded finally.
|
|
36
|
+
*
|
|
37
|
+
* @param key the turn's status key, or null when there was no live
|
|
38
|
+
* turn to end (then the backstop can never fire).
|
|
39
|
+
* @param endingTurn the captured turn atom, forwarded to `purge` so the
|
|
40
|
+
* shadow trace sees the authoritative `replyCalled` flag.
|
|
41
|
+
*/
|
|
42
|
+
export function withTurnEndGateBackstop<T>(
|
|
43
|
+
key: string | null,
|
|
44
|
+
endingTurn: T | null,
|
|
45
|
+
body: () => void,
|
|
46
|
+
deps: TurnEndGateBackstopDeps<T>,
|
|
47
|
+
): void {
|
|
48
|
+
try {
|
|
49
|
+
body()
|
|
50
|
+
} finally {
|
|
51
|
+
if (key != null && deps.hasActiveTurn(key)) {
|
|
52
|
+
deps.log?.(
|
|
53
|
+
`telegram gateway: turn_end backstop-purge — a pre-purge throw left ` +
|
|
54
|
+
`the #1556 inbound gate wedged (key=${key}); forcing purge (#2094)`,
|
|
55
|
+
)
|
|
56
|
+
deps.purge(key, endingTurn ?? undefined)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// #1667 — pure decision core for the `turn_end` handler's answer-delivery
|
|
2
|
+
// gate (originally landed inline in `gateway.ts` by #1664).
|
|
3
|
+
//
|
|
4
|
+
// The gateway's `case 'turn_end':` block resolves, in strict order, which of
|
|
5
|
+
// four terminal dispositions a just-ended turn takes:
|
|
6
|
+
//
|
|
7
|
+
// 1. silent-marker suppression — the model's only output was a silent-turn
|
|
8
|
+
// sentinel (NO_REPLY / HEARTBEAT_OK, or composite/ trailing silent
|
|
9
|
+
// noise). `decideTurnFlush` returns `{kind:'skip', reason:'silent-marker'}`.
|
|
10
|
+
// The gateway suppresses the card without a "Done" edit and returns.
|
|
11
|
+
// 2. turn-flush — the model skipped reply/stream_reply but
|
|
12
|
+
// left substantive terminal prose; `decideTurnFlush` returns
|
|
13
|
+
// `{kind:'flush', text}`. The gateway delivers that prose as the answer
|
|
14
|
+
// (setting `finalAnswerDelivered = true`) and returns.
|
|
15
|
+
// 3. re-prompt (#1664 gate) — the reply-called tail. The turn neither
|
|
16
|
+
// suppressed nor flushed, and `finalAnswerDelivered === false`: the model
|
|
17
|
+
// sent only an interim ack (or nothing) and never delivered a real
|
|
18
|
+
// answer. The silent-end machinery re-prompts so the answer lands.
|
|
19
|
+
// 4. none — the reply-called tail with
|
|
20
|
+
// `finalAnswerDelivered === true`: a genuine final answer was delivered;
|
|
21
|
+
// no re-prompt.
|
|
22
|
+
//
|
|
23
|
+
// This module hoists that ordering into a single pure function so it is
|
|
24
|
+
// testable against the code the gateway actually runs (the gateway's
|
|
25
|
+
// `turn_end` case delegates to `decideTurnEndGate`). It is a strictly
|
|
26
|
+
// behaviour-preserving extraction — the exact conditionals and their order,
|
|
27
|
+
// no logic change.
|
|
28
|
+
//
|
|
29
|
+
// What deliberately remains inline in the gateway (side effects, not
|
|
30
|
+
// decisions): the synthetic-backstop `durationMs === -1` suppression, the
|
|
31
|
+
// `turn == null` guard, the answer-stream materialize/retract block (which may
|
|
32
|
+
// set `finalAnswerDelivered = true` BEFORE this gate is consulted), and every
|
|
33
|
+
// disposition's actual teardown / send / telemetry. Those are not part of the
|
|
34
|
+
// decision — they are what each decision drives. The gate is computed once,
|
|
35
|
+
// immediately after `flushDecision`, reading `finalAnswerDelivered` at its
|
|
36
|
+
// tail value (the answer-stream branch has already run by then; the flush
|
|
37
|
+
// branch, which also sets it, is not yet entered and does not influence the
|
|
38
|
+
// gate's own outcome).
|
|
39
|
+
|
|
40
|
+
import type { FlushDecision } from '../turn-flush-safety.js'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The terminal disposition of a `turn_end`, in the gateway's evaluation order.
|
|
44
|
+
* - `silent_end` → silent-marker suppression path (no reply, no card edit).
|
|
45
|
+
* - `flush` → turn-flush delivers the model's terminal prose as the answer.
|
|
46
|
+
* - `reprompt` → #1664 gate: no final answer delivered → silent-end re-prompt.
|
|
47
|
+
* - `none` → final answer delivered; normal terminal, no re-prompt.
|
|
48
|
+
*/
|
|
49
|
+
export type TurnEndDecision = 'silent_end' | 'flush' | 'reprompt' | 'none'
|
|
50
|
+
|
|
51
|
+
export interface TurnEndGateSnapshot {
|
|
52
|
+
/** The result of `decideTurnFlush(...)` for this turn (or the synthetic
|
|
53
|
+
* `{kind:'skip', reason:'reply-called'}` the gateway substitutes when the
|
|
54
|
+
* answer-stream already materialised the answer). */
|
|
55
|
+
flushDecision: FlushDecision
|
|
56
|
+
/**
|
|
57
|
+
* `turn.finalAnswerDelivered` at the point the gate is consulted — i.e. after
|
|
58
|
+
* the answer-stream materialize branch may have set it true, but reflecting
|
|
59
|
+
* whether a genuine final answer (not a bare interim ack) has been delivered.
|
|
60
|
+
* Only consulted on the reply-called tail (neither silent-marker nor flush).
|
|
61
|
+
*/
|
|
62
|
+
finalAnswerDelivered: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Pure decision core for the `turn_end` answer-delivery gate. Mirrors the
|
|
67
|
+
* gateway's branch ordering exactly: silent-marker early-return, then
|
|
68
|
+
* turn-flush early-return, then the `finalAnswerDelivered === false` re-prompt
|
|
69
|
+
* gate, else no re-prompt. No side effects.
|
|
70
|
+
*/
|
|
71
|
+
export function decideTurnEndGate(snapshot: TurnEndGateSnapshot): TurnEndDecision {
|
|
72
|
+
const { flushDecision, finalAnswerDelivered } = snapshot
|
|
73
|
+
|
|
74
|
+
// 1. Silent-marker suppression takes precedence: the model's only output was
|
|
75
|
+
// a silent-turn sentinel. Suppress without a card edit; never re-prompt.
|
|
76
|
+
if (flushDecision.kind === 'skip' && flushDecision.reason === 'silent-marker') {
|
|
77
|
+
return 'silent_end'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Turn-flush: the model left substantive terminal prose without calling
|
|
81
|
+
// the reply tool. Deliver it as the answer; the turn IS answered.
|
|
82
|
+
if (flushDecision.kind === 'flush') {
|
|
83
|
+
return 'flush'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3. Reply-called tail. #1664: the trigger is "no final answer delivered",
|
|
87
|
+
// not "zero outbound". An interim-ack-only turn (reply called, but
|
|
88
|
+
// finalAnswerDelivered still false) re-prompts; a delivered final answer
|
|
89
|
+
// does not. A zero-outbound turn also lands here with
|
|
90
|
+
// finalAnswerDelivered === false and re-prompts.
|
|
91
|
+
if (finalAnswerDelivered === false) {
|
|
92
|
+
return 'reprompt'
|
|
93
|
+
}
|
|
94
|
+
return 'none'
|
|
95
|
+
}
|
|
@@ -16,8 +16,16 @@
|
|
|
16
16
|
* a mid-turn reply's `finally { stopTypingLoop }` would kill it and the chat
|
|
17
17
|
* would go dark for the rest of the turn. A dedicated map makes the turn loop
|
|
18
18
|
* structurally immune to those stops — only `stop` (the canonical turn-end)
|
|
19
|
-
* clears it.
|
|
20
|
-
*
|
|
19
|
+
* clears it.
|
|
20
|
+
*
|
|
21
|
+
* The map is separate; the SENDS are not (#3084). This file used to claim the
|
|
22
|
+
* redundant pings were "harmless — same action, and `sendChatAction` is cheap".
|
|
23
|
+
* They are not cheap: they spend the per-bot flood budget the REPLIES need, and
|
|
24
|
+
* on 2026-07-11 the two loops together earned a 4.6-hour flood ban. The gateway
|
|
25
|
+
* now injects a `sendChatAction` that routes through the SHARED typing emitter
|
|
26
|
+
* (`typing-emitter.ts`), which enforces one action per chat key per refresh
|
|
27
|
+
* window across BOTH loops. This factory keeps its lifecycle semantics —
|
|
28
|
+
* fire-on-start, refresh, stop-at-turn-end — and the floor lives in the emitter.
|
|
21
29
|
*
|
|
22
30
|
* Extracted into a factory so the lifecycle (fires on start, refreshes on the
|
|
23
31
|
* interval, stops on turn-end, NEVER leaks a refresh interval after the turn
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
import { GrammyError, HttpError } from 'grammy'
|
|
14
14
|
|
|
15
|
+
import { FLOOD_WAIT_ACTIVE } from '../retry-api-call.js'
|
|
16
|
+
|
|
15
17
|
export type RejectionAction = 'shutdown' | 'log_only'
|
|
16
18
|
|
|
17
19
|
export interface RejectionPolicyOptions {
|
|
@@ -65,6 +67,17 @@ export function classifyRejection(
|
|
|
65
67
|
: err instanceof HttpError
|
|
66
68
|
if (isHttp) return 'log_only'
|
|
67
69
|
|
|
70
|
+
// FLOOD_WAIT_ACTIVE (#3084): retry-api-call throws this plain Error marker
|
|
71
|
+
// instead of sleeping through a multi-hour per-bot flood ban (overlord saw
|
|
72
|
+
// retry_after = 16739s ≈ 4.6h). It is the SAME condition as the GrammyError
|
|
73
|
+
// 429 handled below — just surfaced as a non-retryable marker rather than a
|
|
74
|
+
// raw grammy error — so it takes the same posture. A leaked one (a
|
|
75
|
+
// fire-and-forget send that wasn't wrapped in swallowingApiCall) must NOT
|
|
76
|
+
// crash the gateway: the bot is already rate-limited, and a crash→restart
|
|
77
|
+
// fires MORE sends into the open window and extends the ban, which is the
|
|
78
|
+
// exact amplification the #2923 circuit breaker exists to stop.
|
|
79
|
+
if (err instanceof Error && err.message === FLOOD_WAIT_ACTIVE) return 'log_only'
|
|
80
|
+
|
|
68
81
|
if (!isGrammy) return 'shutdown'
|
|
69
82
|
|
|
70
83
|
const e = err as { error_code?: number; description?: string }
|