tmux: route every paste through a uniquely-named buffer (fixes nudge prompt swap)#88
Merged
Merged
Conversation
tmux's anonymous paste buffer is a server-wide singleton, so two concurrent processes calling pasteTmuxPrompt against different sessions race on the shared buffer: the second `set-buffer` clobbers the first's contents before either side gets to `paste-buffer`. The visible symptom was the daily agent nudges swapping prompts when they fired on the same minute. On Monday 06:00 UTC the dependabot-scout pane received the changelog-scribe prompt and vice versa; on Friday the same swap surfaced for the brief window where changelog-scribe was still firing daily (its schedule_day = "Mon" was being dropped by the saas root variable schema until langwatch/langwatch-saas#565 landed). Every set-buffer / paste-buffer pair now uses a `kc-<pid>-<seq>` named buffer plus `paste-buffer -d` to delete it after the paste. pid keeps sibling processes from colliding; the in-process counter keeps multiple calls in the same process distinct (also makes the command stream deterministic for assertions in cli.test.ts). Covers pasteTmuxPrompt, pasteTmuxText, scheduleTmuxPrompt, and the two paste steps inside scheduleTmuxSelfCompact. Adds a real-tmux regression in e2e-tmux.test.ts: five iterations of two concurrent `kanban send` invocations against two sessions; the test asserts each pane received its own text and never the other's. With the previous anonymous-buffer code path this test failed intermittently; with the named-buffer fix it passes all five iterations every run.
…lready ended
The MAX_RESTORE_AGE_MS guard at startup checks the persisted pill's
lastSetMs to decide whether to restore — but the refresh loop bumps
lastSetMs every minute while the bridge runs, so even a turn that ended
hours ago looks "recent" once we restart. Result: bridge restart picks
up the stale pill, the refresh loop keeps re-lighting it, and the
channel shows "is working…" indefinitely below the agent's last reply
even though stop_reason: end_turn landed before the restart.
Tail-scan the agent's transcript / rollout on restore:
- Claude: find the LAST assistant entry, drop pill if its stop_reason
is end_turn / stop_sequence / refusal (TERMINAL_STOP_REASONS).
- Codex: drop pill if a task_complete event lands after the last
user_message in the rollout.
If terminal, clear the visible Slack pill via setStatus("") and drop
the persisted state so the refresh loop never resurrects it. Otherwise
restore as before so a mid-turn restart still re-lights the pill
without waiting for the next text post.
The same-process flow is unchanged: Claude's stop_reason terminal mark
in format.ts and the codex task_complete cross-batch safety net already
clear the pill correctly when the events land in real time. This
covers only the cross-restart case where the offset already jumped past
the marker.
… reply Bringing back the eyes anchor that PR #85 introduced and PR #87 dropped, with the missing half: the ack message gets deleted from the channel the moment the agent posts its first real reply. So the channel sees a visible "received, working" beat in the 10-20s gap before the agent replies, and the ack disappears as soon as the reply lands — no persistent "👀" sitting under every relayed prompt forever. Mechanics: - The bridge posts "👀" as a bot message right after pasteTmuxPrompt and anchors the working pill on it via assistant.threads.setStatus. - The ts is persisted to ~/.kanban-code/eyes-anchors/<slug> via eyes-anchor.ts (same atomic write pattern as active-pill / thread-root). A bridge restart in the gap between the ack and the agent's first reply still finds the record and finishes the cleanup on the next text post (or on the restore-time turn-ended check). - The agent->slack post loop calls consumePendingEyes(slug) BEFORE posting any text reply, so the eyes message is gone in Slack by the time the human sees the new reply land — no flicker. - Restore-time pill drop (the previous commit) also calls consumePendingEyes so a turn that ended without ever producing text doesn't orphan an eyes message in the channel. Adds SlackClient.deleteMessage as a thin wrapper around chat.delete (scope chat:write is already on the bot). 5 unit tests cover the eyes-anchor persistence contract.
`/stop` interrupts the agent's current turn by sending Escape to the
tmux session. Claude's transcript ends mid-tool-use (stop_reason:
tool_use) and Codex never reaches task_complete, so neither the
real-time post loop nor the restore-time turn-ended detector sees a
terminal marker. Without an explicit clear the working pill keeps
refreshing on the interrupted turn's anchor forever and the 👀 ack
(if a relay was in-flight when the operator killed the turn) orphans
in the channel.
After the Esc lands and the announce posts, clear the active pill via
setStatus("") + dropActivePill, and call consumePendingEyes so any
in-flight ack is deleted too. The agent really is stopped after this;
the channel state now matches.
The eyes ack exists to give the channel a "received" beat in the cold- start gap before the agent's first reply. When the agent is already working, that gap doesn't exist — the existing pill on its ongoing text post already says it's on it. Posting an extra 👀 on top of that produced two pills at once (one on the ack, one on the current narrative anchor) and the ack stuck around because no fresh text post comes in to consume it. Skip the ack post when `active.has(slug)` — the agent has a live pill already, so the only thing we'd add is noise.
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.
Summary
tmux's anonymous paste buffer is a server-wide singleton, so two concurrent processes calling
pasteTmuxPromptagainst different sessions race on the shared buffer: the secondset-bufferclobbers the first's contents before either side gets topaste-buffer.Visible symptom: the daily agent nudges have been swapping prompts when they fire on the same minute. On Monday 06:00 UTC the dependabot-scout pane received the changelog-scribe prompt and vice versa; on Friday the same swap surfaced for the brief window where changelog-scribe was still firing daily (its
schedule_day = "Mon"was being dropped by the saas root variable schema until langwatch-saas#565 landed).Fix
Every
set-buffer/paste-bufferpair now uses akc-<pid>-<seq>named buffer pluspaste-buffer -dto delete it after the paste:pidkeeps sibling processes from colliding (the original race).cli.test.ts).Covers
pasteTmuxPrompt,pasteTmuxText,scheduleTmuxPrompt, and the two paste steps insidescheduleTmuxSelfCompact.Regression test
e2e-tmux.test.tsgains a five-iteration concurrent regression: twokanban sendprocesses paste to two different sessions in parallel, and the test asserts each pane received its own text and never the other's. With the previous anonymous-buffer code path this test failed intermittently; with the named-buffer fix it passes all five iterations every run.Test plan
systemctl start agent-nudge-dependabot-scout.service agent-nudge-changelog-scribe.serviceand confirm the prompts land in their own channels.