feat(bin): add a Telegram process-event adapter - #1873
Closed
notno wants to merge 11 commits into
Closed
Conversation
* docs(agents): trim section 2 layout tree to a thin pointer surface Replace the exhaustive annotated layout tree with a compact top-level summary plus explicit owner pointers (docs/configuration.md for layout and config schemas, producing script headers for artifact fields). Normative tree annotations without another owner survive as section 2 prose: the state/ dot-file never-edit rule, the procevent supervision-required presence rule, the symlink edit-the-original rule, and the read-script-headers-first rule. AGENTS.md drops from 63,377 to 53,563 chars. * no-mistakes: apply CI fixes
An operator reply from a phone now wakes firstmate through the existing process-event runner instead of sitting unseen in a chat. bin/fm-procevent-telegram.sh long-polls getUpdates from an offset one past the recorded cursor and returns the first non-empty batch as a captured result. The poll only reads the cursor: the handler advances it after fully handling the messages, so a crash never advances past unhandled replies. A restart before that advance re-captures the same updates, so the header states plainly that handlers must treat any update id at or below the cursor as already seen. The bot token is read from a private file and passed to curl through a stdin config, so it never reaches the argv, stdout, or stderr. Transient transport and server errors retry with bounded backoff; HTTP 401 and 403 exit non-zero so the runner surfaces a rejected token rather than spinning. A message stream never self-terminates, so terminal always keeps the source armed and retirement stays an explicit operator action.
…andoff after delivery
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
Goal: ship a Telegram process-event adapter for firstmate so an operator's phone reply wakes the agent instead of sitting unseen in a chat. One new executable, bin/fm-procevent-telegram.sh, delivered through the normal PR path.
Re-delivery context: this exact work already completed a full pipeline run with a clean review, green tests, and clean lint. That run's PR was raised against the wrong repository because the shared gate clone's origin pointed at the upstream repo. The captain has closed that PR and repointed the clone to the fork (https://github.com/notno/firstmate.git). The branch was rebased onto the fork's main, preserving every pipeline fix commit and dropping an unrelated already-merged commit that had been bundled, and it is delivered here on a fresh branch because the prior terminal run's stale ref blocked a fast-forward. This run must deliver as a PR on the fork against main.
Required behavior of the adapter:
Captain-approved additions accepted during the earlier run, all already implemented and now part of the accepted contract:
Constraints and context a reviewer reading only the diff would not know:
What Changed
bin/fm-procevent-telegram.sh, a thin adapter for the generic process-event runner witharm/poll/ack/terminal/retire.polllong-polls TelegramgetUpdatesatoffset = cursor + 1with a capped batch size, prints the raw JSON once and exits 0, never writes the cursor or prints the token (passed to curl via a stdin config), retries transient errors with bounded backoff, honours a boundedretry_afteron HTTP 429, and exits non-zero on 401/403 and on a 409 conflict naming both of its causes.ackis the cursor's sole writer - atomic, idempotent, locked, and never rewinding - and a delivery marker keeps a restarted poll from re-handing a batch that is still being handled.bin/fm-procevent.shso a child that exits non-zero keeps a private, bounded tail of its stderr atstate/procevent/<source-id>.stderr, removed by a later successful run and by retirement, since a detached runner otherwise has nowhere to report why a source cannot start. Also anchors the--helpextraction to an explicit header end marker instead of an absolute line count.ack-then-handledordering on a wake),docs/configuration.md(the newFM_TELEGRAM_*knobs and the kept stderr record),docs/scripts.md, and the verification matrix. Newtests/fm-procevent-telegram.test.shplus additions totests/fm-procevent.test.shcover the offset/limit contract, cursor read-only polling, ack semantics, hard-failure exits, staged-file cleanup on process-group SIGTERM, and the kept failure diagnostic. Review left five open informational notes on residual crash windows and recovery ergonomics; tests, lint and the document phase pass.Risk Assessment
✅ Low: All five round-2 findings are genuinely resolved - the runner is back to its original single-pipe completion shape with stderr as a plain unwatched file plus a grandchild-holds-stderr regression test, the marker is written after the handoff with the reason recorded in code,
retireclears it idempotently without re-opening the re-capture loop on ordinary re-arm, every registration-removal path now drops the stderr artifact, and the env-var doc is corrected - leaving only a one-line help-range off-by-one and two informational notes.Testing
Ran the two targeted suites (
tests/fm-procevent-telegram.test.sh,tests/fm-procevent.test.sh) throughbin/fm-test-run.sh- both green - and then produced product-level evidence by driving the real generic runner with the real adapter against a stand-in Telegram Bot API, capturing a CLI transcript of the full operator journey from arming a phone line through a captured reply, one published wake, ack-then-handled, restart dedupe, the 401 and 409 failure paths, and retire, with the bot token verifiably absent from every argv, result, wake, and diagnostic. The change is shell/CLI only with no rendered UI surface, so the reviewer-visible artifact is that transcript rather than a screenshot. The worktree is clean; all evidence lives under the dedicated evidence directory.Evidence: End-to-end operator journey transcript (real runner + real adapter, stubbed Bot API)
=== 1. Operator arms the phone line === $ bin/fm-procevent-telegram.sh arm phone-line registered: phone-line (telegram) armed: phone-line $ bin/fm-procevent.sh list SOURCE ADAPTER OWNER PENDING phone-line telegram none 0 === 2. Runner supervises the poll; the operator sends a message from their phone === $ bin/fm-procevent.sh start phone-line captured: .../state/procevent-inbox/phone-line.1.result $ bin/fm-procevent.sh list phone-line telegram none 1 === 3. What the poll asked Telegram for === offset=1 limit=8 timeout=1 === 4. The captured result the agent is woken with === {"ok":true,"result":[{"update_id":901,"message":{"from":{"first_name":"Nathan"},"chat":{"id":4242},"text":"ship it, merge the PR"}}]} $ wake queue entry check: procevent telegram phone-line 1 === 5. Token safety === $ token in curl argv (process listing)? 0 $ token in the stdin config curl read? 3 $ token anywhere in captured state? no matches === 6. Handler finishes: ack first, then handled === $ bin/fm-procevent-telegram.sh ack 901 acked: 901 $ cat ~/.config/firstmate/telegram-cursor 901 $ bin/fm-procevent.sh handled phone-line 1 handled: phone-line 1 $ bin/fm-procevent-telegram.sh ack 901 already-acked: 901 (cursor 901) $ bin/fm-procevent-telegram.sh ack 5 already-acked: 5 (cursor 901) $ cursor after the rewind attempt: 901 === 7. Poll restarts: delivered message not re-woken, the new one is === $ bin/fm-procevent.sh start phone-line captured: .../state/procevent-inbox/phone-line.2.result $ offsets requested this round: offset=902 limit=8 $ second captured result: {"ok":true,"result":[{"update_id":901,..."ship it, merge the PR"},{"update_id":902,..."and tag a release"}]} === 8. A message stream never self-terminates === $ fm-procevent-telegram.sh terminal <result>; echo $? 1 === 9. Hard auth failure surfaces instead of spinning === $ fm-procevent-telegram.sh poll; echo $? error: telegram rejected the bot token (HTTP 401); check ~/.config/firstmate/telegram-token exit=1 $ ls -l state/procevent/phone-line.stderr -rw------- 1 nathan nathan 117 ... phone-line.stderr $ token leaked into that diagnostic? 0 === 10. A conflict names both of its causes === error: telegram refused getUpdates with a conflict (HTTP 409); either a webhook is configured on this bot or another getUpdates poller is already running for it, so check both before re-arming this poll exit=1 === 11. Operator retires the line === $ bin/fm-procevent-telegram.sh retire phone-line retired: phone-line $ bin/fm-procevent.sh list no sources registered $ delivery marker cleared, cursor kept: telegram-cursor telegram-tokenEvidence: Reproducible demo script used to generate the transcript
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-procevent-telegram.sh:50- The documented "duplicate-result window" is not a single restart - it repeats every watcher cycle for the whole handling window.bin/fm-watch.sh:791callsfm-procevent.sh reconcileeach ~15s cycle (POLL=${FM_POLL:-15}), andcmd_reconcilerestarts any registered source whose claim is released (bin/fm-procevent.sh:433-437) with no cooldown and no gate on a pending unhandled result. Sinceterminalalways exits 1 and onlyackmoves the cursor, each restart re-polls the sameoffset=cursor+1, gets the same batch back, andfm_procevent_capture+publish_pendingmint a fresh<id>.<seq>.resultand a fresh wake keyprocevent:<id>:<seq>(bin/fm-procevent.sh:161-178). Concrete sequence: operator sends one message; poll captures update 901 as seq 1 and wakes the agent; the agent spends 5 minutes handling before it can ack; meanwhile ~20 more results and ~20 more wakes for that same update accumulate instate/procevent-inboxand the wake queue, each requiring its ownfm-procevent.sh handledcall. Worse, those duplicates are minted before the ack, so their update ids are above the cursor - exactly the case SKILL.md:55 says "get fully re-acted on with nothing marking them a repeat" - so the already-seen rule does not cover any wake announced before the ack lands. The sibling adapters do not have this shape: lavish's poll blocks until genuinely new feedback, and remote-reply advances its own cursor at capture. Poll cannot fix this locally without writing the cursor, which the intent forbids; the earliest shared boundary is the runner - have reconcile skip restarting a source that already has an unhandled pending result for it. Flagging rather than fixing because the header treats this window as a deliberate accepted tradeoff.bin/fm-procevent-telegram.sh:163- The header (lines 26-29) and the intent both justify the non-zero exit on 401/403/409 as making the runner "surface the blocker rather than spinning on it", but under the only path that actually starts an armed source nothing is surfaced.cmd_startruns the child as"${ARGV[@]}" 2>/dev/null(bin/fm-procevent.sh:279), discarding theerror: telegram rejected the bot token (HTTP 401).../ HTTP 409 message, and the one remaining signal -printf 'no-result: %s (exit %s)'atbin/fm-procevent.sh:316- goes to a detached runner whose output is thrown away (isolate_runner,bin/fm-procevent.sh:200:>/dev/null 2>&1 &). There is no procevent log. Concrete sequence: operator writes a typo'd token to ~/.config/firstmate/telegram-token and runsarm; every ~15s reconcile spawns a poll that dies instantly with exit 1, the registration stays armed forever, and the operator sees nothing anywhere - the silent spin the design set out to avoid, just at reconcile cadence instead of inside the poll loop. The adapter's own contract (exit non-zero) is met; the gap is in the shared runner, which would need to persist or print a hard-failure signal for a detached child. Raising it because the stated acceptance rationale does not hold end to end.bin/fm-procevent-telegram.sh:152---max-time "$((POLL_TIMEOUT + 10))"is the one numeric input in this file that is not base-10 normalized;read_cursoruses$((10#$cursor))(line 103),cmd_ackuses$((10#$id))(line 201), andretry_after_delayuses$((10#$value))(line 126). The validation at line 135 accepts any digit string, including a leading zero. WithFM_TELEGRAM_POLL_TIMEOUT=08, bash fails the arithmetic expansion (08: value too great for base), curl never runs,rcis non-zero, and the loop hitssleep "$BACKOFF"; continueforever - an invisible 5s spin that never contacts Telegram and never exits. Use$((10#$POLL_TIMEOUT + 10))to match the rest of the file..agents/skills/process-event-sources/SKILL.md:55- "first call the telegram adapter'sackfor the highest update id you fully handled, which confirms those updates at Telegram itself" misstates the mechanism.cmd_ackmakes no network call - it only writes ~/.config/firstmate/telegram-cursor under a lock. Confirmation (and server-side deletion) happens later, when the nextpollsendsoffset=cursor+1(bin/fm-procevent-telegram.sh:140,153). The adapter's own header is accurate ("The poll only READS the cursor"), and the operational conclusion in this paragraph - ack first, then handled - is unaffected, but an agent reading this could reasonably expect ack to be a fallible network step.🔧 Fix: gate duplicate telegram emissions, keep failed child stderr
5 issues (3 warnings, 2 infos) still open:
bin/fm-procevent-telegram.sh:249- The marker is committed before the batch is handed over, which converts a crash window from "one benign duplicate" into "a silently dropped operator message" - the inverse of the ordering principle the rest of this design is built on (SKILL.md justifies ack-before-handled precisely because its crash window degrades to a no-op). Concrete sequence: poll gets update 901,write_emitted 901commits,catstreams the JSON into the runner's bounder, and the runner then dies beforefm_procevent_captureat bin/fm-procevent.sh:355 - adie "cannot durably capture the result"on a full or read-only state dir, adie "cannot bound source output"at line 338, a process-group stop, a reboot. No result, no wake. The next poll reads cursor 0, marker 901, gets [901] back, computes highest=901 which is not greater than 901, and sleeps forever. The reply sits unseen in the chat - the exact failure the adapter exists to prevent - until an unrelated later message arrives. Movingwrite_emitted "$highest"to aftercat "$POLL_BODY"succeeds keeps every anti-duplicate property (a restarted poll still reads the marker and declines) and reduces the crash window to one duplicate result. The in-code rationale at lines 246-248 argues the opposite, but awrite_emittedthat dies is a persistently unwritable ~/.config/firstmate: with the marker first that means nothing is ever delivered, with it second it means the pre-fix duplicate behavior, so marker-after-handoff is the weaker failure in both directions.bin/fm-procevent-telegram.sh:312- Nothing ever clears ~/.config/firstmate/telegram-emitted, so the operator's normal recovery lever no longer works. Concrete sequence: operator arms telegram-captain, update 901 is emitted (marker 901) and captured, the agent starts handling it and dies beforeack, so cursor stays 0. The operator does the documented thing -fm-procevent.sh retire telegram-captain(which clears the registration, runner file and stderr file at bin/fm-procevent.sh:612-615) and re-arms. The fresh poll reads cursor 0 and marker 901, Telegram redelivers [901], highest=901 is not above the marker, and it waits indefinitely. Message 901 is stranded with no operator-visible way out:armdoes not touch the marker, there is noresetsubcommand, and neither the script header nor SKILL.md names the file to delete. Before this commit, retire plus re-arm always re-captured everything above the cursor. Either havecmd_armreset the marker to the current cursor (arming already implies no poll is running, since a second poller is the HTTP 409 case), or add an explicit reset and name it in the header.bin/fm-procevent.sh:329- The new plumbing makescmd_start's completion depend on the child's stderr reaching EOF, which is a behavior change every adapter shares, not an additive one. Before, the child's stderr was/dev/null- an open file no descendant could ever gate on. Now stderr is a pipe read by an inner perl; that perl holds the outer fd3 (inherited, never closed) and, more decisively, the subshell cannot run itsexituntil the inner pipeline finishes. So any descendant that outlives the child while still holding fd2 keeps the stderr bounder alive, which keeps the subshell alive, which keeps the outer pipe open, which means the output bounder at line 331 never sees EOF andcmd_starthangs forever - where previously it returned as soon as the child's stdout writers closed. I checked the shipped adapters and found no confirmed trigger today: the telegram poll only forks curl and sleep, and lavish-axi's detached server spawns withstdio: "ignore"or a log fd rather than inheriting stderr; remote-reply execs ssh, whose behavior here depends on the operator's own ControlMaster/ControlPersist config. Butregister <adapter> <id> -- <argv>is a generic boundary, so the hang is reachable by construction. Redirecting the child's stderr straight to${err:-/dev/null}as a plain file and bounding that file after the child exits keeps the diagnostic and restores the original completion condition exactly.bin/fm-procevent.sh:615-cmd_retireis the only registration-removal path that also removes<id>.stderr. Two siblings drop the registration and leave it:retire_owned_terminal_sourceremoves only$registration, reachable whenever a child exits non-zero but still wrote output (line 346 lets that through, line 342 keeps the stderr record, then the adapter classifies the result terminal); and reconcile's stop path at lines 477-481 removes the source file plus staging and runner files but not the stderr file. The result is a private 0600 diagnostic sitting in $REG for a source that no longer exists. It is inert - every registry consumer in this file, fm-procevent-lib.sh and fm-teardown.sh globs*.source/*.runner/*.claim- so this is tidiness, not a correctness break. Add the samerm -f -- "$(stderr_file "$id")"to both paths.docs/configuration.md:525- FM_TELEGRAM_BACKOFF_SECONDS now paces a third thing the doc does not mention. It still reads "pause after a transient network or server error before re-polling", but bin/fm-procevent-telegram.sh:259 also uses it as the wait between re-polls while an already-emitted batch stays unacknowledged - the common steady state for the whole time the handler is working, since Telegram answers that request immediately rather than long-polling. At the default 5 that is 12 getUpdates calls a minute for the duration of handling; at 0, whichnormalize_countaccepts and which the current wording invites as "retry errors immediately", it is an unbounded request loop against the Bot API with no pause at all. Update the description to cover the already-emitted wait, and consider flooring that particular sleep at 1 second independently of the error backoff.🔧 Fix: redirect child stderr to file, record telegram handoff after delivery
3 infos still open:
bin/fm-procevent.sh:94-usage()still printssed -n '2,69p', but this commit's header edit is net +1 line (the two-line "nothing reads it back / next run replaces it" note became the three-line "nothing waits on it / completion is decided by the child's own output alone / dropping the registration drops it too" note). The header's last comment line is now 70, sobin/fm-procevent.sh --helpends mid-sentence at line 69: "...This runner proves capture before publication and bounded re-announcement until handled, and nothing" - the closing "about the source side of the handoff." is dropped. The existing help assertion at tests/fm-procevent.test.sh:1136 only checks for "Durability boundary" (line 68), so it does not catch this. Bump the range to2,70p. The telegram adapter's own2,87pis correct (its last comment line is 87).bin/fm-procevent-telegram.sh:264- Noting the accepted residual, not asking for a change. The ordering fix is right and the reason is now in the code, butcat "$POLL_BODY"returning 0 means the bytes reached the runner's bounding reader, not thatfm_procevent_capture(bin/fm-procevent.sh:365) committed them. A runner death in that gap -die "cannot durably capture the result"on a full or read-only state dir,die "cannot bound source output", a process-group stop - still leaves the marker set with no captured result, so the batch is not re-offered until a newer update arrives or the operator runsretireand re-arms. The adapter cannot observe the runner's durable capture through the supervision contract, so this is as close to "after durable handoff" as it can get inside the stated bound; the header states it plainly andretireis the documented lever. On record because the operator gets no signal that a reply is stranded..agents/skills/process-event-sources/SKILL.md:35- The marker is cleared only bybin/fm-procevent-telegram.sh retire, butbin/fm-procevent.sh retire telegram-captainis a first-class documented command (its own help at lines 47-49 calls it "the supported explicit path") that drops the registration without clearing ~/.config/firstmate/telegram-emitted. An operator who takes that route and re-arms gets a poll that silently strands every update at or below the marker - the exact recovery gap this round closed. The convention is on the author's side: lavish and remote-reply also wrap retire, and SKILL.md:60 already says "Retire any other finished source with the adapter'sretire". The gap is that the Telegram arming paragraph at SKILL.md:35-37 names onlyarm, so nothing at the point of use says which retire to reach for. Namingbin/fm-procevent-telegram.sh retirethere, and why it is the one that recovers a stranded reply, closes it mechanically.✅ **Test** - passed
✅ No issues found.
bin/fm-test-run.sh tests/fm-procevent-telegram.test.sh tests/fm-procevent.test.sh- both scripts pass, 0 failures (adapter behavior: offset=cursor+1 with absent cursor as 0, batch limit, poll never writes the cursor, ack atomic/idempotent/non-rewinding under a lock, 401/403 and 409 hard exits, 429 honouring bounded retry_after, terminal always exit 1, arm registering [<self>, poll], staged-file cleanup on process-group SIGTERM, --help contract)Manual end-to-end operator journey driving the realbin/fm-procevent.shrunner with the realbin/fm-procevent-telegram.shadapter against a stand-in Telegram Bot API (onlycurlstubbed, no network, no real token):fm-procevent-telegram.sh arm phone-line->fm-procevent.sh start phone-line->fm-procevent.sh list-> captured result + wake queue entryVerified the request the poll actually issues:offset=1on an absent cursor,limit=8,timeout=<FM_TELEGRAM_POLL_TIMEOUT>; andoffset=902afterack 901Verified the documented handler order end-to-end:fm-procevent-telegram.sh ack 901(cursor file written to 901) thenfm-procevent.sh handled phone-line 1; repeatack 901and rewindingack 5both succeed as no-ops leaving the cursor at 901Verified restart dedupe: a restarted poll does not re-hand the already-delivered batch and only exits 0 once update 902 arrives, producingphone-line.2.resultVerifiedfm-procevent-telegram.sh terminal <result>exits 1 and the source stays registeredVerified hard failures: HTTP 401 poll exits 1 with a token-free message, and under the runner the reason is kept privately atstate/procevent/phone-line.stderr(mode 0600); HTTP 409 exits 1 naming both a configured webhook and another running pollerVerified token containment: 0 occurrences of the token in curl's argv log, in the captured result, in the wake queue, and in the kept stderr diagnostic; it appears only in the stdin config curl readsVerifiedfm-procevent-telegram.sh retire phone-linedrops the registration and clears the delivery marker while keeping the cursorbin/fm-procevent.sh:94- bin/fm-procevent.sh:94usage()printssed -n '2,69p', but the change extended the header comment to line 70 (set -uis line 71). The last header line is dropped, sobin/fm-procevent.sh --helpnow ends mid-sentence: "...bounded re-announcement until handled, and nothing" - losing "about the source side of the handoff." That sentence is the runner's own statement of what it does NOT prove, and the script header is the authoritative owner of the runner's mechanics. Before this change the range was2,63pagainst a header ending at line 63, so this is a regression introduced here (7 header lines added, range advanced by 6). Fix is2,69p->2,70p. I left it because this phase may only edit documentation files and doc comments, andusage()is an executable line. The sibling bin/fm-procevent-telegram.sh is correct (2,87p, header ends at 87).docs/scripts.md:65- docs/scripts.md lists onlyfm-procevent-remote-reply.shand the newly addedfm-procevent-telegram.sh; the generic runnerfm-procevent.shandfm-procevent-lavish.share still absent from the inventory. This predates the change and the author explicitly scoped it out as a separate table-wide sweep, so I did not touch it. Recording it only as a follow-up candidate.🔧 Fix: anchor procevent help extraction to header end marker
1 info still open:
bin/fm-procevent.sh:95- Follow-up candidate, deliberately not done here. About 18 other bin/*.sh scripts still bound their --help extraction with an absolute line count (sed -n '2,NNp'), the exact pattern that silently truncated this runner's header. I swept them and none is currently truncating (the line after each range is not a comment), so there is no live defect to fix and nothing this change made stale - but each carries the same latent failure the next time its header grows. The repo already has a marker-bounded idiom in three scripts (sed -n '2,/^set -u$/p' | sed '$d; ...'in bin/fm-public-followup.sh, bin/fm-public-followup-emit.sh, bin/fm-session-start.sh), so a consolidation sweep onto one marker convention has a precedent to standardize on. I used an explicit sentinel comment here rather than that/^set -u$/idiom because the approved instruction specified a sentinel; a future sweep should pick one of the two and apply it uniformly. Out of scope for this change.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.