switchroom 0.18.26 → 0.18.27
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/README.md +6 -2
- package/dist/cli/ms-365-write-pretool.mjs +4953 -14
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +16 -0
- package/telegram-plugin/dist/gateway/gateway.js +494 -36
- package/telegram-plugin/flushed-turn-supersede.ts +58 -0
- package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +305 -41
- package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
- package/telegram-plugin/gateway/model-command.ts +68 -0
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
- package/telegram-plugin/send-gate.test.ts +138 -0
- package/telegram-plugin/send-gate.ts +104 -1
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
- package/telegram-plugin/tests/effort-command.test.ts +47 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
- package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
- package/telegram-plugin/tests/model-command.test.ts +112 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
- package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
- package/telegram-plugin/worker-activity-feed.ts +91 -1
|
@@ -113,6 +113,64 @@ export function decideSupersede(
|
|
|
113
113
|
return { supersede: true, deleteMessageIds: [...record.messageIds], reason: 'supersede' }
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* How the gateway should CORRECT a flushed message once its canonical `reply`
|
|
118
|
+
* lands. Historically the supersede always did `deleteMessage(A)` + fresh send
|
|
119
|
+
* of the canonical reply B — visible to the user as a delete+replace flicker
|
|
120
|
+
* plus a second device ping. When the reply fits a single message and matches
|
|
121
|
+
* the flushed message's type (both plain text, the normal case), we can instead
|
|
122
|
+
* edit message A in place with B's content: no delete, no flicker, no re-ping.
|
|
123
|
+
*
|
|
124
|
+
* - `edit-in-place` → the gateway feeds `editMessageId` into the existing
|
|
125
|
+
* single-message edit path (the `previewMessageId` edit lane in
|
|
126
|
+
* `sendReplyChunks`), which renders the canonical reply with the SAME rich
|
|
127
|
+
* path a fresh reply uses and, on any edit 400 (message too old / can't be
|
|
128
|
+
* edited / not found), falls back to delete+resend — so correctness never
|
|
129
|
+
* regresses. `deleteMessageIds` is empty (message A becomes message B).
|
|
130
|
+
* - `delete-resend` → the legacy behaviour: delete every flushed message id,
|
|
131
|
+
* then send the canonical reply fresh. Chosen whenever edit-in-place isn't
|
|
132
|
+
* safe (multi-part reply, >1 flushed message, a file/voice-only reply, or a
|
|
133
|
+
* draft-stream preview already owns the single-message edit lane).
|
|
134
|
+
*/
|
|
135
|
+
export type SupersedeCorrection =
|
|
136
|
+
| { mode: 'edit-in-place'; editMessageId: number; deleteMessageIds: number[] }
|
|
137
|
+
| { mode: 'delete-resend'; deleteMessageIds: number[] }
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Decide how to correct a superseded flush. Pure so the gateway runs the exact
|
|
141
|
+
* branch the regression tests exercise. Edit-in-place is chosen IFF the flush
|
|
142
|
+
* posted exactly ONE message AND the canonical reply is a single plain-text
|
|
143
|
+
* message with no competing edit target:
|
|
144
|
+
* - `flushMessageIds.length === 1` — a multi-message flush has no single
|
|
145
|
+
* edit target; delete all and resend.
|
|
146
|
+
* - `chunkCount === 1` — a multi-part reply can't collapse into one edit.
|
|
147
|
+
* - `!hasFiles` — a file/album reply is not a plain-text edit.
|
|
148
|
+
* - `!suppressText` — a voice-only reply sends no text body to edit into.
|
|
149
|
+
* - `!hasOpenPreview` — a live draft-stream preview already owns the
|
|
150
|
+
* single-message edit lane; deleting the flush and letting the preview edit
|
|
151
|
+
* keeps exactly one message.
|
|
152
|
+
* `literalText` (`format:'text'`) stays eligible — it is still a text message,
|
|
153
|
+
* and the edit lane renders literal vs rich identically to a fresh send.
|
|
154
|
+
*/
|
|
155
|
+
export function decideSupersedeCorrection(input: {
|
|
156
|
+
flushMessageIds: number[]
|
|
157
|
+
chunkCount: number
|
|
158
|
+
hasFiles: boolean
|
|
159
|
+
suppressText: boolean
|
|
160
|
+
hasOpenPreview: boolean
|
|
161
|
+
}): SupersedeCorrection {
|
|
162
|
+
const eligible =
|
|
163
|
+
input.flushMessageIds.length === 1 &&
|
|
164
|
+
input.chunkCount === 1 &&
|
|
165
|
+
!input.hasFiles &&
|
|
166
|
+
!input.suppressText &&
|
|
167
|
+
!input.hasOpenPreview
|
|
168
|
+
if (eligible) {
|
|
169
|
+
return { mode: 'edit-in-place', editMessageId: input.flushMessageIds[0]!, deleteMessageIds: [] }
|
|
170
|
+
}
|
|
171
|
+
return { mode: 'delete-resend', deleteMessageIds: [...input.flushMessageIds] }
|
|
172
|
+
}
|
|
173
|
+
|
|
116
174
|
/** Sentinel key for records whose flush carried no turnId nonce. */
|
|
117
175
|
const NULL_TURN_KEY = '<<null-turn>>'
|
|
118
176
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* derive-turn-id.ts — the stable per-turn identity, extracted from the gateway
|
|
3
|
+
* monolith so BOTH the enqueue seam and out-of-gateway tests use the SAME
|
|
4
|
+
* function (no mirror-drift between production and the round-trip test).
|
|
5
|
+
*
|
|
6
|
+
* Component 3 — derive the stable per-turn identity from the chat, thread, and
|
|
7
|
+
* originating message id. Stamped into the inbound meta at build time
|
|
8
|
+
* (`origin_turn_id`) AND reconstructed at enqueue time from the same three
|
|
9
|
+
* values, so the id stamped on the message the model reads matches the id on
|
|
10
|
+
* the turn the gateway started for it. Using the message id (not the
|
|
11
|
+
* not-yet-known startedAt) is what lets the two sites agree. Returns null when
|
|
12
|
+
* there is no message id (synthetic / cron turns with no originating inbound —
|
|
13
|
+
* they never need origin routing, the live turn IS the origin).
|
|
14
|
+
*
|
|
15
|
+
* NOTE (#3268): a subagent-handback IS a synthetic inbound, but it MUST still
|
|
16
|
+
* derive a stable non-null id so the dead-air pre-turn card can be adopted by
|
|
17
|
+
* identity at enqueue. The handback inbound builder therefore rounds-trips its
|
|
18
|
+
* fabricated `ts` through `meta.message_id` (mirroring resume-inbound-builder),
|
|
19
|
+
* so `ev.messageId` is populated at enqueue and this function yields the SAME
|
|
20
|
+
* `chatKey#ts` the pre-turn seam recorded at release.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { chatKey } from './chat-key.js'
|
|
24
|
+
|
|
25
|
+
export function deriveTurnId(
|
|
26
|
+
chatId: string,
|
|
27
|
+
threadId: number | null | undefined,
|
|
28
|
+
messageId: string | number | null | undefined,
|
|
29
|
+
): string | null {
|
|
30
|
+
if (messageId == null || messageId === '' || String(messageId) === '0') return null
|
|
31
|
+
return `${chatKey(chatId, threadId ?? null)}#${messageId}`
|
|
32
|
+
}
|
|
@@ -88,7 +88,7 @@ import {
|
|
|
88
88
|
type TelegraphAccount,
|
|
89
89
|
} from '../telegraph.js'
|
|
90
90
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
91
|
-
import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS } from '../flushed-turn-supersede.js'
|
|
91
|
+
import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS, decideSupersedeCorrection } from '../flushed-turn-supersede.js'
|
|
92
92
|
import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
|
|
93
93
|
import {
|
|
94
94
|
splitCoalescedAttachments,
|
|
@@ -198,6 +198,11 @@ import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrati
|
|
|
198
198
|
import { toolLabel } from '../tool-labels.js'
|
|
199
199
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
200
200
|
import { createTurnTypingLoop } from './turn-typing-loop.js'
|
|
201
|
+
import {
|
|
202
|
+
createHandbackPreturnSignal,
|
|
203
|
+
type PreTurnCardRecord,
|
|
204
|
+
} from './handback-preturn-signal.js'
|
|
205
|
+
import { deriveTurnId } from './derive-turn-id.js'
|
|
201
206
|
import { createTypingEmitter, TYPING_REFRESH_MS } from '../typing-emitter.js'
|
|
202
207
|
import { type DraftStreamHandle } from '../draft-stream.js'
|
|
203
208
|
import { handlePtyPartialPure, type PtyHandlerState } from '../pty-partial-handler.js'
|
|
@@ -463,6 +468,7 @@ import { handleInjectCommand, type InjectDeps } from './inject-handler.js'
|
|
|
463
468
|
import {
|
|
464
469
|
parseModelCommand,
|
|
465
470
|
planModelCommand,
|
|
471
|
+
resolveStaleAwareBusy,
|
|
466
472
|
modelCommandReceiptLine,
|
|
467
473
|
handleModelCommand,
|
|
468
474
|
buildModelMenu,
|
|
@@ -802,7 +808,10 @@ import {
|
|
|
802
808
|
removeTurnActiveMarker,
|
|
803
809
|
sweepStaleTurnActiveMarker,
|
|
804
810
|
readTurnActiveMarkerAgeMs,
|
|
811
|
+
effectiveTurnAgeMs,
|
|
805
812
|
TURN_ACTIVE_MARKER_FILE,
|
|
813
|
+
TURN_ACTIVE_HARD_TTL_MS,
|
|
814
|
+
TURN_ACTIVE_IDLE_SWEEP_MS,
|
|
806
815
|
} from './turn-active-marker.js'
|
|
807
816
|
import {
|
|
808
817
|
VERSION,
|
|
@@ -2859,14 +2868,91 @@ function turnInFlightForGate(): boolean {
|
|
|
2859
2868
|
// handlers and do not sit behind this gate — and the block is surfaced
|
|
2860
2869
|
// off-Telegram in blocked-approvals/<agent>.json.
|
|
2861
2870
|
const hasPendingApproval = pendingPermissions.size > 0
|
|
2862
|
-
|
|
2871
|
+
// The shared inbound gate stays STRICT: unconditionally busy whenever an
|
|
2872
|
+
// approval card is outstanding (#2841 — a new inbound in that window would
|
|
2873
|
+
// displace the approval context and orphan the pending MCP call). Only the
|
|
2874
|
+
// machine-in-turn leg is factored out (`turnInFlightMachineOnly`); the
|
|
2875
|
+
// `|| hasPendingApproval` composition here is what preserves the contract.
|
|
2876
|
+
return turnInFlightMachineOnly() || hasPendingApproval
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
/**
|
|
2880
|
+
* The machine-in-turn portion of the gate WITHOUT the `pendingPermissions`
|
|
2881
|
+
* hold — "claude is actively producing a turn right now", ignoring an
|
|
2882
|
+
* outstanding approval card. `turnInFlightForGate()` = this OR a pending
|
|
2883
|
+
* approval, so the shared inbound-hold contract (#2841) is unchanged.
|
|
2884
|
+
*
|
|
2885
|
+
* Factored out so the `/model` & `/effort` busy decision can read THIS
|
|
2886
|
+
* machine-only leg (plus a TTL-bounded approval check in
|
|
2887
|
+
* `resolveModelEffortBusy`) — a wedged / undeliverable approval that "never
|
|
2888
|
+
* expires by design" (#3084) must not hold their switch queued forever on an
|
|
2889
|
+
* idle session (#3262), even though it correctly holds the general inbound gate.
|
|
2890
|
+
*/
|
|
2891
|
+
function turnInFlightMachineOnly(): boolean {
|
|
2892
|
+
if (!isDeliveryCutoverEnabled()) return claudeBusyKeys.size > 0
|
|
2863
2893
|
// Machine is authoritative. Run the log-only drift canary (#2794): the
|
|
2864
2894
|
// imperative `claudeBusyKeys` shadow is still live in parallel, so a
|
|
2865
2895
|
// dangerous over-hold divergence (machine holds the gate while the
|
|
2866
2896
|
// imperative view is idle) is surfaced without changing behaviour. The
|
|
2867
2897
|
// benign orphan-dangle direction — the wedge the machine self-heals — is
|
|
2868
2898
|
// NOT flagged. `probeGateParity` returns the machine value unchanged.
|
|
2869
|
-
return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
|
|
2899
|
+
return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
/**
|
|
2903
|
+
* Age (ms) of the live turn for the phantom-turn cross-check (#3262): the
|
|
2904
|
+
* turn-active liveness marker's mtime age (touched on every foreground
|
|
2905
|
+
* tool_use AND on sub-agent JSONL growth, so a genuinely long turn keeps it
|
|
2906
|
+
* small), falling back to `now - turn.startedAt` when the marker is absent
|
|
2907
|
+
* (e.g. already swept). Null when no turn atom is set. A large age on a
|
|
2908
|
+
* non-null atom is the dangling-atom signal.
|
|
2909
|
+
*/
|
|
2910
|
+
function liveTurnAgeMs(now: number): number | null {
|
|
2911
|
+
if (currentTurn === null) return null
|
|
2912
|
+
return effectiveTurnAgeMs(readTurnActiveMarkerAgeMs(STATE_DIR, now), currentTurn.startedAt, now)
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
/** Age (ms) of the OLDEST outstanding pending approval, or null when none. */
|
|
2916
|
+
function oldestPendingApprovalAgeMs(now: number): number | null {
|
|
2917
|
+
let oldest: number | null = null
|
|
2918
|
+
for (const p of pendingPermissions.values()) {
|
|
2919
|
+
const age = now - p.startedAt
|
|
2920
|
+
if (oldest === null || age > oldest) oldest = age
|
|
2921
|
+
}
|
|
2922
|
+
return oldest
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
/**
|
|
2926
|
+
* Resolve the apply-vs-queue busy signals for a `/model` or `/effort` command,
|
|
2927
|
+
* sweeping a stale (dangling) turn atom and discounting a wedged (undeliverable)
|
|
2928
|
+
* approval hold — both bounded by the SAME `TURN_ACTIVE_HARD_TTL_MS` ceiling the
|
|
2929
|
+
* marker sweep uses (#3262). A genuinely in-flight turn (fresh marker) and a
|
|
2930
|
+
* recent pending approval still read busy, so the #3017/#3039 apply-or-queue
|
|
2931
|
+
* contract and #3177/#3180 are preserved. Clears the dangling atom as a side
|
|
2932
|
+
* effect so the phantom can't re-block the next command.
|
|
2933
|
+
*/
|
|
2934
|
+
function resolveModelEffortBusy(now = Date.now()): { currentTurnActive: boolean; turnInFlight: boolean } {
|
|
2935
|
+
const turnAgeMs = liveTurnAgeMs(now)
|
|
2936
|
+
const resolved = resolveStaleAwareBusy({
|
|
2937
|
+
currentTurnActive: currentTurn !== null,
|
|
2938
|
+
turnAgeMs,
|
|
2939
|
+
machineInTurn: turnInFlightMachineOnly(),
|
|
2940
|
+
oldestPendingApprovalAgeMs: oldestPendingApprovalAgeMs(now),
|
|
2941
|
+
hardTtlMs: TURN_ACTIVE_HARD_TTL_MS,
|
|
2942
|
+
})
|
|
2943
|
+
if (resolved.clearStaleTurn && currentTurn !== null) {
|
|
2944
|
+
const ageSec = Math.round((turnAgeMs ?? 0) / 1000)
|
|
2945
|
+
process.stderr.write(
|
|
2946
|
+
`telegram gateway: [phantomturn] cleared stale currentTurn atom age=${ageSec}s ` +
|
|
2947
|
+
`ttl=${Math.round(TURN_ACTIVE_HARD_TTL_MS / 1000)}s for /model|/effort busy-check agent=${getMyAgentName()}\n`,
|
|
2948
|
+
)
|
|
2949
|
+
// Single-tenant / sequential-CLI invariant: the singleton `currentTurn` IS
|
|
2950
|
+
// the only live turn, and a stale atom is a ghost (its `turn_end` never
|
|
2951
|
+
// fired) — clearing every entry is equivalent to clearing it, matching the
|
|
2952
|
+
// bridge-died "every entry is a ghost" semantics.
|
|
2953
|
+
clearAllCurrentTurns()
|
|
2954
|
+
}
|
|
2955
|
+
return { currentTurnActive: resolved.currentTurnActive, turnInFlight: resolved.turnInFlight }
|
|
2870
2956
|
}
|
|
2871
2957
|
|
|
2872
2958
|
/**
|
|
@@ -3674,25 +3760,10 @@ function findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTur
|
|
|
3674
3760
|
if (turn == null || turn.sessionChatId !== chatId) return null
|
|
3675
3761
|
return turn
|
|
3676
3762
|
}
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
* values, so the id stamped on the message the model reads matches the id
|
|
3682
|
-
* on the turn the gateway started for it. Using the message id (not the
|
|
3683
|
-
* not-yet-known startedAt) is what lets the two sites agree. Returns null
|
|
3684
|
-
* when there is no message id (synthetic / cron / handback turns have no
|
|
3685
|
-
* originating inbound — they never need origin routing, the live turn IS
|
|
3686
|
-
* the origin).
|
|
3687
|
-
*/
|
|
3688
|
-
function deriveTurnId(
|
|
3689
|
-
chatId: string,
|
|
3690
|
-
threadId: number | null | undefined,
|
|
3691
|
-
messageId: string | number | null | undefined,
|
|
3692
|
-
): string | null {
|
|
3693
|
-
if (messageId == null || messageId === '' || String(messageId) === '0') return null
|
|
3694
|
-
return `${chatKey(chatId, threadId ?? null)}#${messageId}`
|
|
3695
|
-
}
|
|
3763
|
+
// Component 3 — the stable per-turn identity. Extracted to `derive-turn-id.ts`
|
|
3764
|
+
// (#3268) so the enqueue seam and the handback round-trip test share ONE
|
|
3765
|
+
// function. Re-exported into scope under the original name so every existing
|
|
3766
|
+
// callsite is unchanged.
|
|
3696
3767
|
|
|
3697
3768
|
/**
|
|
3698
3769
|
* Component 3 — resolve the turn that OWNS a reply by its `origin_turn_id`
|
|
@@ -7767,8 +7838,8 @@ const pendingStateReaper = setInterval(() => {
|
|
|
7767
7838
|
try {
|
|
7768
7839
|
sweepStaleTurnActiveMarker(STATE_DIR, {
|
|
7769
7840
|
turnInFlight: currentTurn?.registryKey != null,
|
|
7770
|
-
idleSweepMs:
|
|
7771
|
-
hardTtlMs:
|
|
7841
|
+
idleSweepMs: TURN_ACTIVE_IDLE_SWEEP_MS,
|
|
7842
|
+
hardTtlMs: TURN_ACTIVE_HARD_TTL_MS,
|
|
7772
7843
|
now,
|
|
7773
7844
|
onRemove: ({ ageMs, reason, payload }) => {
|
|
7774
7845
|
const agent = getMyAgentName()
|
|
@@ -8883,11 +8954,20 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
8883
8954
|
const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
|
|
8884
8955
|
path: ACTIVITY_CARD_STORE_PATH,
|
|
8885
8956
|
fs: activityCardStoreFs,
|
|
8957
|
+
// A synthetic pre-turn record's key is never a live topic key, so it is
|
|
8958
|
+
// correctly seen as ownerless here (the seam's own age-based self-reap is
|
|
8959
|
+
// the fast path; this is the crash / long-lived backstop).
|
|
8886
8960
|
isLive: (record) => topicKeys.has(record.turnKey),
|
|
8887
8961
|
ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
|
|
8888
8962
|
now,
|
|
8889
|
-
finalizeCard: (record) =>
|
|
8890
|
-
|
|
8963
|
+
finalizeCard: (record) => {
|
|
8964
|
+
// Reap hook (design lever 4): a never-adopted pre-turn card being
|
|
8965
|
+
// finalized here must also stop its forever-running typing loop and
|
|
8966
|
+
// drop the seam's in-memory entry.
|
|
8967
|
+
if (handbackPreturnSignal.isPreTurnRecord(record.turnKey)) {
|
|
8968
|
+
handbackPreturnSignal.handleReaped(record.turnKey)
|
|
8969
|
+
}
|
|
8970
|
+
return robustApiCall(
|
|
8891
8971
|
() =>
|
|
8892
8972
|
lockedBot.api.editMessageText(
|
|
8893
8973
|
record.chatId,
|
|
@@ -8900,7 +8980,8 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
8900
8980
|
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
8901
8981
|
verb: 'activity-card.mid-session-reap-finalize',
|
|
8902
8982
|
},
|
|
8903
|
-
)
|
|
8983
|
+
)
|
|
8984
|
+
},
|
|
8904
8985
|
// #3001: route through reconcileStatusPin when the pin is a live
|
|
8905
8986
|
// in-memory claim, so the claim AND the durable status-pins.json row
|
|
8906
8987
|
// clear together (the raw-API unpin left both behind — the boot sweep
|
|
@@ -10002,6 +10083,15 @@ _deliveryMachineTick.unref?.()
|
|
|
10002
10083
|
// synthetic-source/empty, which never produce an `enqueue` and would otherwise
|
|
10003
10084
|
// re-deliver forever.
|
|
10004
10085
|
function trackRedeliveredInbound(merged: InboundMessage): void {
|
|
10086
|
+
// Dead-air pre-turn signal: this is the shared CONFIRMED-release chokepoint
|
|
10087
|
+
// (buffer drain, idle-drain tick, bridge re-register). A released
|
|
10088
|
+
// subagent-handback gets a `typing…` + pre-turn card the imminent turn
|
|
10089
|
+
// adopts; a no-op for every non-handback inbound (guarded inside the seam).
|
|
10090
|
+
// Emitted BEFORE the delivery-confirm early-return so it fires regardless of
|
|
10091
|
+
// that feature flag. Design lever 1: emit at release, do NOT gate on
|
|
10092
|
+
// turn-in-flight (the common case is a worker finishing while the parent is
|
|
10093
|
+
// mid-turn on another topic).
|
|
10094
|
+
if (HANDBACK_PRETURN_ENABLED) handbackPreturnSignal.noteHandbackRelease(merged)
|
|
10005
10095
|
if (!DELIVERY_CONFIRM_ENABLED) return
|
|
10006
10096
|
// The boot-resume synthetic ('resume_interrupted') is the ONE synthetic we DO
|
|
10007
10097
|
// enrol: a restart can drop it into a not-ready session exactly like a user
|
|
@@ -13292,6 +13382,15 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
13292
13382
|
// exact-text `outboundDedup` above structurally cannot catch. A reply for a
|
|
13293
13383
|
// DIFFERENT newer live turn never supersedes (decideSupersede → different-turn),
|
|
13294
13384
|
// so a fresh turn's answer is never clobbered.
|
|
13385
|
+
//
|
|
13386
|
+
// Reply-flicker fix: rather than delete the flushed message(s) HERE (which
|
|
13387
|
+
// makes the user see delete+replace when the canonical reply sends fresh
|
|
13388
|
+
// below), we DEFER the correction to the send site. Once chunk count / files /
|
|
13389
|
+
// preview are known, `decideSupersedeCorrection` picks edit-in-place (edit the
|
|
13390
|
+
// single flushed message into the canonical reply — no flicker, no re-ping)
|
|
13391
|
+
// when the reply fits one plain-text message, and falls back to the legacy
|
|
13392
|
+
// delete+resend otherwise. `supersedeFlushIds` carries the ids forward.
|
|
13393
|
+
let supersedeFlushIds: number[] = []
|
|
13295
13394
|
{
|
|
13296
13395
|
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
13297
13396
|
// 2026-07 double-reply-on-DM fix (Part 1) — resolve the turn this reply
|
|
@@ -13319,12 +13418,24 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
13319
13418
|
`telegram gateway: reply: superseding flushed turn message(s) ` +
|
|
13320
13419
|
`chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}\n`,
|
|
13321
13420
|
)
|
|
13322
|
-
|
|
13323
|
-
|
|
13324
|
-
|
|
13325
|
-
|
|
13326
|
-
|
|
13327
|
-
|
|
13421
|
+
// Deferred: the correction (edit-in-place vs delete+resend) is decided at
|
|
13422
|
+
// the send site once chunk count / files / preview are known.
|
|
13423
|
+
supersedeFlushIds = decision.deleteMessageIds
|
|
13424
|
+
// Set the answer-delivered latch NOW, at record consumption — BEFORE the
|
|
13425
|
+
// arg-validation throws between here and the correction site (file
|
|
13426
|
+
// too-large ~L13699, inline_keyboard invalid ~L13782). Without this, a
|
|
13427
|
+
// late reply that supersedes a flush AND carries an oversized file /
|
|
13428
|
+
// invalid keyboard would throw before the correction runs (message A
|
|
13429
|
+
// neither deleted nor edited), the model would retry `reply`, and — the
|
|
13430
|
+
// supersede record already consumed by `take()` above — the retry would
|
|
13431
|
+
// fall into the else/no-record branch below with no latch set, so
|
|
13432
|
+
// suppression wouldn't fire and a fresh B would ship alongside the stale
|
|
13433
|
+
// narration A (both visible). Latching here mirrors the else-branch's own
|
|
13434
|
+
// `answerDelivered = true` and closes that resurrection window: the retry
|
|
13435
|
+
// resolves the same ended owner turn, sees the latch, and is suppressed —
|
|
13436
|
+
// exactly one message ever ships. The latch is idempotent and the normal
|
|
13437
|
+
// (no-throw) path is unaffected: the correction below still ships B once.
|
|
13438
|
+
if (ownerTurn != null) ownerTurn.answerDelivered = true
|
|
13328
13439
|
} else {
|
|
13329
13440
|
// 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race latch.
|
|
13330
13441
|
// Supersede found no record. Either there was no flush (normal reply), or
|
|
@@ -13858,6 +13969,41 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
13858
13969
|
}
|
|
13859
13970
|
}
|
|
13860
13971
|
|
|
13972
|
+
// Reply-flicker fix — apply the deferred flushed-turn correction now that
|
|
13973
|
+
// chunk count / files / preview are all resolved. `edit-in-place` reuses the
|
|
13974
|
+
// single-message edit lane below (`previewMessageId`): it edits the flushed
|
|
13975
|
+
// message A into the canonical reply B with the SAME rich rendering a fresh
|
|
13976
|
+
// reply uses, and `sendReplyChunks` falls back to delete+resend on any edit
|
|
13977
|
+
// 400 (message too old / uneditable / gone) — so exactly one message with the
|
|
13978
|
+
// canonical content always survives. We also forgo the quote (an edit can't
|
|
13979
|
+
// carry a reply_parameters quote) by clearing `reply_to`, so the quote-delete
|
|
13980
|
+
// guard just below does NOT delete our edit target. `delete-resend` keeps the
|
|
13981
|
+
// legacy behaviour (delete the flushed message(s), then send fresh below).
|
|
13982
|
+
if (supersedeFlushIds.length > 0) {
|
|
13983
|
+
const correction = decideSupersedeCorrection({
|
|
13984
|
+
flushMessageIds: supersedeFlushIds,
|
|
13985
|
+
chunkCount: chunks.length,
|
|
13986
|
+
hasFiles: files.length > 0,
|
|
13987
|
+
suppressText,
|
|
13988
|
+
hasOpenPreview: previewMessageId != null,
|
|
13989
|
+
})
|
|
13990
|
+
if (correction.mode === 'edit-in-place') {
|
|
13991
|
+
previewMessageId = correction.editMessageId
|
|
13992
|
+
reply_to = undefined
|
|
13993
|
+
process.stderr.write(
|
|
13994
|
+
`telegram gateway: reply: superseding flushed message via edit-in-place ` +
|
|
13995
|
+
`chatId=${chat_id} id=${correction.editMessageId}\n`,
|
|
13996
|
+
)
|
|
13997
|
+
} else {
|
|
13998
|
+
for (const id of correction.deleteMessageIds) {
|
|
13999
|
+
await swallowingApiCall(
|
|
14000
|
+
() => lockedBot.api.deleteMessage(chat_id, id),
|
|
14001
|
+
{ chat_id, verb: 'reply.supersedeFlushed' },
|
|
14002
|
+
)
|
|
14003
|
+
}
|
|
14004
|
+
}
|
|
14005
|
+
}
|
|
14006
|
+
|
|
13861
14007
|
if (previewMessageId != null && reply_to != null && replyMode !== 'off') {
|
|
13862
14008
|
await deleteStalePreview(previewMessageId)
|
|
13863
14009
|
previewMessageId = null
|
|
@@ -13874,7 +14020,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
13874
14020
|
let silentAnchorEditDone = false
|
|
13875
14021
|
{
|
|
13876
14022
|
const turn = currentTurn
|
|
13877
|
-
|
|
14023
|
+
// Skip the silent-anchor merge when a flushed-turn supersede is active: an
|
|
14024
|
+
// edit-in-place correction has re-pointed `previewMessageId` at the flushed
|
|
14025
|
+
// message (the chunk loop below edits it), and merging into a prior silent
|
|
14026
|
+
// anchor instead would orphan the flushed message A (leaving BOTH A and the
|
|
14027
|
+
// anchor visible — the exact duplicate this supersede exists to prevent).
|
|
14028
|
+
if (turn != null && chunks.length === 1 && supersedeFlushIds.length === 0) {
|
|
13878
14029
|
const decision = decideSilentReplyAnchor({
|
|
13879
14030
|
effectivelySilent: disableNotification,
|
|
13880
14031
|
anchorMessageId: turn.silentAnchorMessageId,
|
|
@@ -16996,6 +17147,90 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
16996
17147
|
})
|
|
16997
17148
|
}
|
|
16998
17149
|
|
|
17150
|
+
// ─── Sub-agent handback dead-air pre-turn signal ─────────────────────────────
|
|
17151
|
+
// Closes the gap between a background sub-agent's handback being RELEASED for
|
|
17152
|
+
// delivery (the buffer drain) and the parent turn that consumes it minting +
|
|
17153
|
+
// rendering its first tool/narration. On release we emit a `typing…` loop + a
|
|
17154
|
+
// "reading the worker's results…" card; the imminent turn ADOPTS that exact
|
|
17155
|
+
// card (by inbound identity) so it's one continuous card lifecycle finalized by
|
|
17156
|
+
// the turn's normal `clearActivitySummary`, never a second card. See
|
|
17157
|
+
// handback-preturn-signal.ts for the design + the red-team holes each lever
|
|
17158
|
+
// closes. Kill switch: SWITCHROOM_HANDBACK_PRETURN=0.
|
|
17159
|
+
const HANDBACK_PRETURN_ENABLED = !STATIC && process.env.SWITCHROOM_HANDBACK_PRETURN !== '0'
|
|
17160
|
+
const HANDBACK_PRETURN_HTML = '🤝 Reading the worker’s results…'
|
|
17161
|
+
const HANDBACK_PRETURN_ORPHAN_HTML =
|
|
17162
|
+
'🤝 A background worker finished, but the handback never started — it may need a nudge.'
|
|
17163
|
+
|
|
17164
|
+
async function openHandbackPreTurnCard(
|
|
17165
|
+
chatId: string,
|
|
17166
|
+
threadId: number | null,
|
|
17167
|
+
): Promise<number | null> {
|
|
17168
|
+
if (STATIC) return null
|
|
17169
|
+
try {
|
|
17170
|
+
const sent = await robustApiCall(
|
|
17171
|
+
// allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
|
|
17172
|
+
() =>
|
|
17173
|
+
bot.api.sendRichMessage(chatId, richMessage(HANDBACK_PRETURN_HTML), {
|
|
17174
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
17175
|
+
disable_notification: true,
|
|
17176
|
+
}),
|
|
17177
|
+
{
|
|
17178
|
+
chat_id: chatId,
|
|
17179
|
+
...(threadId != null ? { threadId } : {}),
|
|
17180
|
+
verb: 'handback-preturn.send',
|
|
17181
|
+
},
|
|
17182
|
+
)
|
|
17183
|
+
return sent?.message_id ?? null
|
|
17184
|
+
} catch (err) {
|
|
17185
|
+
process.stderr.write(
|
|
17186
|
+
`telegram gateway: handback pre-turn card send failed: ${(err as Error).message}\n`,
|
|
17187
|
+
)
|
|
17188
|
+
return null
|
|
17189
|
+
}
|
|
17190
|
+
}
|
|
17191
|
+
|
|
17192
|
+
function finalizeHandbackPreTurnCard(record: PreTurnCardRecord): Promise<void> {
|
|
17193
|
+
return robustApiCall(
|
|
17194
|
+
() =>
|
|
17195
|
+
bot.api.editMessageText(
|
|
17196
|
+
record.chatId,
|
|
17197
|
+
record.activityMessageId,
|
|
17198
|
+
richMessage(HANDBACK_PRETURN_ORPHAN_HTML),
|
|
17199
|
+
{},
|
|
17200
|
+
),
|
|
17201
|
+
{
|
|
17202
|
+
chat_id: record.chatId,
|
|
17203
|
+
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
17204
|
+
verb: 'handback-preturn.orphan-finalize',
|
|
17205
|
+
},
|
|
17206
|
+
)
|
|
17207
|
+
.then(() => undefined)
|
|
17208
|
+
.catch(() => undefined)
|
|
17209
|
+
}
|
|
17210
|
+
|
|
17211
|
+
const handbackPreturnSignal = createHandbackPreturnSignal({
|
|
17212
|
+
chatKey: (chatId, threadId) => chatKey(chatId, threadId) as string,
|
|
17213
|
+
deriveTurnId: (chatId, threadId, messageId) => deriveTurnId(chatId, threadId, messageId),
|
|
17214
|
+
startTypingLoop: (chatId, threadId) => startTurnTypingLoop(chatId, threadId),
|
|
17215
|
+
stopTypingLoop: (chatId, threadId) => stopTurnTypingLoop(chatId, threadId),
|
|
17216
|
+
openCard: openHandbackPreTurnCard,
|
|
17217
|
+
finalizeCard: finalizeHandbackPreTurnCard,
|
|
17218
|
+
writeCardRecord: (record) => {
|
|
17219
|
+
if (!activityCardPersistEnabled) return
|
|
17220
|
+
writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, record)
|
|
17221
|
+
},
|
|
17222
|
+
clearCardRecord: (turnKey, activityMessageId) => {
|
|
17223
|
+
if (!activityCardPersistEnabled) return
|
|
17224
|
+
clearActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, turnKey, activityMessageId)
|
|
17225
|
+
},
|
|
17226
|
+
// Lever 5: never paint a pre-turn card beneath a turn that already delivered
|
|
17227
|
+
// its answer / ended by the time the debounce fires.
|
|
17228
|
+
isTurnSettled: (key) => {
|
|
17229
|
+
const live = currentTurnMap.get(key)
|
|
17230
|
+
return live != null && (live.finalAnswerDelivered || live.endedAt != null)
|
|
17231
|
+
},
|
|
17232
|
+
})
|
|
17233
|
+
|
|
16999
17234
|
/**
|
|
17000
17235
|
* #2849 hindsight Phase 4 — sparse chat-legible memory.
|
|
17001
17236
|
*
|
|
@@ -17349,6 +17584,27 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17349
17584
|
// Wire the per-turn narrative gate now that `next` exists (its SHOW/RETRACT
|
|
17350
17585
|
// effects close over the turn). Born with this turn, torn down at turn end.
|
|
17351
17586
|
next.narrativeGate = makeNarrativeGate(next)
|
|
17587
|
+
// Dead-air pre-turn signal — ADOPT by inbound identity (design lever 2).
|
|
17588
|
+
// If a subagent-handback pre-turn signal was emitted for THIS exact turn
|
|
17589
|
+
// (matched on `turnId`, not the bare topic key, so a racing user inbound
|
|
17590
|
+
// can't mis-adopt), consume it. A card-bearing adoption seeds
|
|
17591
|
+
// `activityMessageId` + `activityEverOpened` so `renderActivityFeed`
|
|
17592
|
+
// EDITS the existing card instead of opening a second one, and so the
|
|
17593
|
+
// turn's own end-of-turn `clearActivitySummary` finalizes it (lever 3).
|
|
17594
|
+
// The handback turn also gets the turn-long typing loop it never had —
|
|
17595
|
+
// whether or not a card was painted (the debounce may not have fired) —
|
|
17596
|
+
// stopped by the canonical turn-end (`purgeReactionTracking →
|
|
17597
|
+
// stopTurnTypingLoop`).
|
|
17598
|
+
if (HANDBACK_PRETURN_ENABLED) {
|
|
17599
|
+
const handbackAdoption = handbackPreturnSignal.tryAdopt(turnId)
|
|
17600
|
+
if (handbackAdoption != null) {
|
|
17601
|
+
if (handbackAdoption.activityMessageId != null) {
|
|
17602
|
+
next.activityMessageId = handbackAdoption.activityMessageId
|
|
17603
|
+
next.activityEverOpened = true
|
|
17604
|
+
}
|
|
17605
|
+
startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
|
|
17606
|
+
}
|
|
17607
|
+
}
|
|
17352
17608
|
// PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
|
|
17353
17609
|
// the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
|
|
17354
17610
|
// per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
|
|
@@ -23515,8 +23771,13 @@ bot.command('model', async ctx => {
|
|
|
23515
23771
|
// command leaves a greppable log line + a history row before any branch. This
|
|
23516
23772
|
// is the fix for the finn 2026-07-12 zero-trace swallow (no log, no reply, no
|
|
23517
23773
|
// ack, no deferred apply). `busyNow` folds BOTH busy signals (turn atom AND
|
|
23518
|
-
// the authoritative delivery-machine/approval gate)
|
|
23519
|
-
|
|
23774
|
+
// the authoritative delivery-machine/approval gate) — but a STALE atom or a
|
|
23775
|
+
// wedged approval older than the hard TTL is discounted as idle (#3262), so a
|
|
23776
|
+
// phantom "active turn" on an idle session no longer blocks the switch. Resolve
|
|
23777
|
+
// once (it may clear a dangling atom) and reuse for both the receipt log and
|
|
23778
|
+
// the routing disposition.
|
|
23779
|
+
const modelBusy = resolveModelEffortBusy()
|
|
23780
|
+
const busyNow = modelBusy.currentTurnActive || modelBusy.turnInFlight
|
|
23520
23781
|
process.stderr.write(modelCommandReceiptLine(getMyAgentName(), parsed, busyNow) + '\n')
|
|
23521
23782
|
if (HISTORY_ENABLED && ctx.message?.message_id != null) {
|
|
23522
23783
|
try {
|
|
@@ -23538,8 +23799,8 @@ bot.command('model', async ctx => {
|
|
|
23538
23799
|
// session busy by EITHER measure ack+queues instead of silently injecting
|
|
23539
23800
|
// into a busy pane. Every branch below produces a visible action.
|
|
23540
23801
|
const disposition = planModelCommand(parsed, {
|
|
23541
|
-
currentTurnActive:
|
|
23542
|
-
turnInFlight:
|
|
23802
|
+
currentTurnActive: modelBusy.currentTurnActive,
|
|
23803
|
+
turnInFlight: modelBusy.turnInFlight,
|
|
23543
23804
|
menuEnabled: process.env.SWITCHROOM_MODEL_MENU !== '0',
|
|
23544
23805
|
})
|
|
23545
23806
|
if (disposition.kind === 'menu') {
|
|
@@ -23650,9 +23911,12 @@ bot.command('effort', async ctx => {
|
|
|
23650
23911
|
}
|
|
23651
23912
|
// Mid-turn: ACK + QUEUE + apply-on-idle + confirm (#3017) — parity with
|
|
23652
23913
|
// /model. `applyEffort` mid-turn silently maybe-failed ("couldn't confirm it
|
|
23653
|
-
// applied") before this gate existed.
|
|
23654
|
-
//
|
|
23655
|
-
|
|
23914
|
+
// applied") before this gate existed. Use the SAME stale-aware busy resolver
|
|
23915
|
+
// as /model (#3262) so a dangling turn atom older than the hard TTL is
|
|
23916
|
+
// discounted as idle and the switch applies instead of queuing forever on an
|
|
23917
|
+
// idle session.
|
|
23918
|
+
const effortBusy = resolveModelEffortBusy()
|
|
23919
|
+
if ((parsed.kind === 'set' || parsed.kind === 'default') && effortBusy.currentTurnActive) {
|
|
23656
23920
|
const requestedLevel = parsed.kind === 'set' ? parsed.level : 'default'
|
|
23657
23921
|
const chatId = String(ctx.chat!.id)
|
|
23658
23922
|
const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
|