Stop welding "delivered" onto "read"

The inbox projection used to call `markAgentInboxDelivered` after
appending each message to the agent's session shadow. That helper
sets `agent_messages.read_at = now()` for direct messages and
inserts into `agent_message_reads` for channel messages — the SAME
flags `listAgentInboxUnread` and the public `?unreadOnly=true` API
use to define "unread". So the moment we put a message into the
recipient's transcript, the recipient's own API call returned
nothing pending. The IC prompt told the agent to "respond to ALL
unread messages"; the API said zero unread; the agent posted a
status comment instead of replying. Every silent-employee report
traces back here.

Replay safety lives entirely on `agent_message_projections`
(installed in v46) — that ledger is keyed by `(message_id,
session_id)` and `claimProjection` already short-circuits a re-run
without touching `read_at` at all. The two concepts are now
properly separated:

  - "delivered" (we put it in front of the agent) — tracked by
    the projection ledger, owned by the runtime bridge;
  - "read" (the agent acknowledged it) — tracked by `read_at` and
    `agent_message_reads`, only flipped by the explicit POST
    /messages/{id}/read endpoint the agent calls itself.

`markAgentInboxDelivered` is removed because there is no remaining
caller and its semantics were the bug — keeping it around would
just invite the same mistake again.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 18:34:49 -04:00
1 parent 3a72c8aa39
commit f30ec740d8
4 files changed
+22 -53

No files matched your search

+11 -14
View File
@@ -529,16 +529,17 @@ export class RuntimeBridge {
// before loading priorMessages — the recipient must observe them
// as plain user turns in its context.
//
// The projection closure is now atomic: the ledger claim, the
// session-shadow append, the thread-event write, and the
// delivered-mark all commit inside one `BEGIN IMMEDIATE`
// transaction. Pre-v46 layout let those four writes drift apart,
// so a crash before the delivered-mark replayed the same inbox
// row on the next run and double-populated the transcript. The
// `agent_message_projections` ledger is the idempotent gate —
// `claimProjection` returns false when the `(message_id,
// session_id)` pair is already recorded, so a replay skips the
// whole closure.
// Replay safety lives entirely on the `agent_message_projections`
// ledger. We deliberately do NOT touch `agent_messages.read_at`
// here even though that column exists: "we put the message into
// the agent's transcript" and "the agent has acknowledged the
// message" are two different facts, and welding them onto one
// column made every projected message immediately invisible to
// the agent's `?unreadOnly=true` API call. The agent then read
// its IC prompt ("respond to ALL unread messages"), called the
// API, got an empty list, and posted a status comment instead of
// replying. The explicit POST /messages/{id}/read is now the
// sole signal that flips `read_at`.
const store = this.getOrCreateStore(payload.taskSessionId);
const inboxRecords = this.deps.db.listAgentInboxUnread(payload.companyId, payload.agentId);
if (inboxRecords.length > 0) {
@@ -562,10 +563,6 @@ export class RuntimeBridge {
record,
);
}
this.deps.db.markAgentInboxDelivered(
payload.agentId,
inboxRecords.map((r) => r.id),
);
});
}