switchroom 0.18.26 → 0.18.28
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 +571 -43
- 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 +358 -53
- 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/gateway/worker-pin-reaper.ts +54 -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/tests/worker-feed-migration-eviction.test.ts +140 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +78 -0
- package/telegram-plugin/worker-activity-feed.ts +169 -6
|
@@ -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,
|
|
@@ -598,6 +604,7 @@ import {
|
|
|
598
604
|
} from './queued-card-store.js'
|
|
599
605
|
import {
|
|
600
606
|
decideWorkerPinReaps,
|
|
607
|
+
storeOnlyWorkerPinCandidates,
|
|
601
608
|
WORKER_PIN_TTL_MS_DEFAULT,
|
|
602
609
|
} from './worker-pin-reaper.js'
|
|
603
610
|
import { driveEscalation } from './escalation-drive.js'
|
|
@@ -802,7 +809,10 @@ import {
|
|
|
802
809
|
removeTurnActiveMarker,
|
|
803
810
|
sweepStaleTurnActiveMarker,
|
|
804
811
|
readTurnActiveMarkerAgeMs,
|
|
812
|
+
effectiveTurnAgeMs,
|
|
805
813
|
TURN_ACTIVE_MARKER_FILE,
|
|
814
|
+
TURN_ACTIVE_HARD_TTL_MS,
|
|
815
|
+
TURN_ACTIVE_IDLE_SWEEP_MS,
|
|
806
816
|
} from './turn-active-marker.js'
|
|
807
817
|
import {
|
|
808
818
|
VERSION,
|
|
@@ -2859,14 +2869,91 @@ function turnInFlightForGate(): boolean {
|
|
|
2859
2869
|
// handlers and do not sit behind this gate — and the block is surfaced
|
|
2860
2870
|
// off-Telegram in blocked-approvals/<agent>.json.
|
|
2861
2871
|
const hasPendingApproval = pendingPermissions.size > 0
|
|
2862
|
-
|
|
2872
|
+
// The shared inbound gate stays STRICT: unconditionally busy whenever an
|
|
2873
|
+
// approval card is outstanding (#2841 — a new inbound in that window would
|
|
2874
|
+
// displace the approval context and orphan the pending MCP call). Only the
|
|
2875
|
+
// machine-in-turn leg is factored out (`turnInFlightMachineOnly`); the
|
|
2876
|
+
// `|| hasPendingApproval` composition here is what preserves the contract.
|
|
2877
|
+
return turnInFlightMachineOnly() || hasPendingApproval
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
/**
|
|
2881
|
+
* The machine-in-turn portion of the gate WITHOUT the `pendingPermissions`
|
|
2882
|
+
* hold — "claude is actively producing a turn right now", ignoring an
|
|
2883
|
+
* outstanding approval card. `turnInFlightForGate()` = this OR a pending
|
|
2884
|
+
* approval, so the shared inbound-hold contract (#2841) is unchanged.
|
|
2885
|
+
*
|
|
2886
|
+
* Factored out so the `/model` & `/effort` busy decision can read THIS
|
|
2887
|
+
* machine-only leg (plus a TTL-bounded approval check in
|
|
2888
|
+
* `resolveModelEffortBusy`) — a wedged / undeliverable approval that "never
|
|
2889
|
+
* expires by design" (#3084) must not hold their switch queued forever on an
|
|
2890
|
+
* idle session (#3262), even though it correctly holds the general inbound gate.
|
|
2891
|
+
*/
|
|
2892
|
+
function turnInFlightMachineOnly(): boolean {
|
|
2893
|
+
if (!isDeliveryCutoverEnabled()) return claudeBusyKeys.size > 0
|
|
2863
2894
|
// Machine is authoritative. Run the log-only drift canary (#2794): the
|
|
2864
2895
|
// imperative `claudeBusyKeys` shadow is still live in parallel, so a
|
|
2865
2896
|
// dangerous over-hold divergence (machine holds the gate while the
|
|
2866
2897
|
// imperative view is idle) is surfaced without changing behaviour. The
|
|
2867
2898
|
// benign orphan-dangle direction — the wedge the machine self-heals — is
|
|
2868
2899
|
// NOT flagged. `probeGateParity` returns the machine value unchanged.
|
|
2869
|
-
return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
|
|
2900
|
+
return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
/**
|
|
2904
|
+
* Age (ms) of the live turn for the phantom-turn cross-check (#3262): the
|
|
2905
|
+
* turn-active liveness marker's mtime age (touched on every foreground
|
|
2906
|
+
* tool_use AND on sub-agent JSONL growth, so a genuinely long turn keeps it
|
|
2907
|
+
* small), falling back to `now - turn.startedAt` when the marker is absent
|
|
2908
|
+
* (e.g. already swept). Null when no turn atom is set. A large age on a
|
|
2909
|
+
* non-null atom is the dangling-atom signal.
|
|
2910
|
+
*/
|
|
2911
|
+
function liveTurnAgeMs(now: number): number | null {
|
|
2912
|
+
if (currentTurn === null) return null
|
|
2913
|
+
return effectiveTurnAgeMs(readTurnActiveMarkerAgeMs(STATE_DIR, now), currentTurn.startedAt, now)
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
/** Age (ms) of the OLDEST outstanding pending approval, or null when none. */
|
|
2917
|
+
function oldestPendingApprovalAgeMs(now: number): number | null {
|
|
2918
|
+
let oldest: number | null = null
|
|
2919
|
+
for (const p of pendingPermissions.values()) {
|
|
2920
|
+
const age = now - p.startedAt
|
|
2921
|
+
if (oldest === null || age > oldest) oldest = age
|
|
2922
|
+
}
|
|
2923
|
+
return oldest
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
/**
|
|
2927
|
+
* Resolve the apply-vs-queue busy signals for a `/model` or `/effort` command,
|
|
2928
|
+
* sweeping a stale (dangling) turn atom and discounting a wedged (undeliverable)
|
|
2929
|
+
* approval hold — both bounded by the SAME `TURN_ACTIVE_HARD_TTL_MS` ceiling the
|
|
2930
|
+
* marker sweep uses (#3262). A genuinely in-flight turn (fresh marker) and a
|
|
2931
|
+
* recent pending approval still read busy, so the #3017/#3039 apply-or-queue
|
|
2932
|
+
* contract and #3177/#3180 are preserved. Clears the dangling atom as a side
|
|
2933
|
+
* effect so the phantom can't re-block the next command.
|
|
2934
|
+
*/
|
|
2935
|
+
function resolveModelEffortBusy(now = Date.now()): { currentTurnActive: boolean; turnInFlight: boolean } {
|
|
2936
|
+
const turnAgeMs = liveTurnAgeMs(now)
|
|
2937
|
+
const resolved = resolveStaleAwareBusy({
|
|
2938
|
+
currentTurnActive: currentTurn !== null,
|
|
2939
|
+
turnAgeMs,
|
|
2940
|
+
machineInTurn: turnInFlightMachineOnly(),
|
|
2941
|
+
oldestPendingApprovalAgeMs: oldestPendingApprovalAgeMs(now),
|
|
2942
|
+
hardTtlMs: TURN_ACTIVE_HARD_TTL_MS,
|
|
2943
|
+
})
|
|
2944
|
+
if (resolved.clearStaleTurn && currentTurn !== null) {
|
|
2945
|
+
const ageSec = Math.round((turnAgeMs ?? 0) / 1000)
|
|
2946
|
+
process.stderr.write(
|
|
2947
|
+
`telegram gateway: [phantomturn] cleared stale currentTurn atom age=${ageSec}s ` +
|
|
2948
|
+
`ttl=${Math.round(TURN_ACTIVE_HARD_TTL_MS / 1000)}s for /model|/effort busy-check agent=${getMyAgentName()}\n`,
|
|
2949
|
+
)
|
|
2950
|
+
// Single-tenant / sequential-CLI invariant: the singleton `currentTurn` IS
|
|
2951
|
+
// the only live turn, and a stale atom is a ghost (its `turn_end` never
|
|
2952
|
+
// fired) — clearing every entry is equivalent to clearing it, matching the
|
|
2953
|
+
// bridge-died "every entry is a ghost" semantics.
|
|
2954
|
+
clearAllCurrentTurns()
|
|
2955
|
+
}
|
|
2956
|
+
return { currentTurnActive: resolved.currentTurnActive, turnInFlight: resolved.turnInFlight }
|
|
2870
2957
|
}
|
|
2871
2958
|
|
|
2872
2959
|
/**
|
|
@@ -3674,25 +3761,10 @@ function findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTur
|
|
|
3674
3761
|
if (turn == null || turn.sessionChatId !== chatId) return null
|
|
3675
3762
|
return turn
|
|
3676
3763
|
}
|
|
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
|
-
}
|
|
3764
|
+
// Component 3 — the stable per-turn identity. Extracted to `derive-turn-id.ts`
|
|
3765
|
+
// (#3268) so the enqueue seam and the handback round-trip test share ONE
|
|
3766
|
+
// function. Re-exported into scope under the original name so every existing
|
|
3767
|
+
// callsite is unchanged.
|
|
3696
3768
|
|
|
3697
3769
|
/**
|
|
3698
3770
|
* Component 3 — resolve the turn that OWNS a reply by its `origin_turn_id`
|
|
@@ -7767,8 +7839,8 @@ const pendingStateReaper = setInterval(() => {
|
|
|
7767
7839
|
try {
|
|
7768
7840
|
sweepStaleTurnActiveMarker(STATE_DIR, {
|
|
7769
7841
|
turnInFlight: currentTurn?.registryKey != null,
|
|
7770
|
-
idleSweepMs:
|
|
7771
|
-
hardTtlMs:
|
|
7842
|
+
idleSweepMs: TURN_ACTIVE_IDLE_SWEEP_MS,
|
|
7843
|
+
hardTtlMs: TURN_ACTIVE_HARD_TTL_MS,
|
|
7772
7844
|
now,
|
|
7773
7845
|
onRemove: ({ ageMs, reason, payload }) => {
|
|
7774
7846
|
const agent = getMyAgentName()
|
|
@@ -8883,11 +8955,20 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
8883
8955
|
const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
|
|
8884
8956
|
path: ACTIVITY_CARD_STORE_PATH,
|
|
8885
8957
|
fs: activityCardStoreFs,
|
|
8958
|
+
// A synthetic pre-turn record's key is never a live topic key, so it is
|
|
8959
|
+
// correctly seen as ownerless here (the seam's own age-based self-reap is
|
|
8960
|
+
// the fast path; this is the crash / long-lived backstop).
|
|
8886
8961
|
isLive: (record) => topicKeys.has(record.turnKey),
|
|
8887
8962
|
ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
|
|
8888
8963
|
now,
|
|
8889
|
-
finalizeCard: (record) =>
|
|
8890
|
-
|
|
8964
|
+
finalizeCard: (record) => {
|
|
8965
|
+
// Reap hook (design lever 4): a never-adopted pre-turn card being
|
|
8966
|
+
// finalized here must also stop its forever-running typing loop and
|
|
8967
|
+
// drop the seam's in-memory entry.
|
|
8968
|
+
if (handbackPreturnSignal.isPreTurnRecord(record.turnKey)) {
|
|
8969
|
+
handbackPreturnSignal.handleReaped(record.turnKey)
|
|
8970
|
+
}
|
|
8971
|
+
return robustApiCall(
|
|
8891
8972
|
() =>
|
|
8892
8973
|
lockedBot.api.editMessageText(
|
|
8893
8974
|
record.chatId,
|
|
@@ -8900,7 +8981,8 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
8900
8981
|
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
8901
8982
|
verb: 'activity-card.mid-session-reap-finalize',
|
|
8902
8983
|
},
|
|
8903
|
-
)
|
|
8984
|
+
)
|
|
8985
|
+
},
|
|
8904
8986
|
// #3001: route through reconcileStatusPin when the pin is a live
|
|
8905
8987
|
// in-memory claim, so the claim AND the durable status-pins.json row
|
|
8906
8988
|
// clear together (the raw-API unpin left both behind — the boot sweep
|
|
@@ -8942,16 +9024,32 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
8942
9024
|
// the durable store row clear together.
|
|
8943
9025
|
if (WORKER_PIN_REAPER_ENABLED && PIN_STATUS_WHILE_WORKING) {
|
|
8944
9026
|
try {
|
|
8945
|
-
const
|
|
8946
|
-
.filter((k) => k.startsWith('wk:'))
|
|
8947
|
-
|
|
8948
|
-
|
|
8949
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
8952
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
9027
|
+
const inMemoryKeys = new Set(
|
|
9028
|
+
[...statusPinState.keys()].filter((k) => k.startsWith('wk:')),
|
|
9029
|
+
)
|
|
9030
|
+
const inMemoryCandidates = [...inMemoryKeys].map((k) => ({
|
|
9031
|
+
pinKey: k,
|
|
9032
|
+
chatId: statusPinChatIds.get(k) ?? '',
|
|
9033
|
+
// A missing timestamp (should not happen — set on every claim)
|
|
9034
|
+
// degrades to "claimed just now": terminality can still reap it,
|
|
9035
|
+
// the TTL gate never can. Conservative, never a spurious unpin.
|
|
9036
|
+
pinnedAt: statusPinPinnedAt.get(k) ?? now,
|
|
9037
|
+
}))
|
|
9038
|
+
// #3001 durable group net: fold in `wk:` rows that live in the DURABLE
|
|
9039
|
+
// store but have NO in-memory claim — the divergence window where the
|
|
9040
|
+
// claim was lost but the Telegram pin + store row survive. These would
|
|
9041
|
+
// otherwise linger until the next boot cleanup; the reconciling sweep
|
|
9042
|
+
// recovers them mid-session too, group-safely (per-message unpin of a
|
|
9043
|
+
// bot-tracked row, NEVER an unpin-all, NEVER an untracked/human pin).
|
|
9044
|
+
const storeOnlyCandidates = statusPinPersistEnabled
|
|
9045
|
+
? storeOnlyWorkerPinCandidates({
|
|
9046
|
+
rows: loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs),
|
|
9047
|
+
inMemoryPinKeys: inMemoryKeys,
|
|
9048
|
+
now,
|
|
9049
|
+
})
|
|
9050
|
+
: []
|
|
9051
|
+
const storeOnlyKeys = new Set(storeOnlyCandidates.map((c) => c.pinKey))
|
|
9052
|
+
const candidates = [...inMemoryCandidates, ...storeOnlyCandidates]
|
|
8955
9053
|
const reaps = decideWorkerPinReaps({
|
|
8956
9054
|
pins: candidates,
|
|
8957
9055
|
statusOf: (agentId) => {
|
|
@@ -8983,11 +9081,35 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
8983
9081
|
now,
|
|
8984
9082
|
})
|
|
8985
9083
|
for (const reap of reaps) {
|
|
9084
|
+
const storeOnly = storeOnlyKeys.has(reap.pinKey)
|
|
8986
9085
|
process.stderr.write(
|
|
8987
9086
|
`telegram gateway: worker-pin reaper unpinning ${reap.pinKey} ` +
|
|
8988
|
-
`(chat=${reap.chatId} reason=${reap.reason}
|
|
9087
|
+
`(chat=${reap.chatId} reason=${reap.reason}` +
|
|
9088
|
+
`${storeOnly ? ' source=store-orphan' : ''})\n`,
|
|
8989
9089
|
)
|
|
8990
|
-
|
|
9090
|
+
if (storeOnly && reap.messageId != null) {
|
|
9091
|
+
// No in-memory claim to reconcile: unpin the exact tracked message
|
|
9092
|
+
// (per-message — group-safe) and drop the store row directly. Both
|
|
9093
|
+
// are best-effort/idempotent; a failed unpin still drops the row so
|
|
9094
|
+
// the next boot's cleanup is the final backstop.
|
|
9095
|
+
try {
|
|
9096
|
+
await statusPinApi().unpinChatMessage(reap.chatId, reap.messageId)
|
|
9097
|
+
} catch (err) {
|
|
9098
|
+
process.stderr.write(
|
|
9099
|
+
`telegram gateway: worker-pin reaper store-orphan unpin failed ` +
|
|
9100
|
+
`(${reap.pinKey} chat=${reap.chatId} msg=${reap.messageId}): ` +
|
|
9101
|
+
`${(err as Error).message}\n`,
|
|
9102
|
+
)
|
|
9103
|
+
}
|
|
9104
|
+
await mutateStatusPinRow(
|
|
9105
|
+
STATUS_PIN_STORE_PATH,
|
|
9106
|
+
statusPinStoreFs,
|
|
9107
|
+
reap.pinKey,
|
|
9108
|
+
null,
|
|
9109
|
+
)
|
|
9110
|
+
} else {
|
|
9111
|
+
await reconcileStatusPin(reap.pinKey, reap.chatId, { pinned: false })
|
|
9112
|
+
}
|
|
8991
9113
|
}
|
|
8992
9114
|
} catch (err) {
|
|
8993
9115
|
process.stderr.write(
|
|
@@ -10002,6 +10124,15 @@ _deliveryMachineTick.unref?.()
|
|
|
10002
10124
|
// synthetic-source/empty, which never produce an `enqueue` and would otherwise
|
|
10003
10125
|
// re-deliver forever.
|
|
10004
10126
|
function trackRedeliveredInbound(merged: InboundMessage): void {
|
|
10127
|
+
// Dead-air pre-turn signal: this is the shared CONFIRMED-release chokepoint
|
|
10128
|
+
// (buffer drain, idle-drain tick, bridge re-register). A released
|
|
10129
|
+
// subagent-handback gets a `typing…` + pre-turn card the imminent turn
|
|
10130
|
+
// adopts; a no-op for every non-handback inbound (guarded inside the seam).
|
|
10131
|
+
// Emitted BEFORE the delivery-confirm early-return so it fires regardless of
|
|
10132
|
+
// that feature flag. Design lever 1: emit at release, do NOT gate on
|
|
10133
|
+
// turn-in-flight (the common case is a worker finishing while the parent is
|
|
10134
|
+
// mid-turn on another topic).
|
|
10135
|
+
if (HANDBACK_PRETURN_ENABLED) handbackPreturnSignal.noteHandbackRelease(merged)
|
|
10005
10136
|
if (!DELIVERY_CONFIRM_ENABLED) return
|
|
10006
10137
|
// The boot-resume synthetic ('resume_interrupted') is the ONE synthetic we DO
|
|
10007
10138
|
// enrol: a restart can drop it into a not-ready session exactly like a user
|
|
@@ -13292,6 +13423,15 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
13292
13423
|
// exact-text `outboundDedup` above structurally cannot catch. A reply for a
|
|
13293
13424
|
// DIFFERENT newer live turn never supersedes (decideSupersede → different-turn),
|
|
13294
13425
|
// so a fresh turn's answer is never clobbered.
|
|
13426
|
+
//
|
|
13427
|
+
// Reply-flicker fix: rather than delete the flushed message(s) HERE (which
|
|
13428
|
+
// makes the user see delete+replace when the canonical reply sends fresh
|
|
13429
|
+
// below), we DEFER the correction to the send site. Once chunk count / files /
|
|
13430
|
+
// preview are known, `decideSupersedeCorrection` picks edit-in-place (edit the
|
|
13431
|
+
// single flushed message into the canonical reply — no flicker, no re-ping)
|
|
13432
|
+
// when the reply fits one plain-text message, and falls back to the legacy
|
|
13433
|
+
// delete+resend otherwise. `supersedeFlushIds` carries the ids forward.
|
|
13434
|
+
let supersedeFlushIds: number[] = []
|
|
13295
13435
|
{
|
|
13296
13436
|
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
13297
13437
|
// 2026-07 double-reply-on-DM fix (Part 1) — resolve the turn this reply
|
|
@@ -13319,12 +13459,24 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
13319
13459
|
`telegram gateway: reply: superseding flushed turn message(s) ` +
|
|
13320
13460
|
`chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}\n`,
|
|
13321
13461
|
)
|
|
13322
|
-
|
|
13323
|
-
|
|
13324
|
-
|
|
13325
|
-
|
|
13326
|
-
|
|
13327
|
-
|
|
13462
|
+
// Deferred: the correction (edit-in-place vs delete+resend) is decided at
|
|
13463
|
+
// the send site once chunk count / files / preview are known.
|
|
13464
|
+
supersedeFlushIds = decision.deleteMessageIds
|
|
13465
|
+
// Set the answer-delivered latch NOW, at record consumption — BEFORE the
|
|
13466
|
+
// arg-validation throws between here and the correction site (file
|
|
13467
|
+
// too-large ~L13699, inline_keyboard invalid ~L13782). Without this, a
|
|
13468
|
+
// late reply that supersedes a flush AND carries an oversized file /
|
|
13469
|
+
// invalid keyboard would throw before the correction runs (message A
|
|
13470
|
+
// neither deleted nor edited), the model would retry `reply`, and — the
|
|
13471
|
+
// supersede record already consumed by `take()` above — the retry would
|
|
13472
|
+
// fall into the else/no-record branch below with no latch set, so
|
|
13473
|
+
// suppression wouldn't fire and a fresh B would ship alongside the stale
|
|
13474
|
+
// narration A (both visible). Latching here mirrors the else-branch's own
|
|
13475
|
+
// `answerDelivered = true` and closes that resurrection window: the retry
|
|
13476
|
+
// resolves the same ended owner turn, sees the latch, and is suppressed —
|
|
13477
|
+
// exactly one message ever ships. The latch is idempotent and the normal
|
|
13478
|
+
// (no-throw) path is unaffected: the correction below still ships B once.
|
|
13479
|
+
if (ownerTurn != null) ownerTurn.answerDelivered = true
|
|
13328
13480
|
} else {
|
|
13329
13481
|
// 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race latch.
|
|
13330
13482
|
// Supersede found no record. Either there was no flush (normal reply), or
|
|
@@ -13858,6 +14010,41 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
13858
14010
|
}
|
|
13859
14011
|
}
|
|
13860
14012
|
|
|
14013
|
+
// Reply-flicker fix — apply the deferred flushed-turn correction now that
|
|
14014
|
+
// chunk count / files / preview are all resolved. `edit-in-place` reuses the
|
|
14015
|
+
// single-message edit lane below (`previewMessageId`): it edits the flushed
|
|
14016
|
+
// message A into the canonical reply B with the SAME rich rendering a fresh
|
|
14017
|
+
// reply uses, and `sendReplyChunks` falls back to delete+resend on any edit
|
|
14018
|
+
// 400 (message too old / uneditable / gone) — so exactly one message with the
|
|
14019
|
+
// canonical content always survives. We also forgo the quote (an edit can't
|
|
14020
|
+
// carry a reply_parameters quote) by clearing `reply_to`, so the quote-delete
|
|
14021
|
+
// guard just below does NOT delete our edit target. `delete-resend` keeps the
|
|
14022
|
+
// legacy behaviour (delete the flushed message(s), then send fresh below).
|
|
14023
|
+
if (supersedeFlushIds.length > 0) {
|
|
14024
|
+
const correction = decideSupersedeCorrection({
|
|
14025
|
+
flushMessageIds: supersedeFlushIds,
|
|
14026
|
+
chunkCount: chunks.length,
|
|
14027
|
+
hasFiles: files.length > 0,
|
|
14028
|
+
suppressText,
|
|
14029
|
+
hasOpenPreview: previewMessageId != null,
|
|
14030
|
+
})
|
|
14031
|
+
if (correction.mode === 'edit-in-place') {
|
|
14032
|
+
previewMessageId = correction.editMessageId
|
|
14033
|
+
reply_to = undefined
|
|
14034
|
+
process.stderr.write(
|
|
14035
|
+
`telegram gateway: reply: superseding flushed message via edit-in-place ` +
|
|
14036
|
+
`chatId=${chat_id} id=${correction.editMessageId}\n`,
|
|
14037
|
+
)
|
|
14038
|
+
} else {
|
|
14039
|
+
for (const id of correction.deleteMessageIds) {
|
|
14040
|
+
await swallowingApiCall(
|
|
14041
|
+
() => lockedBot.api.deleteMessage(chat_id, id),
|
|
14042
|
+
{ chat_id, verb: 'reply.supersedeFlushed' },
|
|
14043
|
+
)
|
|
14044
|
+
}
|
|
14045
|
+
}
|
|
14046
|
+
}
|
|
14047
|
+
|
|
13861
14048
|
if (previewMessageId != null && reply_to != null && replyMode !== 'off') {
|
|
13862
14049
|
await deleteStalePreview(previewMessageId)
|
|
13863
14050
|
previewMessageId = null
|
|
@@ -13874,7 +14061,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
13874
14061
|
let silentAnchorEditDone = false
|
|
13875
14062
|
{
|
|
13876
14063
|
const turn = currentTurn
|
|
13877
|
-
|
|
14064
|
+
// Skip the silent-anchor merge when a flushed-turn supersede is active: an
|
|
14065
|
+
// edit-in-place correction has re-pointed `previewMessageId` at the flushed
|
|
14066
|
+
// message (the chunk loop below edits it), and merging into a prior silent
|
|
14067
|
+
// anchor instead would orphan the flushed message A (leaving BOTH A and the
|
|
14068
|
+
// anchor visible — the exact duplicate this supersede exists to prevent).
|
|
14069
|
+
if (turn != null && chunks.length === 1 && supersedeFlushIds.length === 0) {
|
|
13878
14070
|
const decision = decideSilentReplyAnchor({
|
|
13879
14071
|
effectivelySilent: disableNotification,
|
|
13880
14072
|
anchorMessageId: turn.silentAnchorMessageId,
|
|
@@ -16996,6 +17188,90 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
16996
17188
|
})
|
|
16997
17189
|
}
|
|
16998
17190
|
|
|
17191
|
+
// ─── Sub-agent handback dead-air pre-turn signal ─────────────────────────────
|
|
17192
|
+
// Closes the gap between a background sub-agent's handback being RELEASED for
|
|
17193
|
+
// delivery (the buffer drain) and the parent turn that consumes it minting +
|
|
17194
|
+
// rendering its first tool/narration. On release we emit a `typing…` loop + a
|
|
17195
|
+
// "reading the worker's results…" card; the imminent turn ADOPTS that exact
|
|
17196
|
+
// card (by inbound identity) so it's one continuous card lifecycle finalized by
|
|
17197
|
+
// the turn's normal `clearActivitySummary`, never a second card. See
|
|
17198
|
+
// handback-preturn-signal.ts for the design + the red-team holes each lever
|
|
17199
|
+
// closes. Kill switch: SWITCHROOM_HANDBACK_PRETURN=0.
|
|
17200
|
+
const HANDBACK_PRETURN_ENABLED = !STATIC && process.env.SWITCHROOM_HANDBACK_PRETURN !== '0'
|
|
17201
|
+
const HANDBACK_PRETURN_HTML = '🤝 Reading the worker’s results…'
|
|
17202
|
+
const HANDBACK_PRETURN_ORPHAN_HTML =
|
|
17203
|
+
'🤝 A background worker finished, but the handback never started — it may need a nudge.'
|
|
17204
|
+
|
|
17205
|
+
async function openHandbackPreTurnCard(
|
|
17206
|
+
chatId: string,
|
|
17207
|
+
threadId: number | null,
|
|
17208
|
+
): Promise<number | null> {
|
|
17209
|
+
if (STATIC) return null
|
|
17210
|
+
try {
|
|
17211
|
+
const sent = await robustApiCall(
|
|
17212
|
+
// allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
|
|
17213
|
+
() =>
|
|
17214
|
+
bot.api.sendRichMessage(chatId, richMessage(HANDBACK_PRETURN_HTML), {
|
|
17215
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
17216
|
+
disable_notification: true,
|
|
17217
|
+
}),
|
|
17218
|
+
{
|
|
17219
|
+
chat_id: chatId,
|
|
17220
|
+
...(threadId != null ? { threadId } : {}),
|
|
17221
|
+
verb: 'handback-preturn.send',
|
|
17222
|
+
},
|
|
17223
|
+
)
|
|
17224
|
+
return sent?.message_id ?? null
|
|
17225
|
+
} catch (err) {
|
|
17226
|
+
process.stderr.write(
|
|
17227
|
+
`telegram gateway: handback pre-turn card send failed: ${(err as Error).message}\n`,
|
|
17228
|
+
)
|
|
17229
|
+
return null
|
|
17230
|
+
}
|
|
17231
|
+
}
|
|
17232
|
+
|
|
17233
|
+
function finalizeHandbackPreTurnCard(record: PreTurnCardRecord): Promise<void> {
|
|
17234
|
+
return robustApiCall(
|
|
17235
|
+
() =>
|
|
17236
|
+
bot.api.editMessageText(
|
|
17237
|
+
record.chatId,
|
|
17238
|
+
record.activityMessageId,
|
|
17239
|
+
richMessage(HANDBACK_PRETURN_ORPHAN_HTML),
|
|
17240
|
+
{},
|
|
17241
|
+
),
|
|
17242
|
+
{
|
|
17243
|
+
chat_id: record.chatId,
|
|
17244
|
+
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
17245
|
+
verb: 'handback-preturn.orphan-finalize',
|
|
17246
|
+
},
|
|
17247
|
+
)
|
|
17248
|
+
.then(() => undefined)
|
|
17249
|
+
.catch(() => undefined)
|
|
17250
|
+
}
|
|
17251
|
+
|
|
17252
|
+
const handbackPreturnSignal = createHandbackPreturnSignal({
|
|
17253
|
+
chatKey: (chatId, threadId) => chatKey(chatId, threadId) as string,
|
|
17254
|
+
deriveTurnId: (chatId, threadId, messageId) => deriveTurnId(chatId, threadId, messageId),
|
|
17255
|
+
startTypingLoop: (chatId, threadId) => startTurnTypingLoop(chatId, threadId),
|
|
17256
|
+
stopTypingLoop: (chatId, threadId) => stopTurnTypingLoop(chatId, threadId),
|
|
17257
|
+
openCard: openHandbackPreTurnCard,
|
|
17258
|
+
finalizeCard: finalizeHandbackPreTurnCard,
|
|
17259
|
+
writeCardRecord: (record) => {
|
|
17260
|
+
if (!activityCardPersistEnabled) return
|
|
17261
|
+
writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, record)
|
|
17262
|
+
},
|
|
17263
|
+
clearCardRecord: (turnKey, activityMessageId) => {
|
|
17264
|
+
if (!activityCardPersistEnabled) return
|
|
17265
|
+
clearActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, turnKey, activityMessageId)
|
|
17266
|
+
},
|
|
17267
|
+
// Lever 5: never paint a pre-turn card beneath a turn that already delivered
|
|
17268
|
+
// its answer / ended by the time the debounce fires.
|
|
17269
|
+
isTurnSettled: (key) => {
|
|
17270
|
+
const live = currentTurnMap.get(key)
|
|
17271
|
+
return live != null && (live.finalAnswerDelivered || live.endedAt != null)
|
|
17272
|
+
},
|
|
17273
|
+
})
|
|
17274
|
+
|
|
16999
17275
|
/**
|
|
17000
17276
|
* #2849 hindsight Phase 4 — sparse chat-legible memory.
|
|
17001
17277
|
*
|
|
@@ -17349,6 +17625,27 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17349
17625
|
// Wire the per-turn narrative gate now that `next` exists (its SHOW/RETRACT
|
|
17350
17626
|
// effects close over the turn). Born with this turn, torn down at turn end.
|
|
17351
17627
|
next.narrativeGate = makeNarrativeGate(next)
|
|
17628
|
+
// Dead-air pre-turn signal — ADOPT by inbound identity (design lever 2).
|
|
17629
|
+
// If a subagent-handback pre-turn signal was emitted for THIS exact turn
|
|
17630
|
+
// (matched on `turnId`, not the bare topic key, so a racing user inbound
|
|
17631
|
+
// can't mis-adopt), consume it. A card-bearing adoption seeds
|
|
17632
|
+
// `activityMessageId` + `activityEverOpened` so `renderActivityFeed`
|
|
17633
|
+
// EDITS the existing card instead of opening a second one, and so the
|
|
17634
|
+
// turn's own end-of-turn `clearActivitySummary` finalizes it (lever 3).
|
|
17635
|
+
// The handback turn also gets the turn-long typing loop it never had —
|
|
17636
|
+
// whether or not a card was painted (the debounce may not have fired) —
|
|
17637
|
+
// stopped by the canonical turn-end (`purgeReactionTracking →
|
|
17638
|
+
// stopTurnTypingLoop`).
|
|
17639
|
+
if (HANDBACK_PRETURN_ENABLED) {
|
|
17640
|
+
const handbackAdoption = handbackPreturnSignal.tryAdopt(turnId)
|
|
17641
|
+
if (handbackAdoption != null) {
|
|
17642
|
+
if (handbackAdoption.activityMessageId != null) {
|
|
17643
|
+
next.activityMessageId = handbackAdoption.activityMessageId
|
|
17644
|
+
next.activityEverOpened = true
|
|
17645
|
+
}
|
|
17646
|
+
startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
|
|
17647
|
+
}
|
|
17648
|
+
}
|
|
17352
17649
|
// PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
|
|
17353
17650
|
// the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
|
|
17354
17651
|
// per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
|
|
@@ -23515,8 +23812,13 @@ bot.command('model', async ctx => {
|
|
|
23515
23812
|
// command leaves a greppable log line + a history row before any branch. This
|
|
23516
23813
|
// is the fix for the finn 2026-07-12 zero-trace swallow (no log, no reply, no
|
|
23517
23814
|
// ack, no deferred apply). `busyNow` folds BOTH busy signals (turn atom AND
|
|
23518
|
-
// the authoritative delivery-machine/approval gate)
|
|
23519
|
-
|
|
23815
|
+
// the authoritative delivery-machine/approval gate) — but a STALE atom or a
|
|
23816
|
+
// wedged approval older than the hard TTL is discounted as idle (#3262), so a
|
|
23817
|
+
// phantom "active turn" on an idle session no longer blocks the switch. Resolve
|
|
23818
|
+
// once (it may clear a dangling atom) and reuse for both the receipt log and
|
|
23819
|
+
// the routing disposition.
|
|
23820
|
+
const modelBusy = resolveModelEffortBusy()
|
|
23821
|
+
const busyNow = modelBusy.currentTurnActive || modelBusy.turnInFlight
|
|
23520
23822
|
process.stderr.write(modelCommandReceiptLine(getMyAgentName(), parsed, busyNow) + '\n')
|
|
23521
23823
|
if (HISTORY_ENABLED && ctx.message?.message_id != null) {
|
|
23522
23824
|
try {
|
|
@@ -23538,8 +23840,8 @@ bot.command('model', async ctx => {
|
|
|
23538
23840
|
// session busy by EITHER measure ack+queues instead of silently injecting
|
|
23539
23841
|
// into a busy pane. Every branch below produces a visible action.
|
|
23540
23842
|
const disposition = planModelCommand(parsed, {
|
|
23541
|
-
currentTurnActive:
|
|
23542
|
-
turnInFlight:
|
|
23843
|
+
currentTurnActive: modelBusy.currentTurnActive,
|
|
23844
|
+
turnInFlight: modelBusy.turnInFlight,
|
|
23543
23845
|
menuEnabled: process.env.SWITCHROOM_MODEL_MENU !== '0',
|
|
23544
23846
|
})
|
|
23545
23847
|
if (disposition.kind === 'menu') {
|
|
@@ -23650,9 +23952,12 @@ bot.command('effort', async ctx => {
|
|
|
23650
23952
|
}
|
|
23651
23953
|
// Mid-turn: ACK + QUEUE + apply-on-idle + confirm (#3017) — parity with
|
|
23652
23954
|
// /model. `applyEffort` mid-turn silently maybe-failed ("couldn't confirm it
|
|
23653
|
-
// applied") before this gate existed.
|
|
23654
|
-
//
|
|
23655
|
-
|
|
23955
|
+
// applied") before this gate existed. Use the SAME stale-aware busy resolver
|
|
23956
|
+
// as /model (#3262) so a dangling turn atom older than the hard TTL is
|
|
23957
|
+
// discounted as idle and the switch applies instead of queuing forever on an
|
|
23958
|
+
// idle session.
|
|
23959
|
+
const effortBusy = resolveModelEffortBusy()
|
|
23960
|
+
if ((parsed.kind === 'set' || parsed.kind === 'default') && effortBusy.currentTurnActive) {
|
|
23656
23961
|
const requestedLevel = parsed.kind === 'set' ? parsed.level : 'default'
|
|
23657
23962
|
const chatId = String(ctx.chat!.id)
|
|
23658
23963
|
const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
|