feat(telegram): resume dashboard sessions from chat - #8380
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound parity design on the shared resume seam, but two hand-rolled rollback sagas over the session map land at once, and one capability withdrawal is undisclosed. Watch
Suggestions
[DESIGN-REVIEWED] 56d7ce4 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence gathered. I verified the sibling counts in the repository at HEAD: Teams routes inbound resumed sessions ( First-Principles-Verdict: CONCERNS Real parity feature built on the shared resume core, but a large undeclared privacy-gate cluster rides along, and its own root cause is left unfixed on Teams and half-fixed on Discord. What this change shipsIntent: let the Telegram operator continue an existing dashboard conversation from chat with one button press — an ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 56d7ce4 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI have enough to decide. Let me verify my reasoning on the read-raise reachability by confirming The analysis is complete:
That is a multi-fault conjunction gated on a transient disk fault at a precise window, not a condition that occurs on any deterministic input — (a) resolves to "might," which the bar forbids. The candidate's framing that "the comment directly contradicts the code" is also inaccurate: the comment claims only that the rollback leaves the flag alone on an unknown read, which is exactly what the code does. And restoring a value the read never returned would require new machinery (a non-writing flag read), i.e. untouched code. The candidate does not clear the 80 bar. Step 2 turned up no self-derivable, grounded 80+ defect in the surrounding hunks. No findings. [OPUS-REVIEWED] 56d7ce4 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
UX Review (Fable 5) — ⏭️ skippedRevision |
ea91de6 to
7e1c5fe
Compare
51cefe9 to
aa0940b
Compare
aa0940b to
a998b56
Compare
a998b56 to
e0a5c69
Compare
bb432b0 to
253facc
Compare
253facc to
598f03e
Compare
598f03e to
31322f2
Compare
31322f2 to
6534ca9
Compare
6534ca9 to
1978783
Compare
|
/ai-review override gpt 8490cbe: The residual value-equality rollback guard needs a claim-revision token in SessionMap that every channel and the dashboard share, which is a core concurrency primitive out of scope for this PR and tracked as follow-up work. |
Human judgment recorded@pepmach marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
A restricted dashboard session resumed from a channel could write content to durable history because the gate read a channel-local privacy tracker that a dashboard slot never populates. Skipping only the direct append was still insufficient: live projection marks that slot dirty, so a later flush persisted the same rows. Discord's resume path had the same two-writer gap. Use one dashboard-aware decision for uploads, resumed-turn projection, and direct history writes on both channels. An unreadable persisted mode denies history when a transcript EXISTS, which is where an incognito session can hide; only a truly absent record still records, so cold resume keeps working. Uploads keep denying every unknown. Cover the READ half too: temporary blocks memory and lesson reads, and that fact also lives on the dashboard slot, so a resumed temporary session was taking stored memories into the model prompt. Incognito still reads, which is the documented difference between the modes. Telegram refuses /temporary and /incognito while resumed rather than marking only channel state and falsely promising privacy for a persistent slot, and its /title updates the live slot, title epoch, durable metadata and dashboard broadcast together so a later slot save cannot restore the old name. Telegram's owner gate now covers the durable expectation store, not just the live map: a detached binding whose expectation survives resolved no key, yet the binder still built a notice naming the dashboard session, so a non-owner added by a later multi-user config could read host-wide history. Any non-empty decision becomes the generic refusal, and its settlement stays owed so the real owner is still told. A failed pick restores the expectation it displaced instead of retiring its own replacement. That snapshot read is guarded too: the choice is already consumed by then, so an unreadable store settles fail-closed instead of discarding the press. Both binding batches now run in a worker thread, because batched_save holds the map lock across its block and rewrites the whole map file on exit, which on the loop stalled every task. The conflict decision and both restore snapshots are re-derived inside that batch rather than carried in from the loop, and the rollback is conditional, so a rebind landing in the hand-off window is no longer erased. The dashboard link endpoint now claims the binding BEFORE announcing it. It used to announce first and claim last, leaving a window as long as the inline backfill takes — about a second per message on Telegram — in which an inbound reply resolved nothing and ran in the channel's native session, the one the notice had just said the user left. Every failure path after the claim releases it, and only while the binding is still that claim, so a concurrent rebind is left alone and a failed rebind restores the prior link and opt-out instead of unlinking the session. One-click takeover also works under dm_scope="unified". The native bucket is a unified: key there, which the namespace test read as a stranger, so the press refused and asked for a preparatory /unlink. The dispatcher supplies its own session key, which is honoured only while it occupies that conversation.
bolichen97
left a comment
There was a problem hiding this comment.
Tech Lead review — approved.
Session-resume ownership is fail-closed and I could not find a hijack path. TelegramSessionResume.owner_id is next(iter(allowed_user_ids)) if len(allowed_user_ids) == 1 else 0, and is_owner requires all of owner_id truthy, chat_type == "private", user_id == owner_id, and chat_id == user_id — so a zero-user or multi-user roster disables inbound resume entirely rather than picking a winner, and a Forum Topic (non-private, thread_id set) can never satisfy it. The gate is applied at all three entry points, not just the picker: route() refuses before dispatch whenever the resolution yields a key or is ambiguous, _callback_target re-checks it because callbacks bypass transport.receive, and the dashboard-created path goes through the new may_resume_from hook (thread_id is None and len(self._allowed) == 1 and conversation_id in self._allowed), which resolve_inbound_capability treats as fail-closed on both a missing hook and a raising one.
I specifically checked whether the dispatcher's owner_id snapshot can go stale against a live roster edit: it cannot be exploited, because _authorized reads the live self._allowed and rejects a de-rostered user at the door before routing is ever reached, and a roster GROWING past one leaves owner_id pinned to the original single owner so a newly added user still fails user_id == owner_id. Title disclosure on the durable-expectation path is closed too — a non-owner collapses any non-empty RoutingDecision to the generic refusal that names nothing, and the settlement is deliberately left owed so the real owner still gets the notice.
AGENTS.md messaging rules are met: docs/system-specs/modules/messaging.md and src/kiro_crew/docs/telegram-integration.md are updated in the same commit as the behavior they document, the new hook is documented under the transport contract, authorize stays deny-by-default, and may_resume_from is synchronous and in-memory for the same reason may_send_to is.
Both blocking-capable lanes (GPT 5.6, Opus 4.8) are clean on this exact head, all 64 check runs are success/skipped, and there are no unresolved inline findings. The two advisory CONCERNS I am accepting rather than blocking on: the duplicated hand-rolled compensation sagas over batched_save() (a session-map-owned claim-with-compensation primitive is the right follow-up before a third copy appears), and the description implying /sessions is purely preserved when a multi-user roster in fact loses the read-only listing — that withdrawal is a disclosure tightening, so the direction is safe and only the wording is wrong. The Teams _persist_turn restriction gap that First Principles names is pre-existing and out of this PR's scope; worth a follow-up issue.
What this changes
Telegram's
/sessioncould only search: it printed matching dashboardconversations as text, with no way to attach to one. This makes the results
actionable —
/session <query>posts them as inline buttons, and one press bindsthe chat to that dashboard session, so ordinary messages continue it until
/newor
/unlink. This brings Telegram to functional parity with Discord for dashboardsession takeover.
/session <query>ranks dashboard conversations over bothtitles and message content and posts them as inline buttons.
/sessionis nowthe primary spelling;
/sessionsremains an alias./unlink, replacingonly Telegram-native outbound mirrors of the same DM. A deliberate dashboard
mirror already attached to that chat is left intact and the press is refused
instead.
dispatch, so
/stop,/compact,/modeland/titleact on the resumedsession rather than the native one. Recovery and host commands stay native.
/newand/unlinkrelease throughSessionBinder.release,clearing the binding and retiring the durable expectation, so the next
message runs natively with no stale refusal.
private DM, via a new
may_resume_fromtransport hook that fails closed. It isrechecked on every inbound route and every callback press, so a stale or
dashboard-created binding cannot bypass the rule. Forum Topics and multi-user
allow-lists are refused.
failure at context exit — restores both the mirror state and the expectation.
stale picker cannot mutate a detached session; only native pickers persist the
route preference.
broadcast_userparameter, which Telegram opts into.Docs updated in the same commit:
docs/system-specs/modules/messaging.mdandsrc/kiro_crew/docs/telegram-integration.md, plus the transport/mirror/Webexcontract docstrings that named Discord as the only inbound-resolving channel.
Testing
test_telegram.py,test_telegram_sessions.py(new, coveringthe picker, takeover, rollback, ownership and stale-picker paths),
test_telegram_parity.py,test_discord_sessions.py,test_channel_row_identity.py.test_capability_ledger.py,test_chat_mirror.py,test_session_map_mirror.py,test_teams_sessions.py,test_teams_routing.py.mypy --platform linuxclean across 1,283 files; black, isort,subprocess-encoding, docs-lint, brand and harness-parity gates all pass.
kirocrew podon this branch: the worktree'sgateway boots healthy, then the real flow was driven against real on-disk state
(real
ConversationLog,SessionManager/SessionMap,ResumeExpectations,and the real dispatcher command dispatch) with only the Bot API client stubbed —
28/28 assertions, covering picker → durable bind → routing to the dashboard
session → non-owner refusal →
/unlinkrelease → protected dashboard mirror.Teardown verified zero residue and an untouched live plane.
Not covered
TELEGRAM_BOT_TOKENby design (SEED_DISABLED_SECTIONS,build_pod_env) so itcan never answer real people as the operator's bot, and
telegram/client.pyhardcodes the Bot API base URL. The physical button tap therefore needs a human
with a real token and Telegram account.
/stop,/compact,/modeland/titleagainst a resumed session are covered by unit tests only.may_resume_from),not through a real Topic message.