feat: add private Telegram bridge - #1444
Open
palinopr wants to merge 17 commits into
Open
Conversation
added 16 commits
July 31, 2026 21:50
Only the arm that owns a watcher child reads that cycle's wake output. An arm merely attached to the same watcher - the ordinary shape whenever a manual recovery arm and the Stop-owned auto-arm overlap - therefore could not tell a delivered wake from a watcher that vanished, and reported the very cycle that woke firstmate as "cycle ended without an actionable reason". The owner's own ledger row already recorded the truth: reason=actionable-signal sat beside the observer's attached-cycle-ended row for the same watcher_pid. An attached arm now reads that owner record for the exact watcher PID and lock identity, anchored at the moment it began following, before it classifies the close. Proof of a delivered wake changes only the claim. Loudness is deliberately unchanged. The close still exits nonzero either way, because no live cycle remains and a silent return would let a harness treat an unwatched home as healthy. That keeps every adapter recovering exactly as it does today and makes a misclassification able to misword an alarm but never suppress one. Absent, malformed, truncated, or ambiguous evidence keeps the unexplained wording. Scope: this began as a two-part change that also relaxed the Claude continuity PreToolUse gate. That half is dropped - kunchenguid#1001 removed the watcher-status gate entirely, so it is moot rather than merely declined, and this branch is rebased onto that work and leaves it untouched.
`wedge_timer_check` escalated a provably-working stale pane on elapsed idle time alone, deliberately never re-reading the crew state after classification. A long validation step leaves the pane visually static for its whole duration, so a healthy worker escalated as a "possible wedge" every FM_STALE_ESCALATE_SECS, on exactly the same schedule a frozen one would. Observed live on 2026-07-24: one endpoint escalated six times inside a single 40-minute review pass, each costing a supervision turn to confirm "yes, still working". An alarm with a known benign cause trains its reader to ignore the one that matters. The authoritative signal already existed and was never consulted. The active step's `last_activity` stamp records the age of the most recent thing the pipeline actually did. At the one poll where the timer would otherwise fire, and only there, the watcher now asks whether that activity falls inside the window that just elapsed. Positive proof of advancement resets the timer and absorbs; everything else escalates unchanged. This makes the alarm more correct in both directions: a genuinely wedged worker is now caught on the criterion that actually matters rather than on a clock that fires identically for healthy and wedged panes. The failure mode of the fix is worse than the bug, so it governs the design. - Inability to determine progress escalates exactly as before. No attributed run, a coarse-only match, an unreadable or unparseable stamp, a bounded call that timed out, or a helper too old to understand `--progress` all read as could-not-determine. The evidence may only ever suppress an alarm on positive proof; absence of evidence is never proof. - The demand-deep-inspection path stays reachable: an advancement absorb deliberately leaves the consecutive-escalation count untouched. - Scope is the provably-working wedge timer only. `blocked:`, dead endpoints, declared pauses, and the away-mode daemon's own cadences are untouched. - Cost is bounded and does not run per poll. The read happens only at a would-be escalation, reuses fm-crew-state's existing bounded-call budget, and skips the coarse runs-list fallback that could only answer unknown anyway. The progress read reuses fm-crew-state's run attribution unchanged, so a neighbouring branch's healthy run can never suppress this crew's alarm. Tests: a behavior test proving the false escalation (fails without the change, reporting "idle 500s, possible wedge, escalation 1"), plus a preservation guard that a non-advancing pipeline still escalates and still reaches demand-deep-inspection. The guard passes with the change reverted, by design. Parser and attribution cases cover the stamp shapes read live from the installed CLI and every unprovable answer.
palinopr
force-pushed
the
feat/telegram-bridge-v1
branch
from
August 1, 2026 02:58
27c1b52 to
4309ac6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Build a disabled-by-default private one-captain Telegram Bot API long-poll bridge for Firstmate with owner-only local pairing, fail-closed authorization and durable exactly-once wake correlation, approval identity binding, bounded ambiguity-safe outbound receipts, quiet captain-relevant cross-project notifications, one deterministic safe rich/plain presentation boundary for every reply and selected supervised-worker update, full five-harness/runtime integration, tests, docs, and a green pull request; do not perform live Telegram setup, merge, deploy, restart, or self-update.
What Changed
Risk Assessment
✅ Low: Captain, the repair admits Grok’s supported nested same-session turn only when the exact live lock holder appears in its ancestry, while independent lock-refused sessions remain unable to rotate the epoch.
Testing
Confirmed the exact target commit and scope; the focused bridge suite and six affected supervision/harness suites pass after two narrow test-fixture fixes. A local fake Bot API journey demonstrated disabled default state, protected numeric pairing, unauthorized silence, exact durable request correlation, threaded rich delivery with a readable plain snapshot and sent receipt, body retirement, quiet routine progress, and a selected green-PR notification. No Telegram screenshot was captured because live Telegram setup was explicitly forbidden; the exact CLI transcript and Bot API payloads provide reviewer-visible evidence instead.
Evidence: End-to-end fake Telegram bridge transcript
Evidence: Deterministic rich/plain presentation snapshot
Evidence: Threaded Telegram sendMessage payload
Evidence: Selected green-PR notification payload
Evidence: Durable sent reply receipt
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
.agents/skills/afk/SKILL.md- branch carries 29 commit(s) that exist on your local main branch but were never pushed to origin/main; rebasing would bundle this unrelated work (180 file(s)) into the PR:Push main to origin, or rebase your branch onto origin/main, before gating.
🔧 **Review** - 7 issues found → auto-fixed (6) ✅
bin/fm-private-lib.sh:147- Aftermvreplaces an existing receipt, a directory-sync or validation failure deletes the destination. If this occurs while recording a successful Telegram part, both the previousdispatchingreceipt and new state disappear; retrying the same ID starts from part zero and can duplicate delivered content. Preserve recoverable state at this shared publication boundary instead of removing the post-rename destination.bin/fm-telegram-lib.sh:211- Intent requires "durable exactly-once wake correlation", butwake_queued=truepermanently suppresses requeueing. Oncefm-wake-drain.shconsumes the row, a session crash before handling leaves the incomplete inbox record marked queued forever. Add a durable offer/ack lifecycle at the shared request/wake boundary so incomplete requests remain recoverable without duplicate concurrent offers.bin/fm-telegram-poll.sh:176- Intent requires "approval identity binding", but the one-time approval claim is published before the authoritative inbox request. A crash or publication failure between this call and line 206 consumes the claim; replaying the same Telegram update sees the existing claim and downgrades the valid approval to ordinary text. Make an existing claim idempotent for the exact same request ID, or commit claim and request together.bin/fm-telegram-send.sh:353- The outbound receipt becomessentbefore the approval binding and request retirement are finalized. A crash after line 334 leaves the approval inpreparingand the request body retained; retry enters thesentfast path at lines 117-121 and never repairs either state. Intent requires "approval identity binding"; the sent-receipt recovery path should idempotently finish these post-send transitions before returning.bin/fm-telegram-lib.sh:252- Intent requires "bounded ambiguity-safe outbound receipts", but pruning unconditionally deletesuncertainandsentreceipts after seven days. Reusing the stable notification receipt afterward is treated as a new send, so content that may already have arrived can be delivered again. Retain a compact immutable receipt/tombstone, especially for uncertain outcomes, while pruning message snapshots and other bulky state.bin/fm-telegram-present.pl:131- Intent requires a bounded safe presentation boundary for every reply, butsplit_visiblenever splits or rejects a single extended grapheme whose UTF-16 size exceeds the limit. A base character followed by thousands of combining marks passes the 24,000-unit source limit and produces one oversized Telegram message because no final per-message bound is asserted. Add a deterministic code-point fallback and enforce the final rich/plain unit postcondition.bin/fm-bootstrap.sh:769- Telegram sets the sharedFM_CHECK_INTERVALto one second, while the watcher uses that value to schedule every*.check.sh. A paired home therefore accelerates existing PR, X, and custom checks from their 300-second schedule to every watcher loop, increasing external API and expensive-check churn. Give Telegram its own deadline instead of changing the shared check sweep.🔧 Fix: Harden Telegram recovery, receipts, rendering, and cadence
5 issues (4 errors, 1 warning) still open:
bin/fm-telegram-lib.sh:220- Intent requires "durable exactly-once wake correlation", but an acknowledged request is re-offered after a fixed 300-second lease without proving that its original handler stopped or renewing the lease. A legitimate long-running request can therefore be offered again while its task is active. Bind the lease to a live handler with renewal or prove abandonment before re-offering.bin/fm-telegram-lib.sh:240- Each expired offer or acknowledgement rewrites the inbox file and refreshes its mtime. Because pruning uses that mtime, an actively polled unresolved request is refreshed every 60 or 300 seconds and its private body never reaches the documented seven-day expiry. Base retention on immutablereceived_ator a separate immutable expiry.bin/fm-telegram-lib.sh:382- Intent requires "approval identity binding", but a claim whose link succeeds and directory sync fails returns status 2; this branch treats it as an invalid approval without inspecting the preserved exact claim. The poll then persists an unbound request and advances the offset. Distinguish publication failure from invalid authorization so the update remains retryable rather than being downgraded.bin/fm-telegram-lib.sh:282- Intent requires "durable exactly-once wake correlation", but successful retirement unlinks the inbox record without syncing its directory; the queue replacement is likewise unsynced. A host crash after the sent receipt is finalized can resurrect an already-completed request and re-offer it. Publish a durable completion tombstone or sync both retirement boundaries before reporting finalization.bin/fm-watch.sh:101- The dedicated one-second Telegram interval does not affect the watcher's terminal wait, which still blocks for the defaultFM_POLL=15. After an empty long poll, the bridge can therefore sit idle for another 15 seconds despite documentation promising the next poll promptly. Include the Telegram deadline when calculating the terminal wait.🔧 Fix: Harden Telegram request recovery and durable retirement
2 errors still open:
bin/fm-telegram-lib.sh:332- Intent requires "durable exactly-once wake correlation", but acknowledgement suppression proves only that the long-lived harness session remains alive. If the handling turn aborts before replying while that session stays open, every poll suppresses the incomplete request indefinitely. Bind acknowledgement to a renewable turn/task claim or another boundary that proves the actual handler remains active.bin/fm-telegram-lib.sh:488- Expiry can race a reply already in flight: the sender may read the request, pruning then publishes an immutableexpiredtombstone here, Telegram accepts the reply, and completion cannot replace that tombstone with the sent receipt. The receipt remains permanently unfinalized. Linearize expiry against outbound dispatch or define recoverable precedence for a matching sent completion.🔧 Fix: Harden Telegram turn recovery and receipt precedence
2 issues (1 error, 1 warning) still open:
bin/fm-telegram-lib.sh:392- Intent requires "durable exactly-once wake correlation" and "full five-harness/runtime integration", but the cycle comparison still strands interrupted OpenCode and Pi turns. Those adapters start the successor watcher before delivering the wake and retain it across session idle, so ending or aborting the handling turn does not advancewake-cycle; this comparison continues suppressing the incomplete request indefinitely. Bind acknowledgement to an actual harness turn epoch or explicit handler release instead of watcher-process lifetime.bin/fm-telegram-lib.sh:541- When expiry races an in-flight reply, pruning queuestelegram-error request-retention-expired; the new sent-receipt precedence can subsequently change the tombstone tocompleted, but it neither removes that distinct error wake nor prevents pruning from appending it after completion wins. The captain therefore receives a false expiry alarm for a delivered request. Publish the alert within the same terminal arbitration or revalidate the winning state before enqueueing it.🔧 Fix: Harden Telegram turn recovery and expiry arbitration
1 error still open:
bin/fm-turnend-guard.sh:114- Intent requires "durable exactly-once wake correlation" and "full five-harness/runtime integration", but this new turn marker advances the shared epoch after checking only the checkout scope. A second Codex, Claude, OpenCode, Pi, or Grok session that failed to acquirestate/.lockstill runs the tracked turn-end hook and can rotate the real owner's epoch, causing an acknowledged request to be re-offered while its handler remains active. Require exact session-lock ownership at this shared mutation boundary before advancing the epoch.🔧 Fix: Bind Telegram turn epochs to session-lock ownership
1 error still open:
bin/fm-telegram-lib.sh:248- Intent requires "durable exactly-once wake correlation" and "full five-harness/runtime integration", but this exact ownership check rejects Grok's intentionally nested same-session guard turn.fm-turnend-guard-grok.shrunsgrok --resumebeneath the outer Grok process that ownsstate/.lock;fm_harness_ancestry_pidreturns the nearer nested Grok PID, so this call fails. If that forced recovery turn acknowledges a request, it records the still-live outer owner and current epoch, but its turn end cannot advance the epoch, leaving the incomplete request suppressed. Accept the exact lock PID anywhere in verified harness ancestry, or provide equivalent Grok-aware ownership proof, while continuing to reject independent sessions.🔧 Fix: Accept nested Grok lock-owner ancestry
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
git rev-parse HEAD;git merge-base HEAD 3f71cddd764a49ab71bcd53a46b84e5e7336557a; base-to-target diff inspectiontests/fm-telegram-bridge.test.shbin/fm-test-run.sh tests/fm-supervision-instructions.test.sh tests/fm-session-start.test.sh tests/fm-turnend-guard.test.sh tests/fm-claude-stop-autoarm.test.sh tests/fm-pi-watch-extension.test.sh tests/fm-arm-pretool-check.test.shbin/fm-test-run.sh tests/fm-session-start.test.sh tests/fm-pi-watch-extension.test.shafter fixture repairstests/fm-session-start.test.shafter replacing the Bash 4-only fixture PIDFake Bot API journey usingfm-telegram-setup.sh status|pair, unauthorized and authorizedfm-telegram-poll.shcalls,fm-telegram-render.sh, andfm-telegram-send.sh reply|notifyin the isolated evidence home🔧 **Document** - 3 issues found → auto-fixed ✅
bin/fm-telegram-send.sh:87- Target-wide lint fails with SC2329 because the trap-invoked cleanup function lacks an indirect-use annotation.tests/fm-telegram-bridge.test.sh:303- Target-wide lint fails with seventeen SC2031 findings after the turn-epoch subshell assignments.tests/fm-turnend-guard.test.sh:224- Target-wide lint fails with three SC2016 findings in intentional single-quoted harness fixtures.🔧 Fix: Align Telegram docs and lint rationale
✅ Re-checked - no issues remain.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.