feat(cat): answer @cat in private messages - #767
Merged
Conversation
Tagging the Cat in a conversation now gets an answer in that conversation, from the Cat's own account. The shape is a queue with two payers. The write path records that a reply is owed and returns, because an LLM round trip must not sit inside the sender's POST; a worker pays the debt. The systemd timer is the DURABILITY path rather than the latency path — the write also kicks a run, so a reply normally lands in seconds, and the tick is what guarantees the question survives a process dying mid-thought. An assistant that silently drops a question is worse than one that is slow. Idempotency is the unique key on (source_type, source_id), which is what makes an at-least-once producer safe, and 23505 is therefore treated as success rather than failure. Concurrency is FOR UPDATE SKIP LOCKED inside claim_cat_mentions, so an inline run overlapping a timer tick cannot answer the same mention twice. Proven on production with two concurrent sessions: two mentions seeded, "A claimed: 1", "B claimed: 1", distinct rows claimed 2 of 2, nothing left pending, scratch table dropped. The dedupe key is the MESSAGE id, and the first draft got that wrong. It fell back to the conversation id when no message id was passed, which would have meant the first question ever asked in a conversation was the only one ever answered — every later insert colliding with the unique constraint and being read as "already queued". The parameter is now required and the fallback is gone, with a test that fails if it returns. "@cat what do you think about this?" works because "this" means the conversation. The Cat reads CAT_CONTEXT_MESSAGE_WINDOW recent messages of the thread it was tagged in and nothing else — no other conversation, no history beyond the window. That is a stated product promise about what tagging consents to, which is why the number lives in config and not in a query. The Cat STAYS in the conversation once tagged, so a follow-up needs no second tag and both people can see it is there, and it can be removed like any participant. That was one of the two open product calls; it is recorded here as an assumption and is reversible. A failure to think is still answered. CAT_FALLBACK_REPLY goes out rather than nothing, because a tag that produces silence is indistinguishable from a broken feature, and this codebase has shipped exactly that before. Layering: parse (pure) -> resolve (one query) -> note (the seam a write path calls) -> queue (durability) -> worker (dispatch) -> reply (what it says). Nothing was added to chat-orchestrator.ts or memory.ts, already 837 and 1062 lines. A new surface gains all of this by calling noteCatMention. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Tagging the Cat in a conversation now gets an answer in that conversation, from the Cat's own account.
Shape: a queue with two payers
The write path records that a reply is owed and returns — an LLM round trip must not sit inside the sender's POST. A worker pays the debt.
The systemd timer is the durability path, not the latency path: the write also kicks a run, so a reply normally lands in seconds. The tick is what guarantees the question survives a process dying mid-thought. An assistant that silently drops a question is worse than one that's slow.
UNIQUE (source_type, source_id);23505treated as success, which is what makes an at-least-once producer safeFOR UPDATE SKIP LOCKEDinclaim_cat_mentionspendinguntilMAX_ATTEMPTS, thenfailedwith the reason keptConcurrency proven on production with two simultaneous sessions:
The bug I wrote and caught
The dedupe key must be the message id. My first draft fell back to the conversation id when no message id was passed — which would have meant the first question ever asked in a conversation was the only one ever answered, every later insert colliding with the unique constraint and reading as "already queued".
The parameter is now required, the fallback is gone, and a test fails if it comes back.
"@cat what do you think about this?"
That works because "this" means the conversation. The Cat reads
CAT_CONTEXT_MESSAGE_WINDOWrecent messages of the thread it was tagged in — no other conversation, no history beyond the window. That's a stated promise about what tagging consents to, which is why the number lives in config rather than inside a query.Two product calls, taken as documented assumptions since they were open and both are reversible:
CAT_FALLBACK_REPLYgoes out rather than nothing, because a tag that produces silence is indistinguishable from a broken feature — and this codebase has shipped exactly that before.Layering
Nothing was added to
chat-orchestrator.tsormemory.ts— already 837 and 1062 lines. A new surface (wall posts, group chat) gains all of this by callingnoteCatMention.Verification
npm run verify— exit 0 (2359 tests);check:rpc-existspicked upclaim_cat_mentions, baseline still zeroBEGIN … ROLLBACK, plus the live concurrency test above🤖 Generated with Claude Code