Skip to content

feat(tg-ctl): harden defer-while-waiting (follow-up questions, wedges, stale resurface)#35

Open
alex-mextner wants to merge 1 commit into
mainfrom
tg-question-defer
Open

feat(tg-ctl): harden defer-while-waiting (follow-up questions, wedges, stale resurface)#35
alex-mextner wants to merge 1 commit into
mainfrom
tg-question-defer

Conversation

@alex-mextner

Copy link
Copy Markdown
Owner

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

  • 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 / indefinite wedge — a transient inject failure (pane lost its agent) used to re-defer 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 round-based 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 — not when a flush is in flight or another question is still pending) and tell the user the messages did not land.
  • Throw safetyinjectDeferredOne can no longer reject driveFlush; the fire-and-forget flushDeferred call is .catch-guarded.

Design

features/tg-ctl/defer.ts is a pure, fully unit-tested module (DeferQueues + driveFlush); the daemon owns all tmux I/O and the pendingButtons map. The button-reply channel (inline buttons → routed hook answer) already existed; this PR is the daemon/inbound side.

Tests

  • Unit: tests/ctl-defer.test.ts — queue FIFO/redefer/drop, and driveFlush (inject-all, mid-flush re-defer, failure-continues, whole-queue re-defer).
  • Integration (real daemon + fake Telegram + fake tmux/ps): deferred → FIFO flush on answer; and question abandoned without an answer does not wedge or resurface (Q1 expiry then Q2 answer must not flush Q1's backlog).
  • Full suite green: 1011 pass / 0 fail.

Quality gate

Multi-model review --staged run 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

… 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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread tg-ctl
// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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.

1 participant