switchroom 0.19.43 → 0.19.45
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 -1
- package/dist/auth-broker/index.js +254 -119
- package/dist/cli/notion-write-pretool.mjs +4 -1
- package/dist/cli/switchroom.js +3995 -3196
- package/dist/host-control/main.js +270 -130
- package/dist/vault/approvals/kernel-server.js +201 -66
- package/dist/vault/broker/server.js +263 -128
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +11 -4
- package/telegram-plugin/dist/gateway/gateway.js +945 -638
- package/telegram-plugin/flushed-turn-supersede.ts +190 -16
- package/telegram-plugin/gateway/cron-session.ts +31 -0
- package/telegram-plugin/gateway/gateway.ts +66 -65
- package/telegram-plugin/gateway/outbound-send-path.ts +60 -8
- package/telegram-plugin/gateway/reply-owner-wiring.ts +128 -0
- package/telegram-plugin/gateway/stream-render.ts +91 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +49 -9
- package/telegram-plugin/gateway/subagent-reply-authority.ts +147 -0
- package/telegram-plugin/gateway/turn-end.ts +9 -1
- package/telegram-plugin/reply-owner-resolve.ts +102 -26
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +110 -8
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +297 -27
- package/telegram-plugin/tests/reply-quote-wire.test.ts +2 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +567 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +204 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
*
|
|
27
27
|
* NOT fully deterministic — a residual race remains. The flush→reply direction
|
|
28
28
|
* this registry covers, plus the controller's fire-time recount for the
|
|
29
|
-
* reply→flush direction, close the COMMON
|
|
29
|
+
* reply→flush direction, close the COMMON replay-gap case (~10 s for a
|
|
30
|
+
* tool-call replay; unbounded for the Stop-hook-nudged recompose after a
|
|
31
|
+
* proactive /compact — see the turn-completion window below, #4173); but if a reply
|
|
30
32
|
* and a flush interleave so the reply's `take()` runs BEFORE the flush's
|
|
31
33
|
* `record()` (a much smaller window), `take` finds no record and the duplicate
|
|
32
34
|
* can still slip through. We deliberately trade that residual window for the
|
|
@@ -46,10 +48,85 @@
|
|
|
46
48
|
* `now`. Fully unit-testable; the gateway wires the actual delete/send.
|
|
47
49
|
*/
|
|
48
50
|
|
|
49
|
-
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* ## The supersede window is a TURN-COMPLETION SIGNAL, not a fixed TTL (#4173)
|
|
53
|
+
*
|
|
54
|
+
* The window in which a landing reply may claim a flushed turn used to be a
|
|
55
|
+
* single constant (`DEFAULT_SUPERSEDE_TTL_MS = 60_000`). That constant was
|
|
56
|
+
* sized for the ~10 s tool-replay gap and missed the SLOW variant of the exact
|
|
57
|
+
* class this module exists for (2026-08-01, klanker DM, msgs 25680/25682): the
|
|
58
|
+
* quiescence flush delivered the composed answer, the Stop hook told the model
|
|
59
|
+
* its prose never reached the user, and the model recomposed and called
|
|
60
|
+
* `reply` — but a proactive `/compact` ran in between (occupancy ~393 k), so
|
|
61
|
+
* the canonical reply landed **2 m 24 s** after the flush. Every layer keyed
|
|
62
|
+
* on the 60 s TTL had aged out and the reply shipped as a second bubble
|
|
63
|
+
* (#4166). Widening the constant (the first attempt: 5 min) merely moves the
|
|
64
|
+
* cliff — a longer compaction reproduces the duplicate — while ALSO holding
|
|
65
|
+
* the destructive window open for foreign late repliers (#4172).
|
|
66
|
+
*
|
|
67
|
+
* The framework already holds the right signal. The flush fires a
|
|
68
|
+
* SYNTHETIC turn_end (it nulls the gateway's turn atom so the session can
|
|
69
|
+
* move on), but the claude session's REAL turn_end arrives later, when the
|
|
70
|
+
* model actually stops. A `reply` landing on the MAIN session bridge BETWEEN
|
|
71
|
+
* those two events is that turn's own answer still being composed — PROVIDED
|
|
72
|
+
* nothing else on that bridge can emit a reply in the span. Two things can, and
|
|
73
|
+
* neither is covered by this window; each is closed by its own deterministic
|
|
74
|
+
* gate, and the window is only sound in combination with them:
|
|
75
|
+
*
|
|
76
|
+
* - a FOREIGN session (a Tier-1 cheap-cron bridge, an unregistered client) —
|
|
77
|
+
* closed by caller identity at the send path (`replyCallerIsForeignSession`,
|
|
78
|
+
* gateway/cron-session.ts, #4172);
|
|
79
|
+
* - a background `Task` SUB-AGENT, which runs concurrently with the parent
|
|
80
|
+
* loop and calls `reply` over the SAME main bridge, so identity cannot see
|
|
81
|
+
* it — closed by gateway-observed sub-agent liveness refusing the
|
|
82
|
+
* content-gate bypass (gateway/subagent-reply-authority.ts, #4176). The
|
|
83
|
+
* claude session's own loop is serial; its sub-agents are not, and the
|
|
84
|
+
* earlier revision of this header asserted the stronger, false claim that
|
|
85
|
+
* "no other work can run on the serial session in that span".
|
|
86
|
+
*
|
|
87
|
+
* So the record's lifetime is now three-phase:
|
|
88
|
+
*
|
|
89
|
+
* 1. OPEN (`completedAt == null`) — the flush delivered, the real turn_end
|
|
90
|
+
* has not been observed yet. The turn is still composing (Stop-hook
|
|
91
|
+
* nudge, /compact, recompose all live here). A same-session reply
|
|
92
|
+
* supersedes REGARDLESS of how long this takes — a 20-minute compaction
|
|
93
|
+
* no longer reproduces #4166. Bounded only by the
|
|
94
|
+
* `SUPERSEDE_OPEN_WINDOW_CAP_MS` crash backstop below, and — for a reply
|
|
95
|
+
* the two gates above cannot positively attribute to the session's own
|
|
96
|
+
* loop — by the #3429 content gate, which this phase does NOT relax.
|
|
97
|
+
* 2. COMPLETED (`completedAt` set) — the real turn_end (or a proxy for it:
|
|
98
|
+
* a new turn minting, or the bridge dying) was observed. The turn's own
|
|
99
|
+
* answer either already landed or can only arrive as a bounded REPLAY of
|
|
100
|
+
* an un-acked tool_call (~10 s after a bridge reconnect), so the record
|
|
101
|
+
* stays claimable for `SUPERSEDE_COMPLETED_GRACE_MS` and then expires.
|
|
102
|
+
* 3. EXPIRED — no supersede; a genuinely late duplicate ships as a second
|
|
103
|
+
* visible bubble (safe direction — never an edit-over).
|
|
104
|
+
*
|
|
105
|
+
* In the COMMON case (real turn_end lands seconds after the flush) this is
|
|
106
|
+
* STRICTLY NARROWER than any widened TTL — the destructive window closes ~60 s
|
|
107
|
+
* after the session actually stopped — while being unbounded-correct in the
|
|
108
|
+
* slow case. The `FlushCompletionTracker` below carries the same signal onto
|
|
109
|
+
* the ended TURN ATOM (`realEndObservedAt`) so the latest-ended owner tier
|
|
110
|
+
* (`reply-owner-resolve.ts`) applies the identical three-phase rule.
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/** Crash backstop for an OPEN record: if the real turn_end signal is LOST
|
|
114
|
+
* (every loss path — bridge death, new-turn mint, gateway restart dropping
|
|
115
|
+
* the in-memory registry — normally closes the window explicitly), an open
|
|
116
|
+
* record is forgotten after this long. Generous by design: it exists only
|
|
117
|
+
* for signal loss, and the failure past it is a visible duplicate, never a
|
|
118
|
+
* silent edit-over. Operator override: `SWITCHROOM_SUPERSEDE_OPEN_CAP_MS`
|
|
119
|
+
* (resolved in gateway.ts — this module stays env-free/pure). */
|
|
120
|
+
export const SUPERSEDE_OPEN_WINDOW_CAP_MS = 30 * 60_000
|
|
121
|
+
|
|
122
|
+
/** How long a COMPLETED record stays claimable after the real turn_end was
|
|
123
|
+
* observed. Sized for the bounded replay class the original 60 s TTL was
|
|
124
|
+
* built for: claude-code re-sending an un-acked `reply` tool_call after a
|
|
125
|
+
* bridge reconnect (~10 s), with margin. The exact-text #546 dedup keeps its
|
|
126
|
+
* own 60 s window (`recent-outbound-dedup.ts`) — byte-identical replays, a
|
|
127
|
+
* different class. Operator override: `SWITCHROOM_SUPERSEDE_GRACE_MS`
|
|
128
|
+
* (resolved in gateway.ts). */
|
|
129
|
+
export const SUPERSEDE_COMPLETED_GRACE_MS = 60_000
|
|
53
130
|
|
|
54
131
|
export interface FlushedTurnRecord {
|
|
55
132
|
/** The per-turn `turnId` nonce (`deriveTurnId` shape) of the flushed turn.
|
|
@@ -68,6 +145,13 @@ export interface FlushedTurnRecord {
|
|
|
68
145
|
text: string
|
|
69
146
|
/** Wall-clock ms when recorded. */
|
|
70
147
|
ts: number
|
|
148
|
+
/** #4173 — wall-clock ms the flushed turn's REAL turn_end (or a proxy: new
|
|
149
|
+
* turn mint / bridge death) was observed, or null while the session is
|
|
150
|
+
* still composing (the OPEN phase — see the module header). Null records
|
|
151
|
+
* are claimable indefinitely up to the `openCapMs` crash backstop; once
|
|
152
|
+
* set, the record expires `completedGraceMs` later (the bounded
|
|
153
|
+
* tool-call-replay window). */
|
|
154
|
+
completedAt: number | null
|
|
71
155
|
}
|
|
72
156
|
|
|
73
157
|
/**
|
|
@@ -186,12 +270,24 @@ export function decideSupersede(
|
|
|
186
270
|
replyText?: string | null
|
|
187
271
|
positiveAttribution?: boolean
|
|
188
272
|
now: number
|
|
189
|
-
|
|
273
|
+
/** OPEN-phase crash backstop (see module header). */
|
|
274
|
+
openCapMs?: number
|
|
275
|
+
/** COMPLETED-phase replay grace (see module header). */
|
|
276
|
+
completedGraceMs?: number
|
|
190
277
|
},
|
|
191
278
|
): SupersedeDecision {
|
|
192
|
-
const
|
|
279
|
+
const openCapMs = args.openCapMs ?? SUPERSEDE_OPEN_WINDOW_CAP_MS
|
|
280
|
+
const completedGraceMs = args.completedGraceMs ?? SUPERSEDE_COMPLETED_GRACE_MS
|
|
193
281
|
if (record == null) return { supersede: false, deleteMessageIds: [], reason: 'no-record' }
|
|
194
|
-
|
|
282
|
+
// #4173 three-phase freshness (module header): an OPEN record (real turn_end
|
|
283
|
+
// not yet observed — the session is still composing this turn's answer) is
|
|
284
|
+
// claimable up to the crash-backstop cap; a COMPLETED record only for the
|
|
285
|
+
// bounded tool-call-replay grace after the observed completion.
|
|
286
|
+
const expired =
|
|
287
|
+
record.completedAt == null
|
|
288
|
+
? args.now - record.ts > openCapMs
|
|
289
|
+
: args.now - record.completedAt > completedGraceMs
|
|
290
|
+
if (expired) {
|
|
195
291
|
return { supersede: false, deleteMessageIds: [], reason: 'expired' }
|
|
196
292
|
}
|
|
197
293
|
const sameTurn =
|
|
@@ -312,20 +408,26 @@ function turnKey(turnId: string | null): string {
|
|
|
312
408
|
*/
|
|
313
409
|
export class FlushedTurnSupersedeRegistry {
|
|
314
410
|
private readonly entries = new Map<string, Map<string, FlushedTurnRecord>>()
|
|
315
|
-
private readonly
|
|
411
|
+
private readonly openCapMs: number
|
|
412
|
+
private readonly completedGraceMs: number
|
|
316
413
|
|
|
317
|
-
constructor(opts: {
|
|
318
|
-
this.
|
|
414
|
+
constructor(opts: { openCapMs?: number; completedGraceMs?: number } = {}) {
|
|
415
|
+
this.openCapMs = opts.openCapMs ?? SUPERSEDE_OPEN_WINDOW_CAP_MS
|
|
416
|
+
this.completedGraceMs = opts.completedGraceMs ?? SUPERSEDE_COMPLETED_GRACE_MS
|
|
319
417
|
}
|
|
320
418
|
|
|
321
419
|
/** Record a flush's posted message(s) so a later same-turn reply supersedes
|
|
322
420
|
* them, keyed on the flush's `turnId` within the chat/thread lane. No record
|
|
323
421
|
* is kept when the flush posted zero messages. Sweeps expired records first
|
|
324
|
-
* (lightweight active GC — LOW-2) so orphan records never accumulate.
|
|
422
|
+
* (lightweight active GC — LOW-2) so orphan records never accumulate.
|
|
423
|
+
* `completedAt` (#4173): the flush's async send can outlast the real
|
|
424
|
+
* turn_end, so the record site passes the turn atom's `realEndObservedAt` —
|
|
425
|
+
* a window that was closed BEFORE the record existed is born completed
|
|
426
|
+
* (grace-bounded), never resurrected as open. */
|
|
325
427
|
record(
|
|
326
428
|
chatId: string,
|
|
327
429
|
threadId: number | undefined,
|
|
328
|
-
rec: { turnId: string | null; messageIds: number[]; text: string },
|
|
430
|
+
rec: { turnId: string | null; messageIds: number[]; text: string; completedAt?: number | null },
|
|
329
431
|
now: number,
|
|
330
432
|
): void {
|
|
331
433
|
if (rec.messageIds.length === 0) return
|
|
@@ -341,9 +443,23 @@ export class FlushedTurnSupersedeRegistry {
|
|
|
341
443
|
messageIds: [...rec.messageIds],
|
|
342
444
|
text: rec.text,
|
|
343
445
|
ts: now,
|
|
446
|
+
completedAt: rec.completedAt ?? null,
|
|
344
447
|
})
|
|
345
448
|
}
|
|
346
449
|
|
|
450
|
+
/** #4173 — the real-turn-end signal (or a proxy: new turn mint / bridge
|
|
451
|
+
* death) was observed. Close every OPEN record's window: from `now` each
|
|
452
|
+
* has `completedGraceMs` (the bounded replay window) left to be claimed by
|
|
453
|
+
* its own late replay, then expires. Records already completed keep their
|
|
454
|
+
* original (earlier) completion — the signal only ever narrows. */
|
|
455
|
+
completeAll(now: number): void {
|
|
456
|
+
for (const laneMap of this.entries.values()) {
|
|
457
|
+
for (const rec of laneMap.values()) {
|
|
458
|
+
if (rec.completedAt == null) rec.completedAt = now
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
347
463
|
/** Decide supersede for a landing reply WITHOUT consuming the record. Selects
|
|
348
464
|
* the record whose turnId matches the reply's resolved `liveTurnId` (or the
|
|
349
465
|
* null-turnId record when `liveTurnId == null`). `replyText` (#3429) enables
|
|
@@ -364,7 +480,8 @@ export class FlushedTurnSupersedeRegistry {
|
|
|
364
480
|
replyText: args.replyText,
|
|
365
481
|
positiveAttribution: args.positiveAttribution,
|
|
366
482
|
now: args.now,
|
|
367
|
-
|
|
483
|
+
openCapMs: this.openCapMs,
|
|
484
|
+
completedGraceMs: this.completedGraceMs,
|
|
368
485
|
})
|
|
369
486
|
}
|
|
370
487
|
|
|
@@ -404,11 +521,16 @@ export class FlushedTurnSupersedeRegistry {
|
|
|
404
521
|
this.entries.clear()
|
|
405
522
|
}
|
|
406
523
|
|
|
407
|
-
/** Evict every record past its
|
|
524
|
+
/** Evict every record past its window (three-phase rule, module header) and
|
|
525
|
+
* prune emptied lanes. */
|
|
408
526
|
private sweep(now: number): void {
|
|
409
527
|
for (const [lane, laneMap] of this.entries) {
|
|
410
528
|
for (const [tk, rec] of laneMap) {
|
|
411
|
-
|
|
529
|
+
const expired =
|
|
530
|
+
rec.completedAt == null
|
|
531
|
+
? now - rec.ts > this.openCapMs
|
|
532
|
+
: now - rec.completedAt > this.completedGraceMs
|
|
533
|
+
if (expired) laneMap.delete(tk)
|
|
412
534
|
}
|
|
413
535
|
if (laneMap.size === 0) this.entries.delete(lane)
|
|
414
536
|
}
|
|
@@ -426,3 +548,55 @@ export class FlushedTurnSupersedeRegistry {
|
|
|
426
548
|
function makeKey(chatId: string, threadId: number | undefined): string {
|
|
427
549
|
return threadId == null ? chatId : `${chatId}|${threadId}`
|
|
428
550
|
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* #4173 — carries the turn-completion signal onto the flushed TURN ATOMS, the
|
|
554
|
+
* registry's sibling surface. The registry above bounds the RECORD (message
|
|
555
|
+
* ids); the latest-ended owner tier (`reply-owner-resolve.ts`) is bounded by
|
|
556
|
+
* the TURN's `realEndObservedAt`, and this tracker is what stamps it:
|
|
557
|
+
*
|
|
558
|
+
* - `open(turn)` at flush-fire time (stream-render's turn-flush branch, the
|
|
559
|
+
* one path that ends a turn SYNTHETICALLY before the session stopped):
|
|
560
|
+
* nulls `realEndObservedAt` and holds the atom pending.
|
|
561
|
+
* - `closeAll(now)` when the real turn_end signal (or a proxy: a new turn
|
|
562
|
+
* minting on the session, the bridge dying) is observed: stamps every
|
|
563
|
+
* pending atom's `realEndObservedAt = now` and releases it. Every other
|
|
564
|
+
* turn-end path stamps `realEndObservedAt` directly in
|
|
565
|
+
* `endCurrentTurnAtomic` (turn-end.ts) — for a normally-ended turn the
|
|
566
|
+
* real end IS its end.
|
|
567
|
+
*
|
|
568
|
+
* Serial-session note: claude processes one turn at a time, so pending atoms
|
|
569
|
+
* are naturally few (normally exactly one); `closeAll` closing every pending
|
|
570
|
+
* window on any completion signal is deliberate — closing early is the SAFE
|
|
571
|
+
* direction (a too-short window costs a visible duplicate, never an
|
|
572
|
+
* edit-over). Pure: no clock reads beyond the caller-supplied `now`, no I/O.
|
|
573
|
+
*/
|
|
574
|
+
export class FlushCompletionTracker {
|
|
575
|
+
private pending: Array<{ realEndObservedAt: number | null }> = []
|
|
576
|
+
|
|
577
|
+
/** Hold `turn` pending until the real-turn-end signal; marks its window
|
|
578
|
+
* open (`realEndObservedAt = null`). */
|
|
579
|
+
open(turn: { realEndObservedAt: number | null }): void {
|
|
580
|
+
turn.realEndObservedAt = null
|
|
581
|
+
if (!this.pending.includes(turn)) this.pending.push(turn)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The real-turn-end signal (or a proxy) was observed: stamp every pending
|
|
585
|
+
* atom and release it. Returns how many windows were closed. */
|
|
586
|
+
closeAll(now: number): number {
|
|
587
|
+
let closed = 0
|
|
588
|
+
for (const turn of this.pending) {
|
|
589
|
+
if (turn.realEndObservedAt == null) {
|
|
590
|
+
turn.realEndObservedAt = now
|
|
591
|
+
closed++
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
this.pending = []
|
|
595
|
+
return closed
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Test/diagnostics: number of atoms still awaiting their real turn_end. */
|
|
599
|
+
pendingCount(): number {
|
|
600
|
+
return this.pending.length
|
|
601
|
+
}
|
|
602
|
+
}
|
|
@@ -35,6 +35,37 @@ export function baseAgent(name: string): string {
|
|
|
35
35
|
return isCronIdentity(name) ? name.slice(0, -CRON_IDENTITY_SUFFIX.length) : name;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* #4172 — is a `reply` tool call arriving on this IPC client identity a
|
|
40
|
+
* FOREIGN-SESSION reply, i.e. one that can NEVER be the main claude session's
|
|
41
|
+
* own (possibly late/reworded) answer to a turn the gateway attributed?
|
|
42
|
+
*
|
|
43
|
+
* The whole flushed-turn supersede/bypass/latch machinery
|
|
44
|
+
* (`outbound-send-path.ts`) rests on the premise that a decoupled late reply
|
|
45
|
+
* resolving an ended turn is that turn's own answer unless a handback marker
|
|
46
|
+
* says otherwise. That premise only holds for replies from the MAIN agent
|
|
47
|
+
* bridge — the one whose session the turns belong to. A Tier-1 cheap-cron
|
|
48
|
+
* fire's reply arrives on the derived `<agent>-cron` bridge: it never mints a
|
|
49
|
+
* gateway turn (its session events are dropped in `onSessionEvent`), never
|
|
50
|
+
* traverses `pendingInboundBuffer.push` (so it cannot stamp the handback
|
|
51
|
+
* marker), and lands decoupled with foreign content — the measured #4172
|
|
52
|
+
* failure was such a reply EDITING OVER a flushed answer (double loss: the
|
|
53
|
+
* flushed answer destroyed, the cron digest delivered as a silent edit).
|
|
54
|
+
* Identity is the one deterministic signal that separates it.
|
|
55
|
+
*
|
|
56
|
+
* Returns true (foreign — refuse supersede/bypass/latch authority, always
|
|
57
|
+
* send fresh) for:
|
|
58
|
+
* - a cron-session identity (`<agent>-cron`);
|
|
59
|
+
* - a null/absent identity (an unregistered client — it owns no session,
|
|
60
|
+
* so fail SAFE: a visible fresh message, never destructive authority).
|
|
61
|
+
* Returns false only for a registered non-cron agent bridge — the main
|
|
62
|
+
* session.
|
|
63
|
+
*/
|
|
64
|
+
export function replyCallerIsForeignSession(name: string | null | undefined): boolean {
|
|
65
|
+
if (name == null || name === "") return true;
|
|
66
|
+
return isCronIdentity(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
/**
|
|
39
70
|
* True iff an inject_inbound fire is a scheduled cron fire — Tier-1 cheap-cron
|
|
40
71
|
* (`meta.session='cron'`, routed to the derived `<agent>-cron` bridge) OR a
|
|
@@ -85,7 +85,9 @@ import {
|
|
|
85
85
|
type TelegraphAccount,
|
|
86
86
|
} from '../telegraph.js'
|
|
87
87
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
88
|
-
import { FlushedTurnSupersedeRegistry
|
|
88
|
+
import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
89
|
+
import { resolveReplyOwnerTurnWith, SUPERSEDE_OPEN_CAP_MS, SUPERSEDE_GRACE_MS } from './reply-owner-wiring.js'
|
|
90
|
+
import { subagentReplyAuthority } from './subagent-reply-authority.js'
|
|
89
91
|
import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
|
|
90
92
|
import {
|
|
91
93
|
splitCoalescedAttachments,
|
|
@@ -455,7 +457,7 @@ import {
|
|
|
455
457
|
type SendReplyGatewayDeps,
|
|
456
458
|
type DeliverCapturedProseDeps,
|
|
457
459
|
} from './outbound-send-path.js'
|
|
458
|
-
import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat } from './stream-render.js'
|
|
460
|
+
import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows } from './stream-render.js'
|
|
459
461
|
import { createNarrativeLane } from './narrative-lane.js'
|
|
460
462
|
import {
|
|
461
463
|
parseAgentCallback,
|
|
@@ -488,8 +490,6 @@ import {
|
|
|
488
490
|
FLUSH_SUBSTANTIVE_MIN_CHARS,
|
|
489
491
|
} from '../turn-flush-safety.js'
|
|
490
492
|
import {
|
|
491
|
-
resolveReplyOwnerTurnId,
|
|
492
|
-
resolveReplyOwnerTier,
|
|
493
493
|
type ReplyOwnerTier, type ReplyOwnerCandidates,
|
|
494
494
|
type AnswerDeliveredLatch,
|
|
495
495
|
} from '../reply-owner-resolve.js'
|
|
@@ -657,7 +657,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
|
657
657
|
import { handleRequestMs365Approval } from './ms365-write-approval.js'
|
|
658
658
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
659
659
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
660
|
-
import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback } from './cron-session.js'
|
|
660
|
+
import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession } from './cron-session.js'
|
|
661
661
|
import {
|
|
662
662
|
ObligationLedger,
|
|
663
663
|
obligationEscalationText,
|
|
@@ -2377,7 +2377,12 @@ const outboundDedup = new OutboundDedupCache()
|
|
|
2377
2377
|
// a second message. Keyed on the per-turn `turnId` nonce, NOT on text — so it
|
|
2378
2378
|
// catches the containment case the exact-text `outboundDedup` misses (a
|
|
2379
2379
|
// `narration\n\nanswer` flush never equals the clean `answer`-only reply).
|
|
2380
|
-
|
|
2380
|
+
// #4173/#4175 — window bounds (env kill-switch + rationale) live in
|
|
2381
|
+
// reply-owner-wiring.ts so this ctor and the owner-tier candidates share them.
|
|
2382
|
+
const flushedTurnSupersede = new FlushedTurnSupersedeRegistry({
|
|
2383
|
+
openCapMs: SUPERSEDE_OPEN_CAP_MS,
|
|
2384
|
+
completedGraceMs: SUPERSEDE_GRACE_MS,
|
|
2385
|
+
})
|
|
2381
2386
|
// #3276 — the turn-flush backstop's per-turn delivery latch + per-chunk
|
|
2382
2387
|
// idempotency ledger. The latch (keyed on `turnId`) is the deterministic
|
|
2383
2388
|
// arbiter of backstop-vs-reply; the chunk ledger lets a retry after a partial
|
|
@@ -3457,12 +3462,18 @@ export type CurrentTurn = {
|
|
|
3457
3462
|
// The `latest-ended` supersede tier carries DESTRUCTIVE authority (it drives
|
|
3458
3463
|
// message deletion), so `resolveReplyOwnerTurn` only honours a latest-ended
|
|
3459
3464
|
// turn whose `endedAt` is non-null (#3725 — the registry is populated at turn
|
|
3460
|
-
// START, so the tail entry may still be RUNNING) AND within the
|
|
3461
|
-
// otherwise a late reply belonging to an
|
|
3462
|
-
// a NEWER turn at the registry tail and
|
|
3463
|
-
//
|
|
3464
|
-
// topic to deliver into
|
|
3465
|
+
// START, so the tail entry may still be RUNNING) AND within the
|
|
3466
|
+
// turn-completion window (#4173) — otherwise a late reply belonging to an
|
|
3467
|
+
// OLDER turn could resolve its owner to a NEWER turn at the registry tail and
|
|
3468
|
+
// delete that turn's legit answer. The unbounded ROUTING use
|
|
3469
|
+
// (`endedOnly: false`) is unaffected: it only picks a topic to deliver into
|
|
3470
|
+
// and deletes nothing.
|
|
3465
3471
|
endedAt: number | null
|
|
3472
|
+
// #4173 — the turn-completion signal: ms the SESSION's real stop was
|
|
3473
|
+
// observed, or null while the window is OPEN (a flush-ended turn still
|
|
3474
|
+
// composing). Stamped in turn-end.ts / closed by stream-render's hooks;
|
|
3475
|
+
// full three-phase model in flushed-turn-supersede.ts.
|
|
3476
|
+
realEndObservedAt: number | null
|
|
3466
3477
|
// #1675 (over-ping safety net): wall-clock ms of the first reply
|
|
3467
3478
|
// this turn that landed with `disable_notification: false` (a real
|
|
3468
3479
|
// device ping). The conversational-pacing contract
|
|
@@ -3985,60 +3996,21 @@ function findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): Cu
|
|
|
3985
3996
|
return latestTurnForChat(recentTurnsById.values(), chatId, opts)
|
|
3986
3997
|
}
|
|
3987
3998
|
|
|
3988
|
-
/**
|
|
3989
|
-
*
|
|
3990
|
-
*
|
|
3991
|
-
*
|
|
3992
|
-
* omitted the quoted-message and latest-ended recoveries, so a DM late reply —
|
|
3993
|
-
* no live turn, no `origin_turn_id` — resolved to a null owner and its flush
|
|
3994
|
-
* message was never superseded → duplicate).
|
|
3995
|
-
*
|
|
3996
|
-
* Precedence (first non-null wins), delegated to the pure
|
|
3997
|
-
* `resolveReplyOwnerTurnId` so the exact precedence is unit-tested:
|
|
3998
|
-
* 1. the live `currentTurn` passed in (null once the flush nulled the atom);
|
|
3999
|
-
* 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
|
|
4000
|
-
* 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
|
|
4001
|
-
* 4. `findLatestTurnForChat(chat_id, {endedOnly:true})` — last ENDED turn.
|
|
4002
|
-
* Returns the CurrentTurn for the winning id (so callers can read its
|
|
4003
|
-
* `answerDelivered` latch), or null when every lookup missed.
|
|
4004
|
-
*/
|
|
3999
|
+
/** 2026-07 double-reply-on-DM fix (Part 1) — thin wrapper over the extracted
|
|
4000
|
+
* composition (`reply-owner-wiring.ts`, which also carries the #4173
|
|
4001
|
+
* turn-completion-window bounds). Kept here only to bind the gateway's
|
|
4002
|
+
* stateful lookups. */
|
|
4005
4003
|
function resolveReplyOwnerTurn(
|
|
4006
4004
|
liveTurn: CurrentTurn | null,
|
|
4007
4005
|
chatId: string,
|
|
4008
4006
|
args: Record<string, unknown>,
|
|
4009
4007
|
): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
for (const t of [latestEnded, quoted, origin, liveTurn]) {
|
|
4017
|
-
if (t != null) byId.set(t.turnId, t)
|
|
4018
|
-
}
|
|
4019
|
-
// F2 — bound the DESTRUCTIVE latest-ended tier to the supersede TTL so a stale
|
|
4020
|
-
// latest-ended turn can't inherit deletion authority over a newer turn's flush
|
|
4021
|
-
// record. #3725: the lookup above is `endedOnly`, so `endedAt` is non-null here
|
|
4022
|
-
// and the age is ALWAYS a real number — a not-yet-ended turn is no longer a
|
|
4023
|
-
// candidate at all, and an explicit null age now fails CLOSED downstream.
|
|
4024
|
-
const latestEndedAgeMs =
|
|
4025
|
-
latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
|
|
4026
|
-
const candidates: ReplyOwnerCandidates = {
|
|
4027
|
-
liveTurnId: liveTurn?.turnId ?? null,
|
|
4028
|
-
originTurnId: origin?.turnId ?? null,
|
|
4029
|
-
quotedTurnId: quoted?.turnId ?? null,
|
|
4030
|
-
latestEndedTurnId: latestEnded?.turnId ?? null,
|
|
4031
|
-
latestEndedAgeMs,
|
|
4032
|
-
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
|
|
4033
|
-
}
|
|
4034
|
-
// #3429 — the winning tier AND the candidate set it came from travel with the
|
|
4035
|
-
// turn. Tier alone no longer decides the content-gate bypass: the
|
|
4036
|
-
// model-steerable `origin`/`quoted` tiers must be CORROBORATED against the
|
|
4037
|
-
// framework-derived `latestEndedTurnId` (`decideContentGateBypass`). All three
|
|
4038
|
-
// derive from these SAME candidates, so they can never disagree.
|
|
4039
|
-
const tier = resolveReplyOwnerTier(candidates)
|
|
4040
|
-
const winnerId = resolveReplyOwnerTurnId(candidates)
|
|
4041
|
-
return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier, candidates }
|
|
4008
|
+
return resolveReplyOwnerTurnWith(
|
|
4009
|
+
{ findTurnByOriginId, findTurnByQuotedMessageId, findLatestTurnForChat, now: Date.now },
|
|
4010
|
+
liveTurn,
|
|
4011
|
+
chatId,
|
|
4012
|
+
args,
|
|
4013
|
+
)
|
|
4042
4014
|
}
|
|
4043
4015
|
|
|
4044
4016
|
/**
|
|
@@ -10759,12 +10731,27 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10759
10731
|
},
|
|
10760
10732
|
log: (msg) => process.stderr.write(`${msg}\n`),
|
|
10761
10733
|
})
|
|
10734
|
+
// #4173 — a MAIN-bridge death is a turn-completion proxy: the dead session
|
|
10735
|
+
// can never deliver a flush-ended turn's real turn_end, so close any open
|
|
10736
|
+
// flush windows now (the replay grace still covers a reconnect replay).
|
|
10737
|
+
// Scoped to a registered non-cron agent; rationale in stream-render.ts.
|
|
10738
|
+
if (client.agentName != null && !isCronIdentity(client.agentName)) {
|
|
10739
|
+
subagentReplyAuthority.reset() // #4176 — the sub-agents died with the session
|
|
10740
|
+
const closedWindows = closeFlushCompletionWindows(flushedTurnSupersede, Date.now())
|
|
10741
|
+
if (closedWindows > 0) {
|
|
10742
|
+
process.stderr.write(
|
|
10743
|
+
`telegram gateway: bridge disconnect closed ${closedWindows} open flush-completion window(s) (#4173)\n`,
|
|
10744
|
+
)
|
|
10745
|
+
}
|
|
10746
|
+
}
|
|
10762
10747
|
},
|
|
10763
10748
|
|
|
10764
10749
|
async onToolCall(client: IpcClient, msg: ToolCallMessage): Promise<ToolCallResult> {
|
|
10765
10750
|
process.stderr.write(`telegram gateway: ipc: tool_call tool=${msg.tool} agent=${client.agentName ?? '-'} clientId=${client.id ?? '-'} callId=${msg.id}\n`)
|
|
10766
10751
|
try {
|
|
10767
|
-
|
|
10752
|
+
// #4172 — the calling client's identity gates reply supersede authority
|
|
10753
|
+
// (see replyCallerIsForeignSession, cron-session.ts).
|
|
10754
|
+
const result = await executeToolCall(msg.tool, msg.args, client.agentName)
|
|
10768
10755
|
return { type: 'tool_call_result', id: msg.id, success: true, result }
|
|
10769
10756
|
} catch (err) {
|
|
10770
10757
|
return {
|
|
@@ -11972,13 +11959,18 @@ const ALLOWED_TOOLS = new Set([
|
|
|
11972
11959
|
'linear_agent_setup',
|
|
11973
11960
|
])
|
|
11974
11961
|
|
|
11975
|
-
async function executeToolCall(
|
|
11962
|
+
async function executeToolCall(
|
|
11963
|
+
tool: string,
|
|
11964
|
+
args: Record<string, unknown>,
|
|
11965
|
+
// #4172 — calling client identity; only the reply path consumes it today.
|
|
11966
|
+
callerAgentName: string | null = null,
|
|
11967
|
+
): Promise<unknown> {
|
|
11976
11968
|
if (!ALLOWED_TOOLS.has(tool)) {
|
|
11977
11969
|
throw new Error(`tool not allowed: ${tool}`)
|
|
11978
11970
|
}
|
|
11979
11971
|
switch (tool) {
|
|
11980
11972
|
case 'reply':
|
|
11981
|
-
return executeReply(args)
|
|
11973
|
+
return executeReply(args, callerAgentName)
|
|
11982
11974
|
case 'progress_update':
|
|
11983
11975
|
return executeProgressUpdate(args)
|
|
11984
11976
|
case 'react':
|
|
@@ -12490,14 +12482,22 @@ async function synthesizeVoiceOut(plan: {
|
|
|
12490
12482
|
}
|
|
12491
12483
|
}
|
|
12492
12484
|
|
|
12493
|
-
async function executeReply(
|
|
12485
|
+
async function executeReply(
|
|
12486
|
+
args: Record<string, unknown>,
|
|
12487
|
+
callerAgentName: string | null = null,
|
|
12488
|
+
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
12494
12489
|
// #2996 P2: the ~1,400-line orchestration body moved VERBATIM to
|
|
12495
12490
|
// outbound-send-path.ts `sendReply()` — the single tested send primitive.
|
|
12496
12491
|
// #1664 — the turn is pinned HERE at entry (same read, same position as the
|
|
12497
12492
|
// old inline `const turn = currentTurn`) and passed in `req`; the façade
|
|
12498
12493
|
// also gets a live `getCurrentTurn()` accessor for the deliberate live
|
|
12499
12494
|
// re-reads (Amendment 9: each call site keeps its pin-vs-live choice).
|
|
12500
|
-
|
|
12495
|
+
// #4172 — a foreign-session caller gets no supersede/bypass/latch authority.
|
|
12496
|
+
return sendReply(gatewaySendReplyDeps(), {
|
|
12497
|
+
args,
|
|
12498
|
+
turn: currentTurn,
|
|
12499
|
+
callerIsForeignSession: replyCallerIsForeignSession(callerAgentName),
|
|
12500
|
+
})
|
|
12501
12501
|
}
|
|
12502
12502
|
|
|
12503
12503
|
/** Live gateway deps for the extracted send façade (#2996 P2). Built fresh
|
|
@@ -12540,6 +12540,7 @@ function gatewaySendReplyDeps(): SendReplyGatewayDeps {
|
|
|
12540
12540
|
resolveThreadId,
|
|
12541
12541
|
getLatestInboundMessageId,
|
|
12542
12542
|
getLastSubagentHandbackAt,
|
|
12543
|
+
subagentReplyAuthority,
|
|
12543
12544
|
recordOutbound,
|
|
12544
12545
|
emissionAuthorityFor,
|
|
12545
12546
|
clearActivitySummary,
|