Skip to content

fix(mcp): room_ack clears only consumed lines, not the whole file#32

Merged
ThinkOffApp merged 1 commit into
mainfrom
fix/room-ack-consumed-only
Jul 13, 2026
Merged

fix(mcp): room_ack clears only consumed lines, not the whole file#32
ThinkOffApp merged 1 commit into
mainfrom
fix/room-ack-consumed-only

Conversation

@ThinkOffApp

Copy link
Copy Markdown
Owner

The race

The IAK MCP server's room_ack tool blanked the ENTIRE notification file (writeFileSync(notifyFile, '')). Anything the poller appended between the agent's last room_list_new read and the ack was wiped unread — silently lost.

Real incident (2026-07-08): an owner instruction ("send all 3") landed in exactly that read→ack window and sat 5 hours unseen. This class of loss has bitten twice.

The fix: ack only what was consumed

  • room_list_new now remembers the exact raw bytes it returned, per MCP session, keyed by notification file path (so a config change between calls is treated as a cold ack for the new path).
  • room_ack removes ONLY those bytes:
    • Fast path: the pollers only ever append, so the consumed content is normally still a byte prefix of the current file — drop the prefix, keep everything after it.
    • Fallback: if the file no longer starts with what was read (rotated/edited in between), consumed lines are removed by exact match with multiset semantics (one copy removed per copy read); everything else is kept.
  • Atomic write: remainder is written to a temp file in the same directory and rename(2)d over the notification file, preserving the original file mode. Readers never see a half-written file, and since the pollers re-open the path on every appendFileSync (no flock anywhere in the repo — checked), appends after the rename land in the new file.
  • Caller contract preserved: agents call room_list_new then room_ack with no args — unchanged. A cold room_ack (no room_list_new recorded this session) keeps the legacy clear-all behavior so existing callers don't break, and the tool response nudges toward read-then-ack.
  • room_ack now reports counts: how many read lines were acked and how many late arrivals were preserved (with a hint to call room_list_new again).

Watcher note

The rename replaces the inode, so tail -F-style watchers see it as a rotation and re-open; the IAK Monitor/hook patterns re-read the whole file each time anyway, so surviving lines still trigger delivery.

Remaining window (documented, not hidden)

Without any locking protocol on the poller side there is still a microseconds-wide read→rename window inside a single ack call, versus the old minutes-to-hours read→think→truncate window. Adding a lock protocol shared with the pollers would be the follow-up if that ever matters.

Tests

  • Pure-function coverage for removeConsumedNotifications: prefix drop, drain-to-empty, empty-read-consumes-nothing, rewritten-file fallback, duplicate-line multiset semantics.
  • File-level regression for ackNotificationFile: poller append lands between read and ack and MUST survive; legacy null (cold ack) clear-all; missing-file no-op.
  • End-to-end stdio regression through bin/iak-mcp.mjs: room_list_new → simulated poller append → room_ack → late line survives on disk and the second read/ack cycle drains it.

npm test: 110/110 pass (was 100; +10 new, no regressions).

Review

@codexmb — requesting adversarial review per the cross-model PR review rule before merge. Particular scrutiny welcome on: the prefix-vs-multiset fallback choice, the cold-ack legacy path, and whether the rename interacts badly with any watcher I missed.

🤖 Generated with Claude Code

room_ack blanked the entire notification file, so anything the poller
appended between the agent's last room_list_new read and the ack was
wiped unread (2026-07-08 incident: an owner instruction sat 5 hours
unseen in that window).

Fix: room_list_new remembers the exact bytes it returned (per-session,
keyed by notification file); room_ack removes only those bytes.

  * Fast path: pollers only append, so the consumed content is a byte
    prefix of the current file — drop the prefix, keep the rest.
  * Fallback: if the file was rewritten in between, remove consumed
    lines by exact match (multiset semantics).
  * Writes go through temp-file + rename (mode preserved) so the
    appending poller and file readers never see a half-written file;
    appends after the rename land in the new file.
  * No prior room_list_new this session → legacy clear-all is kept so
    the existing no-args caller contract still works; the response
    nudges callers toward read-then-ack.
  * room_ack now reports how many lines were consumed and how many
    late arrivals were preserved.

Tests: pure-function coverage for prefix/rewrite/multiset/empty-read
cases, file-level regression (append lands between read and ack and
must survive), and an end-to-end stdio regression through bin/iak-mcp.mjs.

Co-Authored-By: Claude Fable 5 <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: 8a677c007d

ℹ️ 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 src/mcp-server.mjs
Comment on lines +259 to +261
if (line.trim() && (pending.get(line) || 0) > 0) {
pending.set(line, pending.get(line) - 1);
removed += 1;

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 Avoid deleting duplicate unread lines in fallback

In the rotated/edited fallback, this removes the first current line whose text matches a previously read line, even if the consumed copy is no longer in the file and the matching line is a new unread duplicate. The pollers format notifications with second-resolution timestamps and truncated bodies, so repeated messages like the same sender/body in the same second can be byte-identical; after a rotation or manual rewrite this path would silently ack that late duplicate instead of preserving it. Consider keeping an offset/generation invariant for fallback or avoiding destructive exact-match removal when the consumed prefix is gone.

Useful? React with 👍 / 👎.

@ThinkOffApp ThinkOffApp merged commit af69be6 into main Jul 13, 2026
3 checks passed
ThinkOffApp added a commit that referenced this pull request Jul 14, 2026
P1 (codex, #33): the bootstrap told agents to truncate the whole
notification file - recreating the exact read-vs-append race #32 just
fixed. Instructions now direct agents to the consumed-only room_ack MCP
tool, with raw-clear only as a no-MCP fallback plus an immediate re-check.
P2: install.sh skipped hook wiring entirely when ~/.claude/settings.json
did not exist yet - fresh one-shot installs now get a minimal file created
so self-arming works on day one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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