switchroom 0.19.2 → 0.19.3
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 +2 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -53,7 +53,11 @@ import { resolveChatIdFallback } from './chat-id-fallback.js'
|
|
|
53
53
|
import { isFinalAnswerReply, isSubstantiveFinalReply } from '../final-answer-detect.js'
|
|
54
54
|
import { decideOverPing, type OverPingDecision } from '../over-ping-safety-net.js'
|
|
55
55
|
import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
|
|
56
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
decideSupersedeCorrection,
|
|
58
|
+
flushedAnswerMatchesReply,
|
|
59
|
+
type FlushedTurnSupersedeRegistry,
|
|
60
|
+
} from '../flushed-turn-supersede.js'
|
|
57
61
|
import { decideAnswerLatchSuppression } from '../reply-owner-resolve.js'
|
|
58
62
|
import { deriveTelegraphTitle } from '../telegraph.js'
|
|
59
63
|
import {
|
|
@@ -872,10 +876,16 @@ export async function sendReply(
|
|
|
872
876
|
// resolvers agree and the late-reply supersede fires by identity.
|
|
873
877
|
const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args)
|
|
874
878
|
const resolvedTurnId = ownerTurn?.turnId ?? null
|
|
879
|
+
// #3429 — pass the (normalized) reply text so the registry can apply the
|
|
880
|
+
// new-content gate: identity match + TTL alone also fits an async handback
|
|
881
|
+
// that merely resolved this flush-delivered ENDED turn as its owner via
|
|
882
|
+
// the latest-ended tier. Editing the flushed message in place with that
|
|
883
|
+
// handback's text does not re-notify client-side (Telegram edits never
|
|
884
|
+
// push) — the observed silent non-surfacing of msgs 10482/10486.
|
|
875
885
|
const decision = flushedTurnSupersede.take(
|
|
876
886
|
chat_id,
|
|
877
887
|
replyThreadId,
|
|
878
|
-
{ liveTurnId: resolvedTurnId, now: Date.now() },
|
|
888
|
+
{ liveTurnId: resolvedTurnId, replyText: text, now: Date.now() },
|
|
879
889
|
)
|
|
880
890
|
if (decision.supersede) {
|
|
881
891
|
process.stderr.write(
|
|
@@ -899,20 +909,31 @@ export async function sendReply(
|
|
|
899
909
|
// resolves the same ended owner turn, sees the latch, and is suppressed —
|
|
900
910
|
// exactly one message ever ships. The latch is idempotent and the normal
|
|
901
911
|
// (no-throw) path is unaffected: the correction below still ships B once.
|
|
902
|
-
|
|
912
|
+
// Tagged 'flush' (#3426): a flush record existed for this turn (take()
|
|
913
|
+
// just consumed it), so the flushed message A is what the suppression
|
|
914
|
+
// protects against duplicating. #3429: stash the record's flushed text
|
|
915
|
+
// alongside, so the retry's latch check can discriminate by content —
|
|
916
|
+
// the retry of THIS superseding reply matches and stays suppressed, while
|
|
917
|
+
// a later genuinely-new handback does not and delivers.
|
|
918
|
+
if (ownerTurn != null) {
|
|
919
|
+
ownerTurn.answerDelivered = 'flush'
|
|
920
|
+
if (decision.recordText != null) ownerTurn.flushedAnswerText = decision.recordText
|
|
921
|
+
}
|
|
903
922
|
} else {
|
|
904
923
|
// 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race latch.
|
|
905
924
|
// Supersede found no record. Either there was no flush (normal reply), or
|
|
906
925
|
// the flush FIRED but has not yet recorded its message ids (the residual
|
|
907
926
|
// pre-record race Part 1's supersede cannot reach). The flush sets
|
|
908
|
-
// `answerDelivered =
|
|
909
|
-
// send AND before `record`), and it persists on the ended turn — so
|
|
910
|
-
// this LATE, substantive reply resolves its owner turn and sees the
|
|
911
|
-
//
|
|
912
|
-
// reply would ship a duplicate. Suppress it. Scoped to the
|
|
913
|
-
// ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor
|
|
914
|
-
// interim sub-floor ack, a
|
|
915
|
-
//
|
|
927
|
+
// `answerDelivered = 'flush'` synchronously at fire time (before its
|
|
928
|
+
// async send AND before `record`), and it persists on the ended turn — so
|
|
929
|
+
// when this LATE, substantive reply resolves its owner turn and sees the
|
|
930
|
+
// FLUSH-armed latch, the flush's message A is already on its way out and
|
|
931
|
+
// this reply would ship a duplicate. Suppress it. Scoped to the
|
|
932
|
+
// substantive ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor, the late-reply case,
|
|
933
|
+
// AND the 'flush' latch source (#3426) so an interim sub-floor ack, a
|
|
934
|
+
// chunked multi-part answer, a legitimate second in-turn substantive
|
|
935
|
+
// reply (live `currentTurn`), or an async sub-agent handback landing
|
|
936
|
+
// after a reply-delivered turn ended (latch = 'reply') is never
|
|
916
937
|
// suppressed. `isSubstantiveFinalReply` reduces to the ≥200-char test on
|
|
917
938
|
// the `reply` path (no `done`); pass the model's original notification
|
|
918
939
|
// intent to mirror the #2533 decoupling call shape.
|
|
@@ -920,12 +941,33 @@ export async function sendReply(
|
|
|
920
941
|
text: rawText,
|
|
921
942
|
disableNotification: args.disable_notification === true,
|
|
922
943
|
})
|
|
944
|
+
// #3429 — content evidence for the latch. `'new-content'` is the
|
|
945
|
+
// registry's POSITIVE determination that this reply differs from the
|
|
946
|
+
// flushed answer (record present, identity matched, text did not);
|
|
947
|
+
// otherwise compare against the owner turn's stashed `flushedAnswerText`
|
|
948
|
+
// (covers the post-fire pre-record race window, where no record exists
|
|
949
|
+
// yet but the fire site already stamped what it is delivering). Null —
|
|
950
|
+
// no flushed text to compare — keeps the conservative pre-#3429
|
|
951
|
+
// flush-armed suppression.
|
|
952
|
+
const replyMatchesFlushedAnswer: boolean | null =
|
|
953
|
+
decision.reason === 'new-content'
|
|
954
|
+
? false
|
|
955
|
+
: ownerTurn?.flushedAnswerText != null
|
|
956
|
+
? flushedAnswerMatchesReply(ownerTurn.flushedAnswerText, text)
|
|
957
|
+
: null
|
|
923
958
|
const suppressByLatch = decideAnswerLatchSuppression({
|
|
924
959
|
superseded: false,
|
|
925
960
|
replySubstantive,
|
|
926
961
|
isLateReply: turn == null,
|
|
927
962
|
ownerAnswerDelivered: ownerTurn?.answerDelivered ?? false,
|
|
963
|
+
replyMatchesFlushedAnswer,
|
|
928
964
|
})
|
|
965
|
+
if (decision.reason === 'new-content') {
|
|
966
|
+
process.stderr.write(
|
|
967
|
+
`telegram gateway: reply: flush supersede declined — new content (#3429) ` +
|
|
968
|
+
`chatId=${chat_id} ownerTurnId=${JSON.stringify(resolvedTurnId)}; sending fresh\n`,
|
|
969
|
+
)
|
|
970
|
+
}
|
|
929
971
|
if (suppressByLatch) {
|
|
930
972
|
process.stderr.write(
|
|
931
973
|
`telegram gateway: reply: suppressed by answer-delivered latch ` +
|
|
@@ -934,11 +976,22 @@ export async function sendReply(
|
|
|
934
976
|
)
|
|
935
977
|
return { content: [{ type: 'text', text: 'sent (deduped — answer already delivered via turn-flush)' }] }
|
|
936
978
|
}
|
|
937
|
-
// A substantive answer is going out via this reply —
|
|
938
|
-
// owner turn
|
|
939
|
-
//
|
|
979
|
+
// A substantive answer is going out via this reply — record it on the
|
|
980
|
+
// owner turn, tagged 'reply' (#3426). The 'reply' tag does NOT trip the
|
|
981
|
+
// late-reply suppression above: a later reply attributed to this turn
|
|
982
|
+
// after it ends (the async sub-agent handback pattern — dispatch, interim
|
|
983
|
+
// ack, turn_end, handback with no live gateway turn) is genuinely new
|
|
984
|
+
// content and must deliver. Byte-identical replays of THIS answer are
|
|
985
|
+
// deduped by the content-keyed #546 cache at the top of this function.
|
|
986
|
+
// Honest bound: the dedup TTL (60 s) is anchored at reply RECORD time,
|
|
987
|
+
// while the latest-ended owner tier's 60 s is anchored at `endedAt` —
|
|
988
|
+
// later by the reply→turn_end gap. A byte-identical replay landing >60 s
|
|
989
|
+
// after record but ≤60 s after endedAt is evicted from dedup yet still
|
|
990
|
+
// resolves this ended turn, so it now DELIVERS as a duplicate message.
|
|
991
|
+
// Conscious trade: a rare duplicate beats the silent handback drop the
|
|
992
|
+
// boolean latch caused (#3426).
|
|
940
993
|
if (replySubstantive && ownerTurn != null) {
|
|
941
|
-
ownerTurn.answerDelivered =
|
|
994
|
+
ownerTurn.answerDelivered = 'reply'
|
|
942
995
|
}
|
|
943
996
|
}
|
|
944
997
|
}
|
|
@@ -20,6 +20,28 @@
|
|
|
20
20
|
* whichever was observed last. A fresh assistant line always reclaims the
|
|
21
21
|
* transcript as the source; a confirmed switch always beats an older
|
|
22
22
|
* transcript line. Pinned by tests/session-model-source.test.ts.
|
|
23
|
+
*
|
|
24
|
+
* Divergence tripwire (#3427 item 4): `--fallback-model` masks an invalid
|
|
25
|
+
* requested model id — claude silently serves the fallback while the override
|
|
26
|
+
* carries the requested token. The FIRST live transcript observation of the
|
|
27
|
+
* post-relaunch session is therefore the earliest deterministic verification
|
|
28
|
+
* point: when the injected comparator says the served id does NOT satisfy the
|
|
29
|
+
* requested token, the registered divergence handler fires (once per armed
|
|
30
|
+
* override) so the gateway can log + warn instead of self-healing silently.
|
|
31
|
+
*
|
|
32
|
+
* Two false-positive guards (#3437 review H1/H2 — "a false accusation must be
|
|
33
|
+
* impossible" is the contract, enforced HERE, not by caller discipline):
|
|
34
|
+
* - H1: verification arms ONLY on an explicit `setOverride(model,
|
|
35
|
+
* { verify: true })` — the boot-rehydration site, where the override IS
|
|
36
|
+
* the launched token of the session now serving. A command-time
|
|
37
|
+
* `setOverride(model)` (the pre-restart status-honesty record in
|
|
38
|
+
* scheduleModelRelaunch) must NOT arm: an assistant line landing in the
|
|
39
|
+
* pre-restart window is served by the OLD model and would false-mismatch
|
|
40
|
+
* the NEW requested token.
|
|
41
|
+
* - H2: observations flagged `replayed: true` (the session-tail's
|
|
42
|
+
* first-attach replay of a prior session's in-flight turn — OLD-model
|
|
43
|
+
* lines delivered AFTER boot) neither consume nor fire verification;
|
|
44
|
+
* the tripwire waits for the first LIVE observation.
|
|
23
45
|
*/
|
|
24
46
|
|
|
25
47
|
export interface SessionModelResolution {
|
|
@@ -30,14 +52,45 @@ export interface SessionModelResolution {
|
|
|
30
52
|
source: 'transcript' | 'override'
|
|
31
53
|
}
|
|
32
54
|
|
|
55
|
+
/** The first live post-override assistant line served a different model (#3427). */
|
|
56
|
+
export interface SessionModelDivergence {
|
|
57
|
+
/** The override token the operator requested (`/model <token>`). */
|
|
58
|
+
requested: string
|
|
59
|
+
/** The transcript's `message.model` — the model actually serving calls. */
|
|
60
|
+
served: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SessionModelSourceOptions {
|
|
64
|
+
/**
|
|
65
|
+
* Comparator for the divergence tripwire: does `served` (a resolved
|
|
66
|
+
* transcript id) satisfy `requested` (the override token)? Must be
|
|
67
|
+
* CONSERVATIVE — return true when the pair is not deterministically
|
|
68
|
+
* comparable (see servedModelMatchesRequested in model-command.ts).
|
|
69
|
+
* Absent → the tripwire never fires (verification is skipped).
|
|
70
|
+
*/
|
|
71
|
+
servedMatchesRequested?: (requested: string, served: string) => boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
33
74
|
export interface SessionModelSource {
|
|
34
|
-
/**
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Record a transcript observation (an assistant line's `message.model`,
|
|
77
|
+
* already sentinel-filtered by the session-tail projection). Pass
|
|
78
|
+
* `replayed: true` for lines delivered by the session-tail's first-attach
|
|
79
|
+
* replay (a PRIOR session's in-flight turn): they still update /status
|
|
80
|
+
* freshness exactly as before, but are excluded from divergence
|
|
81
|
+
* verification (H2 — they carry the pre-relaunch model).
|
|
82
|
+
*/
|
|
83
|
+
noteTranscriptModel(model: string, opts?: { replayed?: boolean }): void
|
|
84
|
+
/**
|
|
85
|
+
* Record an override set (a positively-confirmed /model switch), or clear
|
|
86
|
+
* it with null. Setting stamps a fresh sequence, so the override wins over
|
|
87
|
+
* every EARLIER transcript observation until a new assistant line lands.
|
|
88
|
+
* `verify: true` additionally ARMS divergence verification for this
|
|
89
|
+
* override — pass it ONLY when the override is the launched token of the
|
|
90
|
+
* session currently serving (the boot-rehydration site). Default: not
|
|
91
|
+
* armed (H1 — command-time/rollback sets must never arm).
|
|
92
|
+
*/
|
|
93
|
+
setOverride(model: string | null, opts?: { verify?: boolean }): void
|
|
41
94
|
/** Current override value (the #2982 in-memory record), independent of
|
|
42
95
|
* freshness — for callers that need the override itself (e.g. the model
|
|
43
96
|
* menu's "session" marker), not the /status resolution. */
|
|
@@ -45,18 +98,42 @@ export interface SessionModelSource {
|
|
|
45
98
|
/** The freshest observation across both sources, or null when neither has
|
|
46
99
|
* reported yet. */
|
|
47
100
|
resolve(): SessionModelResolution | null
|
|
101
|
+
/** Register the handler fired when the first LIVE transcript observation
|
|
102
|
+
* after an ARMED override fails the comparator (#3427 item 4). At most
|
|
103
|
+
* once per armed override; null unregisters. Replaces any prior handler. */
|
|
104
|
+
setDivergenceHandler(handler: ((d: SessionModelDivergence) => void) | null): void
|
|
48
105
|
}
|
|
49
106
|
|
|
50
|
-
export function createSessionModelSource(
|
|
107
|
+
export function createSessionModelSource(
|
|
108
|
+
options: SessionModelSourceOptions = {},
|
|
109
|
+
): SessionModelSource {
|
|
51
110
|
let seq = 0
|
|
52
111
|
let transcript: { model: string; seq: number } | null = null
|
|
53
112
|
let override: { model: string; seq: number } | null = null
|
|
113
|
+
// True while an ARMED ({ verify: true }) non-null override awaits its first
|
|
114
|
+
// LIVE transcript observation. Consumed (set false) on that observation
|
|
115
|
+
// whether or not it diverges, so the handler fires at most once per armed
|
|
116
|
+
// override. Replayed observations neither consume nor fire (H2).
|
|
117
|
+
let overrideUnverified = false
|
|
118
|
+
let onDivergence: ((d: SessionModelDivergence) => void) | null = null
|
|
54
119
|
return {
|
|
55
|
-
noteTranscriptModel(model: string): void {
|
|
120
|
+
noteTranscriptModel(model: string, opts?: { replayed?: boolean }): void {
|
|
56
121
|
transcript = { model, seq: ++seq }
|
|
122
|
+
if (opts?.replayed === true) return // H2: pre-relaunch line — no verification
|
|
123
|
+
if (override != null && overrideUnverified) {
|
|
124
|
+
overrideUnverified = false
|
|
125
|
+
const matches = options.servedMatchesRequested
|
|
126
|
+
if (matches != null && !matches(override.model, model)) {
|
|
127
|
+
onDivergence?.({ requested: override.model, served: model })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
57
130
|
},
|
|
58
|
-
setOverride(model: string | null): void {
|
|
131
|
+
setOverride(model: string | null, opts?: { verify?: boolean }): void {
|
|
59
132
|
override = model == null ? null : { model, seq: ++seq }
|
|
133
|
+
// H1: only an explicit verify-arm (the boot-rehydration site) starts
|
|
134
|
+
// verification; a plain set (command-time record, rollback restore)
|
|
135
|
+
// clears any pending arm — its token is NOT what is serving right now.
|
|
136
|
+
overrideUnverified = model != null && opts?.verify === true
|
|
60
137
|
},
|
|
61
138
|
getOverride(): string | null {
|
|
62
139
|
return override?.model ?? null
|
|
@@ -69,5 +146,8 @@ export function createSessionModelSource(): SessionModelSource {
|
|
|
69
146
|
}
|
|
70
147
|
return { model: transcript.model, source: 'transcript' }
|
|
71
148
|
},
|
|
149
|
+
setDivergenceHandler(handler: ((d: SessionModelDivergence) => void) | null): void {
|
|
150
|
+
onDivergence = handler
|
|
151
|
+
},
|
|
72
152
|
}
|
|
73
153
|
}
|
|
@@ -307,6 +307,9 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
307
307
|
// 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race
|
|
308
308
|
// latch, reset at turn start alongside the other answer flags.
|
|
309
309
|
answerDelivered: false,
|
|
310
|
+
// #3429 — flushed-answer text for the content-vs-flush latch
|
|
311
|
+
// discrimination; stamped at flush arm, reset at turn start.
|
|
312
|
+
flushedAnswerText: null,
|
|
310
313
|
// 2026-07 double-reply-on-DM fix (F2) — stamped at turn end.
|
|
311
314
|
endedAt: null,
|
|
312
315
|
firstPingAt: null,
|
|
@@ -535,7 +538,10 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
535
538
|
if (turn != null) {
|
|
536
539
|
turn.currentModel = ev.model
|
|
537
540
|
}
|
|
538
|
-
|
|
541
|
+
// `replayed` (#3427 H2): a first-attach replay line carries the
|
|
542
|
+
// PRE-restart session's model — record it for freshness (unchanged
|
|
543
|
+
// behavior) but exclude it from divergence verification.
|
|
544
|
+
sessionModelSource.noteTranscriptModel(ev.model, { replayed: ev.replayed === true })
|
|
539
545
|
return
|
|
540
546
|
}
|
|
541
547
|
case 'usage': {
|
|
@@ -1623,16 +1629,25 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1623
1629
|
// corrects it in place.
|
|
1624
1630
|
//
|
|
1625
1631
|
// TWO distinct arbiters set synchronously here, before any `await`:
|
|
1626
|
-
// (a) `turn.answerDelivered` — the backstop-vs-LATE-REPLY
|
|
1627
|
-
// reply path already reads
|
|
1628
|
-
// `
|
|
1632
|
+
// (a) `turn.answerDelivered = 'flush'` — the backstop-vs-LATE-REPLY
|
|
1633
|
+
// signal the reply path already reads
|
|
1634
|
+
// (`decideAnswerLatchSuppression` + `flushedTurnSupersede`).
|
|
1635
|
+
// Source-tagged 'flush' (#3426): the late-reply suppression is
|
|
1636
|
+
// scoped to flush-armed latches, so a later async handback
|
|
1637
|
+
// attributed to a reply-delivered ended turn is never dropped.
|
|
1629
1638
|
// (b) `backstopDeliveryLedger.claim` — the backstop-vs-BACKSTOP
|
|
1630
1639
|
// double-fire latch: `claim` returning false means this turn
|
|
1631
1640
|
// already fired a backstop (answer-ready quiescence, then the
|
|
1632
1641
|
// turn-end backstop), so this fire is a no-op. It does NOT
|
|
1633
1642
|
// arbitrate the late reply (that is (a)); it is redundant-but-
|
|
1634
1643
|
// cheap with the `currentTurn == null` bail below.
|
|
1635
|
-
turn.answerDelivered =
|
|
1644
|
+
turn.answerDelivered = 'flush'
|
|
1645
|
+
// #3429 — stamp WHAT the flush is delivering alongside the arm, so the
|
|
1646
|
+
// late-reply suppression can discriminate by content: a late reply
|
|
1647
|
+
// carrying this same answer is the flush race (suppress/supersede); a
|
|
1648
|
+
// late reply carrying DIFFERENT content is a genuinely new async
|
|
1649
|
+
// handback attributed to this ended turn and must send fresh.
|
|
1650
|
+
turn.flushedAnswerText = capturedText
|
|
1636
1651
|
const backstopLatchClaimed = backstopDeliveryLedger.claim(turn.turnId)
|
|
1637
1652
|
|
|
1638
1653
|
// #654 deterministic double-message fix. Hand off the pinned
|
|
@@ -1830,6 +1845,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1830
1845
|
if (backstopCtrl) backstopCtrl.finalize('error')
|
|
1831
1846
|
backstopDeliveryLedger.release(turn.turnId)
|
|
1832
1847
|
turn.answerDelivered = false
|
|
1848
|
+
turn.flushedAnswerText = null // #3429 — cleared with the latch
|
|
1833
1849
|
} else if (backstopCtrl) {
|
|
1834
1850
|
backstopCtrl.finalize('done')
|
|
1835
1851
|
}
|
|
@@ -1854,6 +1870,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1854
1870
|
process.stderr.write(`telegram gateway: turn-flush post-delivery bookkeeping failed: ${(err as Error).message}\n`)
|
|
1855
1871
|
if (!delivered) {
|
|
1856
1872
|
turn.answerDelivered = false
|
|
1873
|
+
turn.flushedAnswerText = null // #3429 — cleared with the latch
|
|
1857
1874
|
backstopDeliveryLedger.release(turn.turnId)
|
|
1858
1875
|
if (backstopCtrl) backstopCtrl.finalize('error')
|
|
1859
1876
|
}
|
|
@@ -147,18 +147,41 @@ export function buildBar(pct: number, elapsedFrac: number): string {
|
|
|
147
147
|
|
|
148
148
|
// ── account status (title-line suffix) ───────────────────────────────
|
|
149
149
|
|
|
150
|
-
export type QuotaBarAccountStatus =
|
|
150
|
+
export type QuotaBarAccountStatus =
|
|
151
|
+
| 'active'
|
|
152
|
+
| 'org-disabled'
|
|
153
|
+
| 'retired'
|
|
154
|
+
| 'exhausted'
|
|
155
|
+
| 'idle';
|
|
156
|
+
|
|
157
|
+
/** Title-line display word for each status (some differ from the enum key). */
|
|
158
|
+
const STATUS_LABEL: Record<QuotaBarAccountStatus, string> = {
|
|
159
|
+
active: 'active',
|
|
160
|
+
'org-disabled': 'DISABLED (org)',
|
|
161
|
+
retired: 'retired',
|
|
162
|
+
exhausted: 'exhausted',
|
|
163
|
+
idle: 'idle',
|
|
164
|
+
};
|
|
151
165
|
|
|
152
166
|
/**
|
|
153
|
-
* Title-line status word. `active`
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
167
|
+
* Title-line status word. Precedence: `active` > `DISABLED (org)` > `retired` >
|
|
168
|
+
* `exhausted` > `idle`.
|
|
169
|
+
* - `active` wins over everything (the fleet's pinned account is reported
|
|
170
|
+
* active even if also flagged exhausted — the operator wants WHICH account
|
|
171
|
+
* is live first, health second, from the window rows underneath).
|
|
172
|
+
* - `org-disabled` (entitlement block, PR2) and `retired` (`in_service` false:
|
|
173
|
+
* removed from every config list) are OUT OF SERVICE — never "idle", which
|
|
174
|
+
* reads as an available-but-unused account.
|
|
175
|
+
* - else `exhausted` if the broker flags it, else `idle`.
|
|
159
176
|
*/
|
|
160
|
-
export function accountStatus(
|
|
177
|
+
export function accountStatus(
|
|
178
|
+
isActive: boolean,
|
|
179
|
+
exhausted: boolean,
|
|
180
|
+
opts: { inService?: boolean; entitlementBlocked?: boolean } = {},
|
|
181
|
+
): QuotaBarAccountStatus {
|
|
161
182
|
if (isActive) return 'active';
|
|
183
|
+
if (opts.entitlementBlocked === true) return 'org-disabled';
|
|
184
|
+
if (opts.inService === false) return 'retired';
|
|
162
185
|
if (exhausted) return 'exhausted';
|
|
163
186
|
return 'idle';
|
|
164
187
|
}
|
|
@@ -188,8 +211,9 @@ export function renderQuotaBarAccount(
|
|
|
188
211
|
quota: QuotaUtilization | null,
|
|
189
212
|
now: Date = new Date(),
|
|
190
213
|
demo = false,
|
|
214
|
+
service: { inService?: boolean; entitlementBlocked?: boolean } = {},
|
|
191
215
|
): string[] {
|
|
192
|
-
const status = accountStatus(isActive, exhausted);
|
|
216
|
+
const status = accountStatus(isActive, exhausted, service);
|
|
193
217
|
// Title line wraps `label` in GFM `**bold**`, NOT a code span — so this
|
|
194
218
|
// needs `escapeMarkdown` (backslash-escapes *, _, [, ], etc.), not
|
|
195
219
|
// `codeSpanSafe` (which only defuses backticks and is only correct
|
|
@@ -197,7 +221,18 @@ export function renderQuotaBarAccount(
|
|
|
197
221
|
// was a bug: a label containing e.g. `**` or `[x](url)` would break the
|
|
198
222
|
// bold run or inject a markdown link into the card.
|
|
199
223
|
const displayLabel = demo ? maskEmail(label) : label;
|
|
200
|
-
const lines: string[] = [`- **${escapeMarkdown(displayLabel)}** (${status})`];
|
|
224
|
+
const lines: string[] = [`- **${escapeMarkdown(displayLabel)}** (${STATUS_LABEL[status]})`];
|
|
225
|
+
// Out-of-service accounts (retired / org-disabled) have no meaningful live
|
|
226
|
+
// windows — a 0%/0% bar would read as "available", the exact bug this fixes.
|
|
227
|
+
// Replace the two window rows with a single status note (shape stays a list).
|
|
228
|
+
if (status === 'retired' || status === 'org-disabled') {
|
|
229
|
+
const note =
|
|
230
|
+
status === 'org-disabled'
|
|
231
|
+
? 'disabled by org — no fleet routing'
|
|
232
|
+
: 'retired — removed from fleet rotation';
|
|
233
|
+
lines.push(`- ⚫ \`${note}\``);
|
|
234
|
+
return lines;
|
|
235
|
+
}
|
|
201
236
|
if (!quota || isProbeThin(quota)) {
|
|
202
237
|
// Data-quality gap. A failed / thin probe carries NO real utilization
|
|
203
238
|
// signal, so it must NOT render as a healthy 🟢 0% bar — that's
|
|
@@ -288,10 +323,21 @@ export function renderQuotaBarBlock(
|
|
|
288
323
|
const now = opts.now ?? new Date();
|
|
289
324
|
const demo = opts.demo ?? false;
|
|
290
325
|
const lines: string[] = [];
|
|
291
|
-
|
|
326
|
+
// Out-of-service accounts (retired / org-disabled) sort LAST — stable
|
|
327
|
+
// partition preserves the caller's active-first ordering among the rest.
|
|
328
|
+
const outOfService = (s: AccountSnapshot) =>
|
|
329
|
+
!s.isActive && (s.entitlementBlocked === true || s.inService === false);
|
|
330
|
+
const ordered = [
|
|
331
|
+
...snapshots.filter((s) => !outOfService(s)),
|
|
332
|
+
...snapshots.filter(outOfService),
|
|
333
|
+
];
|
|
334
|
+
for (const snap of ordered) {
|
|
292
335
|
const exhausted = exhaustedByLabel.get(snap.label) ?? false;
|
|
293
336
|
lines.push(
|
|
294
|
-
...renderQuotaBarAccount(snap.label, snap.isActive, exhausted, snap.quota, now, demo
|
|
337
|
+
...renderQuotaBarAccount(snap.label, snap.isActive, exhausted, snap.quota, now, demo, {
|
|
338
|
+
inService: snap.inService,
|
|
339
|
+
entitlementBlocked: snap.entitlementBlocked,
|
|
340
|
+
}),
|
|
295
341
|
);
|
|
296
342
|
}
|
|
297
343
|
return lines.join('\n');
|
|
@@ -319,6 +365,8 @@ export function renderQuotaBarBlockFromListState(
|
|
|
319
365
|
quotaError: acc.last_quota ? undefined : 'no cached quota (no probe since broker start)',
|
|
320
366
|
expiresAtMs: acc.expiresAt,
|
|
321
367
|
capturedAtMs: acc.last_quota?.capturedAt,
|
|
368
|
+
inService: acc.in_service,
|
|
369
|
+
entitlementBlocked: acc.entitlement_blocked,
|
|
322
370
|
}));
|
|
323
371
|
return renderQuotaBarBlock(snapshots, exhaustedByLabel, { now });
|
|
324
372
|
}
|
|
@@ -107,6 +107,42 @@ export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): strin
|
|
|
107
107
|
)
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The answer-delivered latch value — SOURCE-TAGGED (#3426).
|
|
112
|
+
*
|
|
113
|
+
* `false` — no answer delivered this turn (latch unarmed).
|
|
114
|
+
* `'flush'` — the TURN-FLUSH backstop delivered (or is mid-delivering) this
|
|
115
|
+
* turn's answer as its own message A. Set synchronously at
|
|
116
|
+
* flush-fire time, and at supersede-record consumption (the
|
|
117
|
+
* resurrection window — a flush record existed for this turn).
|
|
118
|
+
* `'reply'` — a normally-delivered `reply` tool call carried this turn's
|
|
119
|
+
* answer (no flush involved).
|
|
120
|
+
*
|
|
121
|
+
* Why the tag exists: the late-reply suppression below is a race backstop for
|
|
122
|
+
* FLUSH duplicates only. A boolean latch also suppressed the async sub-agent
|
|
123
|
+
* handback pattern (#3426): the parent turn's interim ack (a substantive
|
|
124
|
+
* `reply`) armed the latch, the turn ended, and the sub-agent completion
|
|
125
|
+
* handback — a genuinely NEW answer arriving with no live gateway turn —
|
|
126
|
+
* resolved the ended turn as owner (latest-ended tier, inside the 60 s
|
|
127
|
+
* supersede TTL), saw the stale latch, and was silently dropped with a false
|
|
128
|
+
* "deduped" success. Tagging the source lets the suppression fire ONLY for the
|
|
129
|
+
* flush races it exists for; byte-identical replays of a reply-delivered
|
|
130
|
+
* answer remain covered by the content-keyed outbound dedup (#546).
|
|
131
|
+
*
|
|
132
|
+
* Honest bound on that dedup cover: the #546 TTL (60 s) is anchored at reply
|
|
133
|
+
* RECORD time, while the latest-ended owner tier's 60 s is anchored at the
|
|
134
|
+
* turn's `endedAt` — later by the reply→turn_end gap. A byte-identical replay
|
|
135
|
+
* landing >60 s after record but ≤60 s after endedAt is evicted from dedup yet
|
|
136
|
+
* still resolves the ended turn, so it DELIVERS as a duplicate message. That
|
|
137
|
+
* is a conscious trade: this fix also drops the weak "reworded/bridge-replayed
|
|
138
|
+
* duplicate" suppression the reply-armed boolean latch used to provide —
|
|
139
|
+
* replays of an un-acked tool_call are byte-identical (content dedup's case),
|
|
140
|
+
* and a model-REGENERATED paraphrase is indistinguishable from a genuinely new
|
|
141
|
+
* handback, so delivering it is the correct default. A rare duplicate message
|
|
142
|
+
* beats the silent handback drop.
|
|
143
|
+
*/
|
|
144
|
+
export type AnswerDeliveredLatch = false | 'flush' | 'reply'
|
|
145
|
+
|
|
110
146
|
/**
|
|
111
147
|
* The answer-delivered latch inputs (Part 2 — the race backstop).
|
|
112
148
|
*
|
|
@@ -118,11 +154,11 @@ export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): strin
|
|
|
118
154
|
* There `flushed-turn-supersede` finds no record (nothing to delete yet) and the
|
|
119
155
|
* reply would ship message B as a duplicate of the flush's message A.
|
|
120
156
|
*
|
|
121
|
-
* The latch closes that window: the gateway sets `answerDelivered =
|
|
122
|
-
* turn atom SYNCHRONOUSLY at flush-fire time — before the ~500 ms async send
|
|
123
|
-
* before the record — and the flag persists on the ended turn (readable via
|
|
124
|
-
* unified resolver after `currentTurn` is null). A reply landing in the race
|
|
125
|
-
* window then sees the latch already set and suppresses itself.
|
|
157
|
+
* The latch closes that window: the gateway sets `answerDelivered = 'flush'` on
|
|
158
|
+
* the turn atom SYNCHRONOUSLY at flush-fire time — before the ~500 ms async send
|
|
159
|
+
* and before the record — and the flag persists on the ended turn (readable via
|
|
160
|
+
* the unified resolver after `currentTurn` is null). A reply landing in the race
|
|
161
|
+
* window then sees the flush latch already set and suppresses itself.
|
|
126
162
|
*/
|
|
127
163
|
export interface AnswerLatchSuppressInput {
|
|
128
164
|
/** True when Part 1's supersede already fired for THIS reply (message A was
|
|
@@ -140,21 +176,50 @@ export interface AnswerLatchSuppressInput {
|
|
|
140
176
|
* legitimate second in-turn substantive reply (a genuine multi-message
|
|
141
177
|
* answer, live currentTurn) untouched. */
|
|
142
178
|
isLateReply: boolean
|
|
143
|
-
/** The resolved owner turn's `answerDelivered` latch. */
|
|
144
|
-
ownerAnswerDelivered:
|
|
179
|
+
/** The resolved owner turn's source-tagged `answerDelivered` latch. */
|
|
180
|
+
ownerAnswerDelivered: AnswerDeliveredLatch
|
|
181
|
+
/** #3429 — content evidence: does the landing reply carry the SAME answer
|
|
182
|
+
* the flush delivered (`flushedAnswerMatchesReply` against the supersede
|
|
183
|
+
* record's text or the owner turn's stashed `flushedAnswerText`)?
|
|
184
|
+
* - `false` → POSITIVE evidence of genuinely new content (an async
|
|
185
|
+
* handback attributed to the flush-delivered ended turn).
|
|
186
|
+
* Suppressing it would silently drop the user's answer —
|
|
187
|
+
* never suppress.
|
|
188
|
+
* - `true` → the reply is the flushed answer landing again — suppress
|
|
189
|
+
* (the flush race the latch exists for).
|
|
190
|
+
* - `null` / omitted → no flushed text available to compare (legacy atom,
|
|
191
|
+
* pre-#3429 caller). Conservative: keep the pre-#3429
|
|
192
|
+
* flush-armed suppression. */
|
|
193
|
+
replyMatchesFlushedAnswer?: boolean | null
|
|
145
194
|
}
|
|
146
195
|
|
|
147
196
|
/**
|
|
148
197
|
* Decide whether the answer-delivered latch suppresses a landing reply.
|
|
149
198
|
*
|
|
150
199
|
* Suppress IFF: Part 1 did NOT already supersede, the reply is a substantive
|
|
151
|
-
* final answer, it is a late reply (no live turn), AND the owner turn's latch
|
|
152
|
-
*
|
|
153
|
-
* the pre-record race window
|
|
200
|
+
* final answer, it is a late reply (no live turn), AND the owner turn's latch
|
|
201
|
+
* was armed by the TURN-FLUSH path (`'flush'` — the flush delivered the same
|
|
202
|
+
* substantive answer as message A in the pre-record race window, or the
|
|
203
|
+
* supersede-consumed resurrection window). Otherwise the reply sends.
|
|
204
|
+
*
|
|
205
|
+
* A `'reply'`-armed latch deliberately does NOT suppress (#3426): the prior
|
|
206
|
+
* answer went out via a normal, completed `reply`, so a later late-landing
|
|
207
|
+
* reply attributed to that ended turn is NOT a flush duplicate — it is
|
|
208
|
+
* (typically) an async sub-agent handback carrying genuinely new content, and
|
|
209
|
+
* suppressing it silently drops the user's answer. Byte-identical replays of
|
|
210
|
+
* the delivered reply are still deduped by the content-keyed #546 cache.
|
|
211
|
+
*
|
|
212
|
+
* #3429 refinement: even a FLUSH-armed latch does not suppress when there is
|
|
213
|
+
* POSITIVE content evidence (`replyMatchesFlushedAnswer === false`) that the
|
|
214
|
+
* landing reply is NOT the flushed answer — an async handback attributed to a
|
|
215
|
+
* flush-delivered ended turn must send fresh, not vanish. Absent evidence
|
|
216
|
+
* (`null`/omitted) the flush-armed suppression holds, preserving the #2996
|
|
217
|
+
* Part 2 race backstop.
|
|
154
218
|
*/
|
|
155
219
|
export function decideAnswerLatchSuppression(input: AnswerLatchSuppressInput): boolean {
|
|
156
220
|
if (input.superseded) return false
|
|
157
221
|
if (!input.replySubstantive) return false
|
|
158
222
|
if (!input.isLateReply) return false
|
|
159
|
-
|
|
223
|
+
if (input.replyMatchesFlushedAnswer === false) return false
|
|
224
|
+
return input.ownerAnswerDelivered === 'flush'
|
|
160
225
|
}
|
|
@@ -100,7 +100,12 @@ export type SessionEvent =
|
|
|
100
100
|
// same batch already reflects the current model. Sentinels (`<synthetic>` on
|
|
101
101
|
// compaction lines, fixture junk) are filtered at projection — see
|
|
102
102
|
// isModelSentinel — so this only ever carries a real resolved model id.
|
|
103
|
-
|
|
103
|
+
// `replayed: true` marks a model observation delivered by the FIRST-ATTACH
|
|
104
|
+
// replay of a prior session's in-flight turn (computeFirstAttachCursor) —
|
|
105
|
+
// it reflects the PRE-restart session's model, not the live one. Consumers
|
|
106
|
+
// that verify the live model (#3427 divergence tripwire, H2) must skip
|
|
107
|
+
// replayed observations; freshness consumers may still record them.
|
|
108
|
+
| { kind: 'model'; model: string; replayed?: boolean }
|
|
104
109
|
| { kind: 'tool_use'; toolName: string; toolUseId?: string | null; input?: Record<string, unknown>; precomputedLabel?: string }
|
|
105
110
|
// Real-time tool label from the PreToolUse-hook sidecar — fires when the
|
|
106
111
|
// hook writes the label (synchronous at tool-call time), independent of
|
|
@@ -1217,6 +1222,17 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
1217
1222
|
// re-attach.
|
|
1218
1223
|
const fileCursors = new Map<string, { cursor: number; pendingPartial: string }>()
|
|
1219
1224
|
|
|
1225
|
+
// First-attach REPLAY window per file (#3427 H2): when attachToFile replays
|
|
1226
|
+
// a prior session's in-flight turn (computeFirstAttachCursor returned an
|
|
1227
|
+
// offset below the size-at-attach), every byte below that size is HISTORY
|
|
1228
|
+
// written by the pre-restart session. Model observations projected from it
|
|
1229
|
+
// must be marked `replayed` so the divergence tripwire ignores them.
|
|
1230
|
+
// Granularity is the read CHUNK (a batch whose read started inside the
|
|
1231
|
+
// window is marked wholesale) — deliberately conservative: over-marking a
|
|
1232
|
+
// boundary batch can only delay verification to the next live line, never
|
|
1233
|
+
// false-accuse.
|
|
1234
|
+
const replayUntilByFile = new Map<string, number>()
|
|
1235
|
+
|
|
1220
1236
|
function readNew(): void {
|
|
1221
1237
|
if (stopped || !currentFile) return
|
|
1222
1238
|
try {
|
|
@@ -1226,9 +1242,14 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
1226
1242
|
// stored per-file state for this path.
|
|
1227
1243
|
cursor = 0
|
|
1228
1244
|
pendingPartial = ''
|
|
1229
|
-
if (currentFile != null)
|
|
1245
|
+
if (currentFile != null) {
|
|
1246
|
+
fileCursors.delete(currentFile)
|
|
1247
|
+
replayUntilByFile.delete(currentFile)
|
|
1248
|
+
}
|
|
1230
1249
|
}
|
|
1231
1250
|
if (stat.size === cursor) return
|
|
1251
|
+
const chunkStart = cursor
|
|
1252
|
+
const isReplayChunk = chunkStart < (replayUntilByFile.get(currentFile) ?? 0)
|
|
1232
1253
|
const buf = Buffer.alloc(stat.size - cursor)
|
|
1233
1254
|
const fd = openSync(currentFile, 'r')
|
|
1234
1255
|
try {
|
|
@@ -1247,7 +1268,7 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
1247
1268
|
const sid = sessionIdForFile(currentFile)
|
|
1248
1269
|
for (const ev of events) {
|
|
1249
1270
|
try {
|
|
1250
|
-
onEvent(decorate(ev, sid))
|
|
1271
|
+
onEvent(decorate(isReplayChunk && ev.kind === 'model' ? { ...ev, replayed: true } : ev, sid))
|
|
1251
1272
|
} catch (err) {
|
|
1252
1273
|
log?.(`session-tail: onEvent threw: ${(err as Error).message}`)
|
|
1253
1274
|
}
|
|
@@ -1322,6 +1343,9 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
1322
1343
|
const size = statSync(file).size
|
|
1323
1344
|
cursor = computeFirstAttachCursor(file, size)
|
|
1324
1345
|
if (cursor < size) {
|
|
1346
|
+
// #3427 H2: everything below size-at-attach is pre-restart history;
|
|
1347
|
+
// model events projected from it are marked `replayed` in readNew.
|
|
1348
|
+
replayUntilByFile.set(file, size)
|
|
1325
1349
|
log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`)
|
|
1326
1350
|
} else {
|
|
1327
1351
|
log?.(`session-tail: attached to ${file} (cursor=${cursor})`)
|