switchroom 0.19.22 → 0.19.24
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 +5 -2
- package/dist/auth-broker/index.js +95 -2
- package/dist/cli/notion-write-pretool.mjs +5 -2
- package/dist/cli/switchroom.js +749 -357
- package/dist/host-control/main.js +96 -3
- package/dist/vault/approvals/kernel-server.js +98 -5
- package/dist/vault/broker/server.js +98 -5
- package/package.json +5 -4
- package/profiles/_base/start.sh.hbs +101 -0
- package/profiles/_shared/agent-self-service.md.hbs +64 -109
- package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
- package/profiles/_shared/dev-protocol.md.hbs +12 -42
- package/profiles/_shared/execution-discipline.md.hbs +7 -14
- package/profiles/coding/CLAUDE.md.hbs +0 -6
- package/profiles/default/CLAUDE.md.hbs +21 -50
- package/skills/dev-protocol/SKILL.md +97 -107
- package/skills/switchroom-release/SKILL.md +2 -1
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/dist/gateway/gateway.js +267 -52
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +43 -42
- package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +61 -22
- package/telegram-plugin/gateway/stream-render.ts +6 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
- package/telegram-plugin/gateway/turn-end.ts +1 -1
- package/telegram-plugin/gateway/turn-record-status.ts +19 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
- package/telegram-plugin/reply-owner-resolve.ts +110 -9
- package/telegram-plugin/send-gate-degraded.test.ts +45 -16
- package/telegram-plugin/send-gate.ts +185 -24
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
- package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
- package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
- package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
- package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
- package/vendor/hindsight-memory/tests/test_pending.py +12 -4
|
@@ -114,8 +114,10 @@ export class BackstopDeliveryLedger {
|
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
116
|
* #3278 — transition a landed-unconfirmed chunk to `landed-confirmed` after a
|
|
117
|
-
* read-back probe proved the message exists in the chat.
|
|
118
|
-
*
|
|
117
|
+
* read-back probe proved the message exists in the chat. Confirmation is the
|
|
118
|
+
* STRONGER of the two delivery states; the weaker `landed-unconfirmed` also
|
|
119
|
+
* counts as delivered (an inconclusive probe is not a failure — see
|
|
120
|
+
* `runBackstopDelivery`). Only a POSITIVE absence (`demoteChunk`) un-delivers.
|
|
119
121
|
*/
|
|
120
122
|
confirmChunk(turnId: string, index: number): void {
|
|
121
123
|
let set = this.confirmed.get(turnId)
|
|
@@ -164,8 +166,14 @@ export class BackstopDeliveryLedger {
|
|
|
164
166
|
return true
|
|
165
167
|
}
|
|
166
168
|
|
|
167
|
-
/** Landed message ids of CONFIRMED chunks only, in chunk-index order — the
|
|
168
|
-
*
|
|
169
|
+
/** Landed message ids of CONFIRMED chunks only, in chunk-index order — the
|
|
170
|
+
* read-back view of the ledger (ids proven present in the chat). The delivery
|
|
171
|
+
* predicate counts LANDED ids (`sentIds`), not these; this is the stronger
|
|
172
|
+
* proven-present subset. Its production consumer is
|
|
173
|
+
* {@link BackstopDeliveryResult.landedUnconfirmedIds} — `sentIds` minus these
|
|
174
|
+
* is the landed-but-uncorroborated set that reaches `turns.jsonl` as
|
|
175
|
+
* `landed_unconfirmed`, the counter that measures whether this module's
|
|
176
|
+
* optimism about an inconclusive probe is ever wrong. */
|
|
169
177
|
confirmedIds(turnId: string): number[] {
|
|
170
178
|
const m = this.chunks.get(turnId)
|
|
171
179
|
const set = this.confirmed.get(turnId)
|
|
@@ -226,9 +234,11 @@ export class BackstopDeliveryLedger {
|
|
|
226
234
|
* `landed-confirmed` (counts toward delivery).
|
|
227
235
|
* - `absent` — Telegram `400 message to edit not found` ⇒ positive absence
|
|
228
236
|
* ⇒ demote to `unsent` (safe to re-send — it never landed).
|
|
229
|
-
* - `ambiguous` — 429 / 5xx / network / gate-shed / anything else
|
|
230
|
-
* `landed-unconfirmed`; NEVER re-send (a re-send would
|
|
231
|
-
* duplicate, and duplicate-risk beats missing-risk
|
|
237
|
+
* - `ambiguous` — 429 / 5xx / network / gate-shed / gate no-op / anything else
|
|
238
|
+
* ⇒ leave `landed-unconfirmed`; NEVER re-send (a re-send would
|
|
239
|
+
* risk a duplicate, and duplicate-risk beats missing-risk
|
|
240
|
+
* here) and NEVER count it as a delivery failure either — the
|
|
241
|
+
* probe established nothing, so the landed-id evidence stands.
|
|
232
242
|
*
|
|
233
243
|
* NOTE (honest limitation, #3278 §1.4): a passing probe proves only that the
|
|
234
244
|
* message EXISTS at that chat_id. It does NOT prove the human's client rendered
|
|
@@ -322,8 +332,36 @@ export interface BackstopDeliveryResult {
|
|
|
322
332
|
sentIds: number[]
|
|
323
333
|
/** Number of input chunks the answer was split into. */
|
|
324
334
|
chunkCount: number
|
|
325
|
-
/**
|
|
335
|
+
/**
|
|
336
|
+
* True IFF every chunk landed at least one message id AND at least one of
|
|
337
|
+
* them is a fresh non-card chat id. This is the DELIVERY verdict the turn
|
|
338
|
+
* record, the obligation ledger and the status reaction key on.
|
|
339
|
+
*
|
|
340
|
+
* A read-back probe can only ever LOWER it, and only on POSITIVE absence: an
|
|
341
|
+
* `absent` verdict demotes the chunk back to `unsent`, so it stops counting as
|
|
342
|
+
* landed. An `ambiguous` probe carries no information and therefore does not
|
|
343
|
+
* move this flag — see {@link confirmed}.
|
|
344
|
+
*/
|
|
326
345
|
delivered: boolean
|
|
346
|
+
/**
|
|
347
|
+
* True IFF every chunk was read-back CONFIRMED (`exists`). Strictly stronger
|
|
348
|
+
* than {@link delivered} and purely observational — nothing keys a failure on
|
|
349
|
+
* it. `delivered && !confirmed` is the `landed-unconfirmed` state: the Bot API
|
|
350
|
+
* returned fresh ids for every chunk but the probe could not corroborate them.
|
|
351
|
+
*/
|
|
352
|
+
confirmed: boolean
|
|
353
|
+
/**
|
|
354
|
+
* The landed message ids NO read-back corroborated — `sentIds` minus the
|
|
355
|
+
* confirmed subset, in chunk-index order. Empty when {@link confirmed}.
|
|
356
|
+
*
|
|
357
|
+
* This is the MEASURABLE form of this module's central bet: a landed id whose
|
|
358
|
+
* probe came back inconclusive is counted as delivered. Its count is stamped
|
|
359
|
+
* onto the turn record as `landed_unconfirmed` (see `buildTurnRecord`) so the
|
|
360
|
+
* fleet can tell how often that bet is being made — and, if a
|
|
361
|
+
* `landed_unconfirmed` turn is ever followed by a "you never answered me",
|
|
362
|
+
* that the bet was wrong. Purely observational: nothing keys a failure on it.
|
|
363
|
+
*/
|
|
364
|
+
landedUnconfirmedIds: number[]
|
|
327
365
|
/** How many attempts ran (1..maxAttempts). */
|
|
328
366
|
attempts: number
|
|
329
367
|
/** True when retries were exhausted without full delivery (terminal fail). */
|
|
@@ -345,9 +383,12 @@ export interface BackstopDeliveryResult {
|
|
|
345
383
|
* is read-back-probed via `deps.readBack` (when provided): `exists` confirms it,
|
|
346
384
|
* `absent` demotes it to `unsent` so the NEXT attempt re-sends only that chunk,
|
|
347
385
|
* and `ambiguous` leaves it `landed-unconfirmed` — never re-sent (duplicate-risk
|
|
348
|
-
* beats missing-risk).
|
|
349
|
-
*
|
|
350
|
-
*
|
|
386
|
+
* beats missing-risk). An API-ack'd-but-silently-dropped send (fresh id, absent
|
|
387
|
+
* on read-back) is therefore reported `delivered:false` and the caller leaves
|
|
388
|
+
* the obligation OPEN — a correct mechanism that is INERT UNTIL #3703, because
|
|
389
|
+
* the probe is 100% shed in production and never resolves `absent`. An
|
|
390
|
+
* INCONCLUSIVE probe is not a failure: it leaves `delivered` alone and only
|
|
391
|
+
* clears `confirmed` (see `BackstopDeliveryResult`).
|
|
351
392
|
*
|
|
352
393
|
* `recordOutbound` (when provided) fires ONCE at the end with the full landed
|
|
353
394
|
* set and a `texts` array ALIGNED to the actual sent ids (via `ledger.entries`).
|
|
@@ -449,12 +490,52 @@ export async function runBackstopDelivery(
|
|
|
449
490
|
}
|
|
450
491
|
|
|
451
492
|
const sentIds = ledger.sentIds(turnId)
|
|
452
|
-
|
|
453
|
-
//
|
|
493
|
+
const confirmed = ledger.allConfirmed(turnId, chunkCount)
|
|
494
|
+
// The delivery verdict is EVIDENCE-BASED, not confirmation-gated.
|
|
495
|
+
//
|
|
496
|
+
// #3278 originally required every chunk to be `landed-confirmed`, so an
|
|
497
|
+
// `ambiguous` probe — which by definition establishes nothing — produced
|
|
498
|
+
// `delivered:false`. That inverted the guard it was meant to be: the turn was
|
|
499
|
+
// recorded `send_failed`, the status reaction painted error, and the delivery
|
|
500
|
+
// obligation was left OPEN for a re-present, all for an answer the user had
|
|
501
|
+
// demonstrably received. In production the probe is ambiguous essentially
|
|
502
|
+
// always (it is issued at cosmetic priority in the same millisecond as the
|
|
503
|
+
// send it probes, so the per-chat token bucket sheds it), so this turned a
|
|
504
|
+
// successful backstop delivery into a logged failure ~146 times in two weeks.
|
|
505
|
+
//
|
|
506
|
+
// Absence of evidence is not evidence of absence. The verdict is therefore:
|
|
507
|
+
// every chunk LANDED (guard 6) and at least one landed id is a fresh non-card
|
|
508
|
+
// chat id (the receipt gate, guard 7). A probe can still lower it — an
|
|
509
|
+
// `absent` verdict demotes the chunk to `unsent` above, so it is no longer
|
|
510
|
+
// landed — which keeps #3278's real contribution (a positive absence is
|
|
511
|
+
// caught and re-sent) while an inconclusive probe changes nothing.
|
|
512
|
+
//
|
|
513
|
+
// Honesty about what that contribution is worth TODAY: it is INERT UNTIL
|
|
514
|
+
// #3703. The probe is issued at cosmetic priority in the same millisecond as
|
|
515
|
+
// the send it probes, so the per-chat token bucket sheds it 100% of the time
|
|
516
|
+
// (146 `ambiguous` / 0 `absent` across the live fleet) and the `absent` branch
|
|
517
|
+
// is structurally unreachable in production. The demote-and-re-send path below
|
|
518
|
+
// is a correct mechanism with no live trigger; #3703 is the change that wakes
|
|
519
|
+
// the probe (and must first fix its resplit-overwrite hazard). Do not read
|
|
520
|
+
// this block as "absence is still caught in production" — it is not, yet.
|
|
521
|
+
const allLanded = chunkCount > 0 && ledger.unsentIndices(turnId, chunkCount).length === 0
|
|
454
522
|
const delivered =
|
|
455
|
-
|
|
456
|
-
backstopReceiptIds(ledger.confirmedIds(turnId), cardMessageId).length > 0
|
|
523
|
+
allLanded && backstopReceiptIds(sentIds, cardMessageId).length > 0
|
|
457
524
|
const exhausted = !delivered
|
|
525
|
+
// The landed-but-uncorroborated set (L2/L5): `sentIds` minus the read-back
|
|
526
|
+
// confirmed subset. Surfaced on the result so the caller can stamp
|
|
527
|
+
// `landed_unconfirmed` on the turn record — the only way to measure whether
|
|
528
|
+
// counting an inconclusive probe as delivered is ever wrong.
|
|
529
|
+
const confirmedSet = new Set(ledger.confirmedIds(turnId))
|
|
530
|
+
const landedUnconfirmedIds = sentIds.filter(id => !confirmedSet.has(id))
|
|
531
|
+
if (delivered && !confirmed) {
|
|
532
|
+
stderr(
|
|
533
|
+
`telegram gateway: backstop delivery landed-unconfirmed for turn ${turnId} — ` +
|
|
534
|
+
`every chunk returned a fresh message id but the read-back probe was ` +
|
|
535
|
+
`inconclusive for ${landedUnconfirmedIds.length} of ${sentIds.length} landed ` +
|
|
536
|
+
`id(s); counting it delivered (an ambiguous probe is not a failure)\n`,
|
|
537
|
+
)
|
|
538
|
+
}
|
|
458
539
|
|
|
459
540
|
if (deps.recordOutbound && sentIds.length > 0) {
|
|
460
541
|
const texts: string[] = []
|
|
@@ -468,5 +549,5 @@ export async function runBackstopDelivery(
|
|
|
468
549
|
deps.recordOutbound(ids, texts)
|
|
469
550
|
}
|
|
470
551
|
|
|
471
|
-
return { sentIds, chunkCount, delivered, attempts, exhausted }
|
|
552
|
+
return { sentIds, chunkCount, delivered, confirmed, landedUnconfirmedIds, attempts, exhausted }
|
|
472
553
|
}
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
*
|
|
15
15
|
* ── The fix (design-of-record §2.2) ─────────────────────────────────────────
|
|
16
16
|
* Make the re-present a *byte-identical captured-answer resume* of ONLY the
|
|
17
|
-
* not-yet
|
|
18
|
-
*
|
|
17
|
+
* not-yet-LANDED tail — never a regeneration — so a chunk that already reached
|
|
18
|
+
* the chat is provably never re-posted:
|
|
19
19
|
*
|
|
20
20
|
* 1. On a partial (`!delivered`) turn-flush, persist a {@link
|
|
21
21
|
* CapturedDeliverySnapshot} (the split chunks + each landed chunk's ids and
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
* 2. The obligation sweep's `represent` branch becomes source-aware: when the
|
|
26
26
|
* obligation carries a captured-delivery snapshot it drives THIS resume
|
|
27
27
|
* (re-run `runBackstopDelivery` for the SAME `turnId` and the SAME captured
|
|
28
|
-
* chunks, resuming at the first
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* chunks, resuming at the first UNSENT index) instead of pushing a
|
|
29
|
+
* fresh-generation inbound. No snapshot ⇒ the genuine "model wrote nothing
|
|
30
|
+
* / never fired a backstop" case falls through to fresh generation
|
|
31
31
|
* unchanged.
|
|
32
32
|
* 3. The in-memory ledger is rehydrated from the snapshot AND reconciled against
|
|
33
33
|
* the durable outbound-text oracle (`hasOutboundWithText`), so a chunk that
|
|
@@ -39,9 +39,29 @@
|
|
|
39
39
|
* chunks (not a regenerated answer). #3278's per-chunk state machine
|
|
40
40
|
* (`unsent → pending → landed-unconfirmed → {landed-confirmed | unsent}`) binds
|
|
41
41
|
* here too: the resume re-PROBES `landed-unconfirmed` chunks (never re-sends
|
|
42
|
-
* them) and re-SENDS only `unsent` ones, so a
|
|
42
|
+
* them) and re-SENDS only `unsent` ones, so a landed chunk is never duplicated
|
|
43
43
|
* by either the in-fire retry OR the cross-represent resume.
|
|
44
44
|
*
|
|
45
|
+
* ── The CLOSE condition is LANDED, not confirmed (#3702) ────────────────────
|
|
46
|
+
* `runBackstopDelivery`'s verdict is evidence-based: every chunk landed a
|
|
47
|
+
* message id and at least one is a fresh non-card id. An inconclusive read-back
|
|
48
|
+
* does not lower it (only a POSITIVE `absent`, which demotes the chunk back to
|
|
49
|
+
* `unsent`, does). So a resume over a snapshot whose chunks ALL landed but were
|
|
50
|
+
* never corroborated closes the obligation having sent NOTHING — the re-probe
|
|
51
|
+
* ran, resolved nothing, and the landed-id evidence stood.
|
|
52
|
+
*
|
|
53
|
+
* That is deliberate, not an oversight. The alternative — keep the obligation
|
|
54
|
+
* OPEN because nothing corroborated the ids — has no corrective action
|
|
55
|
+
* available: the resume never re-sends a LANDED chunk, so every subsequent
|
|
56
|
+
* represent would re-run the same shed probe, send zero messages, and burn
|
|
57
|
+
* represent budget until the cap escalates a false "the agent never answered
|
|
58
|
+
* you" nudge to the operator. Representing cannot deliver anything the first
|
|
59
|
+
* delivery did not; the only thing it can produce is noise. When the durable
|
|
60
|
+
* outbound-text oracle CAN corroborate (history enabled + the row present) it
|
|
61
|
+
* confirms the chunk at hydration and the same close happens by the older
|
|
62
|
+
* `hasOutboundWithText` route — this path is the same decision when that oracle
|
|
63
|
+
* is unavailable.
|
|
64
|
+
*
|
|
45
65
|
* This module is PURE state + orchestration over injected effects — no Telegram,
|
|
46
66
|
* no SQLite, no gateway module state — so the resume decision is unit-testable
|
|
47
67
|
* (see tests/captured-answer-resume.test.ts). The gateway owns the real
|
|
@@ -55,10 +75,10 @@ import type { CapturedDeliverySnapshot, Obligation } from './obligation-ledger.j
|
|
|
55
75
|
/**
|
|
56
76
|
* Build the durable {@link CapturedDeliverySnapshot} from the live per-chunk
|
|
57
77
|
* ledger after a partial `runBackstopDelivery`. Captures every LANDED chunk's
|
|
58
|
-
* message ids + confirmed flag so the resume can (a) never re-send a
|
|
59
|
-
*
|
|
60
|
-
* it. Chunks that never landed (send threw) are simply absent → the
|
|
61
|
-
* them as `unsent` and re-sends them. Pure.
|
|
78
|
+
* message ids + confirmed flag so the resume can (a) never re-send a chunk that
|
|
79
|
+
* already landed and (b) re-PROBE a landed-unconfirmed chunk instead of blindly
|
|
80
|
+
* re-sending it. Chunks that never landed (send threw) are simply absent → the
|
|
81
|
+
* resume treats them as `unsent` and re-sends them. Pure.
|
|
62
82
|
*/
|
|
63
83
|
export function buildCapturedDeliverySnapshot(
|
|
64
84
|
ledger: BackstopDeliveryLedger,
|
|
@@ -165,9 +185,13 @@ export interface CapturedResumeDispatcher {
|
|
|
165
185
|
* The obligation-sweep's captured-answer resume driver. Owns the in-flight guard
|
|
166
186
|
* + the deliver→close/leave-open orchestration so the gateway sweep stays a
|
|
167
187
|
* two-line dispatch. On each dispatch it consumes one represent-budget unit
|
|
168
|
-
* (bounding the ladder → escalate), re-delivers only the
|
|
188
|
+
* (bounding the ladder → escalate), re-delivers only the UNSENT tail (a landed
|
|
189
|
+
* chunk is re-probed, never re-sent), and:
|
|
169
190
|
* - fully delivered ⇒ record the supersede tail, close the obligation, GC the
|
|
170
|
-
* ledger (the represent ladder stops)
|
|
191
|
+
* ledger (the represent ladder stops). Since #3702 "delivered" means every
|
|
192
|
+
* chunk LANDED — so a snapshot that is already fully landed closes here
|
|
193
|
+
* having sent nothing (see the module header for why that is the right
|
|
194
|
+
* call, and why leaving it OPEN would only manufacture a false escalation);
|
|
171
195
|
* - still partial / error ⇒ leave the obligation OPEN so the next eligible
|
|
172
196
|
* sweep (after the per-represent grace) retries, until the represent cap
|
|
173
197
|
* escalates it to the operator nudge — no infinite loop.
|
|
@@ -176,7 +200,7 @@ export function createCapturedResumeDispatcher(ports: CapturedResumePorts): Capt
|
|
|
176
200
|
const stderr = ports.stderr ?? (() => {})
|
|
177
201
|
const inFlight = new Set<string>()
|
|
178
202
|
|
|
179
|
-
/** Re-deliver the
|
|
203
|
+
/** Re-deliver the UNSENT tail of `o`'s captured answer, byte-identical,
|
|
180
204
|
* by rehydrating the per-chunk ledger from the snapshot reconciled against the
|
|
181
205
|
* durable text oracle. Never regenerates. */
|
|
182
206
|
function deliver(o: Obligation, snapshot: CapturedDeliverySnapshot): Promise<{ delivered: boolean; sentIds: number[] }> {
|
|
@@ -221,16 +245,21 @@ export function createCapturedResumeDispatcher(ports: CapturedResumePorts): Capt
|
|
|
221
245
|
if (delivered) {
|
|
222
246
|
ports.obligationLedger.close(o.originTurnId)
|
|
223
247
|
ports.backstopDeliveryLedger.clear(o.originTurnId)
|
|
248
|
+
// `sentIds` is `ledger.sentIds()` — every LANDED id, which since #3702
|
|
249
|
+
// routinely includes ids no read-back corroborated. Say "landed", not
|
|
250
|
+
// "confirmed": claiming confirmation for an uncorroborated id is exactly
|
|
251
|
+
// the overstatement this PR removed from the delivery verdict.
|
|
224
252
|
stderr(
|
|
225
253
|
`telegram gateway: captured-answer resume delivered — origin=${o.originTurnId} ` +
|
|
226
|
-
`${sentIds.length}
|
|
254
|
+
`${sentIds.length} message id(s) landed; obligation closed\n`,
|
|
227
255
|
)
|
|
228
256
|
} else {
|
|
229
|
-
//
|
|
230
|
-
//
|
|
257
|
+
// Not fully landed (a chunk never got an id, or a positive `absent`
|
|
258
|
+
// demoted one) — leave the obligation OPEN for the next paced sweep /
|
|
259
|
+
// escalation (bounded by the represent cap).
|
|
231
260
|
stderr(
|
|
232
261
|
`telegram gateway: captured-answer resume partial — origin=${o.originTurnId} ` +
|
|
233
|
-
`tail still not
|
|
262
|
+
`tail still not landed; left OPEN for retry\n`,
|
|
234
263
|
)
|
|
235
264
|
}
|
|
236
265
|
} catch (err) {
|
|
@@ -489,7 +489,7 @@ import {
|
|
|
489
489
|
import {
|
|
490
490
|
resolveReplyOwnerTurnId,
|
|
491
491
|
resolveReplyOwnerTier,
|
|
492
|
-
type ReplyOwnerTier,
|
|
492
|
+
type ReplyOwnerTier, type ReplyOwnerCandidates,
|
|
493
493
|
type AnswerDeliveredLatch,
|
|
494
494
|
} from '../reply-owner-resolve.js'
|
|
495
495
|
import { SubagentHandbackMarker } from './subagent-handback-marker.js'
|
|
@@ -717,8 +717,9 @@ import {
|
|
|
717
717
|
} from './emission-authority.js'
|
|
718
718
|
import { CurrentTurnMap } from './current-turn-map.js'
|
|
719
719
|
import { resolveAnswerThreadId } from './answer-thread-resolve.js'
|
|
720
|
+
import { latestTurnForChat } from './latest-turn-lookup.js'
|
|
720
721
|
import { decideObligationTurnEnd } from './obligation-turn-end.js'
|
|
721
|
-
import { maybeRotate } from './turns-jsonl-rotate.js'
|
|
722
|
+
import { maybeRotate, resolveAgentStateDir, resolveTurnsJsonlPath } from './turns-jsonl-rotate.js'
|
|
722
723
|
import {
|
|
723
724
|
buildTurnRecord,
|
|
724
725
|
finalizeBackstopSendGated,
|
|
@@ -1032,7 +1033,7 @@ const blockedApprovalStore = createBlockedApprovalStore(
|
|
|
1032
1033
|
// Fallback: the agent's OWN state dir, which the scaffold chowns to the agent
|
|
1033
1034
|
// uid, so a write there always succeeds. Guarantees the record can never be
|
|
1034
1035
|
// silently lost when the shared dir isn't writable by this agent's uid.
|
|
1035
|
-
|
|
1036
|
+
resolveAgentStateDir(),
|
|
1036
1037
|
)
|
|
1037
1038
|
|
|
1038
1039
|
/**
|
|
@@ -2456,9 +2457,9 @@ async function deliverAnswer(args: {
|
|
|
2456
2457
|
* the send alive if the user deleted their message. Null for synthesized
|
|
2457
2458
|
* turns (cron/handback) — those send bare, as before. */
|
|
2458
2459
|
replyToMessageId: number | null
|
|
2459
|
-
/** #3282 captured-answer RESUME (see captured-answer-resume.ts): re-deliver the SAME byte-identical chunks + pre-hydrate the ledger (
|
|
2460
|
+
/** #3282 captured-answer RESUME (see captured-answer-resume.ts): re-deliver the SAME byte-identical chunks + pre-hydrate the ledger (unsent tail only; a landed chunk is re-probed, never re-sent). */
|
|
2460
2461
|
resume?: { snapshot: CapturedDeliverySnapshot; hydrate: (ledger: BackstopDeliveryLedger, turnId: string) => void }
|
|
2461
|
-
}): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }> {
|
|
2462
|
+
}): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean; landedUnconfirmed: number }> {
|
|
2462
2463
|
const { chatId, turnId } = args
|
|
2463
2464
|
// Spacers into `\n\n` gaps then split (as executeReply); a resume re-delivers the EXACT captured chunks (byte-stable, no re-split).
|
|
2464
2465
|
const chunks = args.resume
|
|
@@ -2594,6 +2595,7 @@ async function deliverAnswer(args: {
|
|
|
2594
2595
|
chunkCount: result.chunkCount,
|
|
2595
2596
|
delivered: result.delivered,
|
|
2596
2597
|
exhausted: result.exhausted,
|
|
2598
|
+
landedUnconfirmed: result.landedUnconfirmedIds.length, // #3702 — the caller stamps this on the turn record
|
|
2597
2599
|
}
|
|
2598
2600
|
}
|
|
2599
2601
|
|
|
@@ -3427,6 +3429,7 @@ export type CurrentTurn = {
|
|
|
3427
3429
|
// turn-end paths (reply-tool tail, silent-marker, genuine no-reply), where
|
|
3428
3430
|
// the legacy `finalAnswerDelivered` reading still applies unchanged.
|
|
3429
3431
|
deliveryOutcome?: DeliveryOutcome
|
|
3432
|
+
landedUnconfirmed?: number // #3702 — landed ids no read-back corroborated; emitted as `landed_unconfirmed` (rationale: turn-record-status.ts)
|
|
3430
3433
|
// Feed-reopen-after-ack refinement — whether the reply that set
|
|
3431
3434
|
// `finalAnswerDelivered` was a *substantive* final answer (stream
|
|
3432
3435
|
// `done`, or ≥200 chars) as opposed to a short pinging interim ACK.
|
|
@@ -3482,12 +3485,14 @@ export type CurrentTurn = {
|
|
|
3482
3485
|
flushedAnswerText: string | null
|
|
3483
3486
|
// 2026-07 double-reply-on-DM fix (F2 — recency bound). Wall-clock ms the turn
|
|
3484
3487
|
// ENDED (stamped once by `endCurrentTurnAtomic`), or null while still live.
|
|
3485
|
-
// The `
|
|
3486
|
-
//
|
|
3487
|
-
//
|
|
3488
|
+
// The `latest-ended` supersede tier carries DESTRUCTIVE authority (it drives
|
|
3489
|
+
// message deletion), so `resolveReplyOwnerTurn` only honours a latest-ended
|
|
3490
|
+
// turn whose `endedAt` is non-null (#3725 — the registry is populated at turn
|
|
3491
|
+
// START, so the tail entry may still be RUNNING) AND within the supersede TTL —
|
|
3488
3492
|
// otherwise a late reply belonging to an OLDER turn could resolve its owner to
|
|
3489
|
-
// a NEWER turn
|
|
3490
|
-
//
|
|
3493
|
+
// a NEWER turn at the registry tail and delete that turn's legit answer. The
|
|
3494
|
+
// unbounded ROUTING use (`endedOnly: false`) is unaffected: it only picks a
|
|
3495
|
+
// topic to deliver into and deletes nothing.
|
|
3491
3496
|
endedAt: number | null
|
|
3492
3497
|
// #1675 (over-ping safety net): wall-clock ms of the first reply
|
|
3493
3498
|
// this turn that landed with `disable_notification: false` (a real
|
|
@@ -3997,21 +4002,18 @@ const LATE_REPLY_TOPIC_RECOVERY_ENABLED =
|
|
|
3997
4002
|
process.env.SWITCHROOM_LATE_REPLY_TOPIC_RECOVERY !== '0'
|
|
3998
4003
|
|
|
3999
4004
|
/**
|
|
4000
|
-
* The most
|
|
4001
|
-
*
|
|
4002
|
-
*
|
|
4003
|
-
*
|
|
4004
|
-
*
|
|
4005
|
-
*
|
|
4006
|
-
*
|
|
4007
|
-
*
|
|
4005
|
+
* The most recent turn for a chat from the bounded recent-turn registry — the
|
|
4006
|
+
* deterministic fallback for a LATE answer reply when the model echoed no
|
|
4007
|
+
* `origin_turn_id` and `currentTurn` has already cleared. Returns null when the
|
|
4008
|
+
* chat has no matching turn (so the caller keeps the legacy result). NB: this is
|
|
4009
|
+
* the chat's own most-recent TURN, not the `chatThreadMap` last-seen-any-message
|
|
4010
|
+
* heuristic that caused the wrong-topic bug. `endedOnly` selects the consumer's
|
|
4011
|
+
* semantics — routing takes the tail entry even when that turn is still RUNNING;
|
|
4012
|
+
* the destructive `latest-ended` owner tier takes only a genuinely ENDED turn,
|
|
4013
|
+
* because the registry is populated at turn START (#3725; see the module).
|
|
4008
4014
|
*/
|
|
4009
|
-
function
|
|
4010
|
-
|
|
4011
|
-
for (const t of recentTurnsById.values()) {
|
|
4012
|
-
if (t.sessionChatId === chatId) latest = t
|
|
4013
|
-
}
|
|
4014
|
-
return latest
|
|
4015
|
+
function findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): CurrentTurn | null {
|
|
4016
|
+
return latestTurnForChat(recentTurnsById.values(), chatId, opts)
|
|
4015
4017
|
}
|
|
4016
4018
|
|
|
4017
4019
|
/**
|
|
@@ -4027,7 +4029,7 @@ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
|
|
|
4027
4029
|
* 1. the live `currentTurn` passed in (null once the flush nulled the atom);
|
|
4028
4030
|
* 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
|
|
4029
4031
|
* 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
|
|
4030
|
-
* 4. `
|
|
4032
|
+
* 4. `findLatestTurnForChat(chat_id, {endedOnly:true})` — last ENDED turn.
|
|
4031
4033
|
* Returns the CurrentTurn for the winning id (so callers can read its
|
|
4032
4034
|
* `answerDelivered` latch), or null when every lookup missed.
|
|
4033
4035
|
*/
|
|
@@ -4035,10 +4037,10 @@ function resolveReplyOwnerTurn(
|
|
|
4035
4037
|
liveTurn: CurrentTurn | null,
|
|
4036
4038
|
chatId: string,
|
|
4037
4039
|
args: Record<string, unknown>,
|
|
4038
|
-
): { turn: CurrentTurn | null; tier: ReplyOwnerTier } {
|
|
4040
|
+
): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
|
|
4039
4041
|
const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
4040
4042
|
const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
|
|
4041
|
-
const latestEnded =
|
|
4043
|
+
const latestEnded = findLatestTurnForChat(chatId, { endedOnly: true })
|
|
4042
4044
|
const byId = new Map<string, CurrentTurn>()
|
|
4043
4045
|
// Populate lowest-precedence first so a higher tier's turn wins the id slot
|
|
4044
4046
|
// when two lookups resolve the same turn (they carry the same turnId anyway).
|
|
@@ -4047,12 +4049,12 @@ function resolveReplyOwnerTurn(
|
|
|
4047
4049
|
}
|
|
4048
4050
|
// F2 — bound the DESTRUCTIVE latest-ended tier to the supersede TTL so a stale
|
|
4049
4051
|
// latest-ended turn can't inherit deletion authority over a newer turn's flush
|
|
4050
|
-
// record.
|
|
4051
|
-
//
|
|
4052
|
-
//
|
|
4052
|
+
// record. #3725: the lookup above is `endedOnly`, so `endedAt` is non-null here
|
|
4053
|
+
// and the age is ALWAYS a real number — a not-yet-ended turn is no longer a
|
|
4054
|
+
// candidate at all, and an explicit null age now fails CLOSED downstream.
|
|
4053
4055
|
const latestEndedAgeMs =
|
|
4054
4056
|
latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
|
|
4055
|
-
const candidates = {
|
|
4057
|
+
const candidates: ReplyOwnerCandidates = {
|
|
4056
4058
|
liveTurnId: liveTurn?.turnId ?? null,
|
|
4057
4059
|
originTurnId: origin?.turnId ?? null,
|
|
4058
4060
|
quotedTurnId: quoted?.turnId ?? null,
|
|
@@ -4060,15 +4062,14 @@ function resolveReplyOwnerTurn(
|
|
|
4060
4062
|
latestEndedAgeMs,
|
|
4061
4063
|
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
|
|
4062
4064
|
}
|
|
4063
|
-
// #3429 — the
|
|
4064
|
-
//
|
|
4065
|
-
//
|
|
4066
|
-
//
|
|
4067
|
-
//
|
|
4068
|
-
// tier can never disagree.
|
|
4065
|
+
// #3429 — the winning tier AND the candidate set it came from travel with the
|
|
4066
|
+
// turn. Tier alone no longer decides the content-gate bypass: the
|
|
4067
|
+
// model-steerable `origin`/`quoted` tiers must be CORROBORATED against the
|
|
4068
|
+
// framework-derived `latestEndedTurnId` (`decideContentGateBypass`). All three
|
|
4069
|
+
// derive from these SAME candidates, so they can never disagree.
|
|
4069
4070
|
const tier = resolveReplyOwnerTier(candidates)
|
|
4070
4071
|
const winnerId = resolveReplyOwnerTurnId(candidates)
|
|
4071
|
-
return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier }
|
|
4072
|
+
return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier, candidates }
|
|
4072
4073
|
}
|
|
4073
4074
|
|
|
4074
4075
|
/**
|
|
@@ -4113,7 +4114,7 @@ function resolveAnswerThreadWithLog(
|
|
|
4113
4114
|
explicitThreadId == null &&
|
|
4114
4115
|
originTurn == null &&
|
|
4115
4116
|
liveTurn == null
|
|
4116
|
-
?
|
|
4117
|
+
? findLatestTurnForChat(chatId, { endedOnly: false })
|
|
4117
4118
|
: null
|
|
4118
4119
|
const threadId = resolveAnswerThreadId({
|
|
4119
4120
|
explicitThreadId,
|
|
@@ -5012,7 +5013,7 @@ function snapshotContextOccupancy(): void {
|
|
|
5012
5013
|
} catch {
|
|
5013
5014
|
cap = null; // config unreadable → show occupancy without a ratio
|
|
5014
5015
|
}
|
|
5015
|
-
const stateDir =
|
|
5016
|
+
const stateDir = resolveAgentStateDir();
|
|
5016
5017
|
writeContextOccupancySnapshot(
|
|
5017
5018
|
stateDir,
|
|
5018
5019
|
buildContextOccupancy(occupancy, cap, Date.now()),
|
|
@@ -5033,12 +5034,12 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
|
|
|
5033
5034
|
toolCallCount: turn.toolCallCount ?? 0,
|
|
5034
5035
|
turnId: turn.turnId,
|
|
5035
5036
|
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
5036
|
-
deliveryOutcome: turn.deliveryOutcome,
|
|
5037
|
+
deliveryOutcome: turn.deliveryOutcome, landedUnconfirmed: turn.landedUnconfirmed,
|
|
5037
5038
|
},
|
|
5038
5039
|
endedAt,
|
|
5039
5040
|
),
|
|
5040
5041
|
) + '\n'
|
|
5041
|
-
const turnsPath =
|
|
5042
|
+
const turnsPath = resolveTurnsJsonlPath() // honours SWITCHROOM_AGENT_STATE_DIR
|
|
5042
5043
|
// Size-cap rotation: keep at most one rotated generation so the file can't
|
|
5043
5044
|
// grow unbounded on a long-lived agent. Best-effort (never throws).
|
|
5044
5045
|
maybeRotate(turnsPath, {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recently-seen-turn registry scan, extracted from `gateway.ts` as a pure
|
|
3
|
+
* function so its ENDED-ness contract is unit-testable (#3725; gateway.ts is not
|
|
4
|
+
* importable in tests — the repo's `decideTurnFlush` / `resolveReplyOwnerTurnId`
|
|
5
|
+
* pattern).
|
|
6
|
+
*
|
|
7
|
+
* ## Why `endedOnly` exists (#3725)
|
|
8
|
+
*
|
|
9
|
+
* `recentTurnsById` is populated at turn **start** (`rememberRecentTurn` fires
|
|
10
|
+
* from the `enqueue` lifecycle event in `stream-render.ts`), and the atom is
|
|
11
|
+
* built with `endedAt: null`; `endedAt` is stamped later, in `turn-end.ts`. So
|
|
12
|
+
* the registry's tail entry for a chat is the most-recently-STARTED turn, which
|
|
13
|
+
* may still be RUNNING. The registry is also chat-wide and thread-agnostic, so
|
|
14
|
+
* on a forum a turn running in ANOTHER topic sits at the tail.
|
|
15
|
+
*
|
|
16
|
+
* That distinction is load-bearing because the two consumers want different
|
|
17
|
+
* things:
|
|
18
|
+
*
|
|
19
|
+
* - **Routing** (`resolveAnswerThreadWithLog`) wants the chat's most recent
|
|
20
|
+
* turn whether or not it has ended — it only picks a topic to deliver into,
|
|
21
|
+
* and a still-running turn's topic is a perfectly good (indeed better)
|
|
22
|
+
* answer than falling back to General. `endedOnly: false`.
|
|
23
|
+
* - **Owner resolution** (`resolveReplyOwnerTurn` → the `latest-ended`
|
|
24
|
+
* supersede tier) wants a genuinely ENDED turn: that tier carries
|
|
25
|
+
* DESTRUCTIVE authority (it drives message deletion) and is bounded by the
|
|
26
|
+
* supersede TTL measured from `endedAt`. A turn with `endedAt == null` has
|
|
27
|
+
* no age, so it could not be TTL-bounded at all — it was an unbounded
|
|
28
|
+
* anchor for the corroborated content-gate bypass (#3725). A still-running
|
|
29
|
+
* turn must be resolved by the `live` tier, never by this fallback.
|
|
30
|
+
* `endedOnly: true`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The registry-atom shape this scan needs (structural — `CurrentTurn` in the
|
|
34
|
+
* gateway satisfies it without importing the gateway's type). */
|
|
35
|
+
export interface LatestTurnLookupAtom {
|
|
36
|
+
/** The chat the turn belongs to. */
|
|
37
|
+
sessionChatId: string
|
|
38
|
+
/** Wall-clock ms the turn ENDED, or null while it is still running. */
|
|
39
|
+
endedAt: number | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The last turn for `chatId` in registry insertion order — i.e. the most recent
|
|
44
|
+
* one. With `endedOnly: true` the scan skips turns that have not ended yet, so
|
|
45
|
+
* the result is the most-recently-ended turn (which may NOT be the tail entry).
|
|
46
|
+
* Returns null when the chat has no matching turn.
|
|
47
|
+
*/
|
|
48
|
+
export function latestTurnForChat<T extends LatestTurnLookupAtom>(
|
|
49
|
+
turns: Iterable<T>,
|
|
50
|
+
chatId: string,
|
|
51
|
+
opts: { endedOnly: boolean },
|
|
52
|
+
): T | null {
|
|
53
|
+
let latest: T | null = null
|
|
54
|
+
for (const t of turns) {
|
|
55
|
+
if (t.sessionChatId !== chatId) continue
|
|
56
|
+
if (opts.endedOnly && t.endedAt == null) continue
|
|
57
|
+
latest = t
|
|
58
|
+
}
|
|
59
|
+
return latest
|
|
60
|
+
}
|