Skip to content

feat(telegram): resume dashboard sessions from chat - #8380

Merged
bolichen97 merged 2 commits into
mainfrom
feat/telegram-session-resume
Sep 6, 2026
Merged

feat(telegram): resume dashboard sessions from chat#8380
bolichen97 merged 2 commits into
mainfrom
feat/telegram-session-resume

Conversation

@pepmach

@pepmach pepmach commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What this changes

Telegram's /session could only search: it printed matching dashboard
conversations as text, with no way to attach to one. This makes the results
actionable — /session <query> posts them as inline buttons, and one press binds
the chat to that dashboard session, so ordinary messages continue it until /new
or /unlink. This brings Telegram to functional parity with Discord for dashboard
session takeover.

  • Picker → bind. /session <query> ranks dashboard conversations over both
    titles and message content and posts them as inline buttons. /session is now
    the primary spelling; /sessions remains an alias.
  • One-click takeover. A press binds with no preparatory /unlink, replacing
    only 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.
  • Commands follow the resumed session. Routing resolves before command
    dispatch, so /stop, /compact, /model and /title act on the resumed
    session rather than the native one. Recovery and host commands stay native.
  • Release. /new and /unlink release through SessionBinder.release,
    clearing the binding and retiring the durable expectation, so the next
    message runs natively with no stale refusal.
  • Ownership. Inbound binding requires the single configured operator in a
    private DM, via a new may_resume_from transport hook that fails closed. It is
    rechecked 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.
  • Rollback. Any failure — a lost claim, a persistence error, or a batch-write
    failure at context exit — restores both the mirror state and the expectation.
  • Model picker. Pickers bind to an exact session and revalidate on press, so a
    stale picker cannot mutate a detached session; only native pickers persist the
    route preference.
  • Discord's existing non-broadcast live projection is preserved behind an explicit
    broadcast_user parameter, which Telegram opts into.

Docs updated in the same commit: docs/system-specs/modules/messaging.md and
src/kiro_crew/docs/telegram-integration.md, plus the transport/mirror/Webex
contract docstrings that named Discord as the only inbound-resolving channel.

Testing

  • 877 passedtest_telegram.py, test_telegram_sessions.py (new, covering
    the picker, takeover, rollback, ownership and stale-picker paths),
    test_telegram_parity.py, test_discord_sessions.py,
    test_channel_row_identity.py.
  • 292 passedtest_capability_ledger.py, test_chat_mirror.py,
    test_session_map_mirror.py, test_teams_sessions.py, test_teams_routing.py.
  • flake8 clean; mypy --platform linux clean across 1,283 files; black, isort,
    subprocess-encoding, docs-lint, brand and harness-parity gates all pass.
  • Smoke-tested in an isolated kirocrew pod on this branch: the worktree's
    gateway 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 → /unlink release → protected dashboard mirror.
    Teardown verified zero residue and an untouched live plane.

Not covered

  • No live-bot test. A pod force-disables Telegram and scrubs
    TELEGRAM_BOT_TOKEN by design (SEED_DISABLED_SECTIONS, build_pod_env) so it
    can never answer real people as the operator's bot, and telegram/client.py
    hardcodes the Bot API base URL. The physical button tap therefore needs a human
    with a real token and Telegram account.
  • No model turn ran in the pod smoke test, so /stop, /compact, /model and
    /title against a resumed session are covered by unit tests only.
  • The forum-Topic refusal is verified at the policy level (may_resume_from),
    not through a real Topic message.

@pepmach
pepmach requested a review from a team as a code owner September 4, 2026 05:13
@pepmach
pepmach requested a review from dwu96 September 4, 2026 05:13
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 56d7ce470b8f4f637fada6d8e2fb3489c2f8f172 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

  • Two bespoke compensation patterns over sessions.batched_save() ship in one PR: commit_binding/rollback_binding in the shared choose() and _claim_binding/_release_binding in api_chat_slot_mirror_link. Each encodes the same subtle invariants (re-derive occupancy inside the lock, conditional undo, "anything newer is deliberate") only in comments; the next writer who needs atomic map mutation will hand-roll a third, and a missed rule silently clears a deliberate user binding. Consider a session-map-owned claim-with-compensation primitive as a follow-up before a third copy appears.
  • /session now hard-refuses any non-single-owner configuration (show_pickerowner_refusal), so a multi-user forum roster loses the read-only listing /sessions used to give ("/sessions remains an alias" implies it's purely additive). The withdrawal is a defensible disclosure fix — host-wide titles reached any allow-listed participant — but it should be stated in the description as removed capability, not implied preserved.

Suggestions

  • rename_channel_title_live mutates slot._titled/_title_origin/_title_epoch and imports the private _persist_title; give the slot an owned rename method so the manual-rename ordering has one author instead of two.

[DESIGN-REVIEWED] 56d7ce4

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 56d7ce470b8f4f637fada6d8e2fb3489c2f8f172 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All evidence gathered. I verified the sibling counts in the repository at HEAD: Teams routes inbound resumed sessions (teams/transport_dispatch.py:519) yet its _persist_turn (line 1042) appends with no restriction check (session_is_restricted in teams/: 0 matches), and neither Discord nor Teams passes blocks_reads to build_message (0 matches in each; default is False at context.py:2785). Here is the review.

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 ships

Intent: let the Telegram operator continue an existing dashboard conversation from chat with one button press — an ADDITION.

  1. /session posts inline buttons; a press binds the DM to that dashboard session — justified
  2. /session becomes the menu spelling, /sessions the alias — justified (label follows the new capability)
  3. A press auto-replaces the DM's own outbound mirror; a deliberate dashboard mirror refuses — justified
  4. /stop, /compact, /model, /title, /spawn, /task act on the resumed session; recovery commands stay native — justified
  5. /new and /unlink leave the resumed session and retire its durable expectation — justified
  6. Only one configured operator's private DM may resume, enforced dashboard-side via new may_resume_from hook — justified
  7. Failed picks and failed dashboard links restore the prior mirror and expectation — justified
  8. Model pickers bind to an exact session and refuse after a rebind — justified
  9. Resumed incognito/temporary dashboard sessions write no transcript/title, and on Telegram read no memory; /temporary//incognito refused while resumed — undeclared; point patch (siblings below)
  10. Dashboard "link to channel" now claims the binding before announcing and backfilling — undeclared
    More than 10 items exist (shared-helper moves, Webex docstring, baseline prune); these are the ten a user would notice.

Watch

  • Counted unfixed siblings of item 9's root cause (channel writers/readers fail open on a restricted dashboard: key). Write gate: Teams routes resumed sessions (teams/transport_dispatch.py:519) but _persist_turn (:1042) appends unconditionally — grep session_is_restricted in teams/: 0. Read gate: fixed only on Telegram — grep blocks_reads in discord/ and teams/: 0 each, and build_message defaults it False (context.py:2785), so a dashboard session marked temporary after resume still reads memory on Discord/Teams. The Teams fix is the same ~30-line shape this PR already applied to Discord; deferred, it should at least be named.
  • Items 9 and 10 are entirely absent from the description ("Ownership… Rollback… Model picker" is the full bullet list); a reviewer reading the intent file never learns Discord's write behavior and a dashboard endpoint's ordering changed.
  • The single-operator rule is spelled twice: TelegramTransport.may_resume_from reads the live _allowed set; TelegramSessionResume.is_owner reads an owner_id captured once at construction. The description attributes the per-message recheck to the hook; the code uses the other spelling, and the two can drift on a roster change.

Subtractions

  • Delete the owner_id snapshot in TelegramSessionResume.__init__ (telegram/session_resume.py) — derive ownership per call from the same live roster may_resume_from reads, so the single-operator rule has one spelling.

[FIRST-PRINCIPLES-REVIEWED] 56d7ce4

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 56d7ce470b8f4f637fada6d8e2fb3489c2f8f172 — this comment is updated in place on each push.

Review details

I have enough to decide. Let me verify my reasoning on the read-raise reachability by confirming mirror_opt_out's only write-triggering path.

The analysis is complete:

  • mirror_opt_out (session.py:2391) returns immediately from a pure in-memory get_flag when the bucket flag is True. get_flag (session_map.py:1761) is a plain dict read that cannot raise. The only path that can raise is the legacy-key promotion at session.py:2414-2416, where a batched_save file write fails — i.e. the user must have an opt-out stored under the old generation key (upgraded install), the bucket flag must be False, and a filesystem write must fault at that exact moment.
  • For the opt-out to end up durably cleared, that same I/O fault must then be transient enough that _claim_binding's write of False succeeds durably, while a later step (governance narrowing / send failure) fails so _release_binding runs and skips the restore because previous_opt_out is None.

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 56d7ce470b8f4f637fada6d8e2fb3489c2f8f172.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 56d7ce470b8f4f637fada6d8e2fb3489c2f8f172: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 56d7ce470b8f4f637fada6d8e2fb3489c2f8f172 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 56d7ce4

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 56d7ce470b8f4f637fada6d8e2fb3489c2f8f172: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 4, 2026
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ⏭️ skipped

Revision 56d7ce470b8f4f637fada6d8e2fb3489c2f8f172 touches no user-facing surface (no changes under website/ or committed screenshots), so the UX review was skipped. Advisory — does not block merge.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Sep 4, 2026
@pepmach
pepmach force-pushed the feat/telegram-session-resume branch from ea91de6 to 7e1c5fe Compare September 4, 2026 18:56
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 4, 2026
@pepmach
pepmach force-pushed the feat/telegram-session-resume branch 3 times, most recently from 51cefe9 to aa0940b Compare September 4, 2026 21:37
@pepmach pepmach changed the title feat: resume dashboard sessions from telegram feat(telegram): resume dashboard sessions from chat Sep 4, 2026
@pepmach
pepmach force-pushed the feat/telegram-session-resume branch from aa0940b to a998b56 Compare September 4, 2026 22:06
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 4, 2026
@pepmach
pepmach force-pushed the feat/telegram-session-resume branch from a998b56 to e0a5c69 Compare September 4, 2026 22:24
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 5, 2026
@pepmach
pepmach force-pushed the feat/telegram-session-resume branch from bb432b0 to 253facc Compare September 5, 2026 06:00
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 5, 2026
@pepmach
pepmach force-pushed the feat/telegram-session-resume branch from 253facc to 598f03e Compare September 5, 2026 06:21
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@pepmach
pepmach force-pushed the feat/telegram-session-resume branch from 598f03e to 31322f2 Compare September 5, 2026 06:25
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@pepmach
pepmach force-pushed the feat/telegram-session-resume branch from 31322f2 to 6534ca9 Compare September 5, 2026 06:44
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 5, 2026
@pepmach
pepmach force-pushed the feat/telegram-session-resume branch from 6534ca9 to 1978783 Compare September 5, 2026 07:04
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 5, 2026
@pepmach

pepmach commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

/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.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Human judgment recorded

@pepmach marked the gpt AI finding as false positive, not applicable, or explicitly accepted for 8490cbe35a48047c1e23441fd8331f9047fd4dcf.

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.

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 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants