feat(tg-ctl): harden defer-while-waiting (follow-up questions, wedges, stale resurface)#35
feat(tg-ctl): harden defer-while-waiting (follow-up questions, wedges, stale resurface)#35alex-mextner wants to merge 1 commit into
Conversation
… wedges, and stale resurface Inbound Telegram text is injected into the agent's tmux pane via send-keys. If a question/permission prompt is OPEN in that pane, injected text lands IN the prompt and is lost/corrupted. The daemon already queued inbound behind an open question and flushed on answer, but the flush had several real hazards: - Follow-up question: the agent commonly re-prompts the instant its question is answered. The old flush drained the whole queue up front and pasted without re-checking, typing queued text straight into the new prompt. driveFlush now re-checks the pane before EVERY paste and re-defers the untouched tail. - Head-of-line block: a transient inject failure (pane lost its agent) re-deferred the whole tail behind a question that may never come, wedging every later message forever. Failures now log + drop + continue. - Out-of-order during a flush: a message arriving in the 800ms settle window or between pastes could jump ahead of older queued items. A flushingPanes flag keeps new inbound deferring (strict FIFO) for the whole flush; the drain loop delivers it. - Stale resurface: a question removed WITHOUT a Telegram answer (timeout / hook socket close / send failure) left its backlog to flush, stale and out of order, on a LATER unrelated question's answer. Those paths now dead-letter the pane's queue (only when the pane is otherwise idle) and tell the user the messages did not land. - Throw safety: injectDeferredOne can no longer reject driveFlush, and the fire-and-forget flushDeferred call is .catch-guarded. The queue model (DeferQueues + driveFlush) is a pure, fully unit-tested module; the daemon owns tmux I/O and the pendingButtons map. Adds unit tests for the queue + flush logic and two daemon-level integration tests (deferred→FIFO flush; question abandoned-without-answer does not wedge or resurface later inbound). Known pre-existing limitation (not introduced here): when a Telegram question expires but the agent's TERMINAL prompt stays open, the daemon loses its only "busy" signal and the next inbound can inject into that open prompt. Solving it needs prompt-state the bridge cannot observe; tracked separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 64ed7c6261
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // queue) or another question is still pending (its answer will flush). Dropping | ||
| // in either case would lose another handler's messages (review finding). | ||
| const onQuestionAbandoned = (paneId: string): void => { | ||
| if (flushingPanes.has(paneId) || paneHasPendingQuestion(paneId)) return |
There was a problem hiding this comment.
Drop abandoned queues even during a flush
When a follow-up question opens while an earlier answer is flushing, then that follow-up hook times out or its socket closes before the flush loop observes it, this guard returns solely because flushingPanes is set. Messages that arrived behind the now-abandoned question stay in deferred, and the active flushDeferred loop will see no pending question and drain them into the still-open terminal prompt instead of dead-lettering them. Please track/drop queues abandoned during a flush rather than letting the flush deliver them.
Useful? React with 👍 / 👎.
What
Hardens the tg-ctl defer-while-waiting path (ROADMAP §9 tg#30, #30). tg-ctl injects inbound Telegram text into the agent's tmux pane via
send-keys; if a question/permission prompt is OPEN in that pane, injected text lands IN the prompt and is lost/corrupted. The daemon already queued inbound behind an open question and flushed on answer — but the flush had several real hazards. This PR closes them and adds the test coverage the feature lacked.Hazards fixed
driveFlushnow re-checks the pane before EVERY paste and re-defers the untouched tail.flushingPanesflag keeps new inbound deferring (strict FIFO) for the whole flush; the round-based drain loop delivers it.injectDeferredOnecan no longer rejectdriveFlush; the fire-and-forgetflushDeferredcall is.catch-guarded.Design
features/tg-ctl/defer.tsis a pure, fully unit-tested module (DeferQueues+driveFlush); the daemon owns all tmux I/O and thependingButtonsmap. The button-reply channel (inline buttons → routed hook answer) already existed; this PR is the daemon/inbound side.Tests
tests/ctl-defer.test.ts— queue FIFO/redefer/drop, anddriveFlush(inject-all, mid-flush re-defer, failure-continues, whole-queue re-defer).Quality gate
Multi-model
review --stagedrun across several rounds; every real finding fixed (head-of-line block, stale resurface, whole-pane over-drop, throw safety, FIFO-during-flush).Known pre-existing limitation (not introduced here)
When a Telegram question EXPIRES but the agent's TERMINAL prompt stays open, the daemon loses its only "busy" signal and the next inbound can inject into that open prompt. Solving it needs prompt-state the bridge cannot observe (no event signals an in-terminal answer). Same behavior as before this PR; called out for follow-up.
🤖 Generated with Claude Code