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

-23
View File
@@ -339,28 +339,5 @@ export function buildMessageMethods(
});
},
/**
* Transactionally mark a batch of messages as delivered to `agentId`.
* - Direct messages: sets agent_messages.read_at = now() if currently null.
* - All messages (direct + channel): insert into agent_message_reads with
* ON CONFLICT DO NOTHING so reprocessing is idempotent.
*/
markAgentInboxDelivered(agentId: string, messageIds: string[]): void {
if (messageIds.length === 0) return;
const ts = nowIso();
deps.transaction(() => {
const db = getDb();
const directUpdate = db.prepare(
"update agent_messages set read_at = ? where id = ? and to_agent_id = ? and read_at is null",
);
const receiptInsert = db.prepare(
"insert into agent_message_reads (message_id, agent_id, read_at) values (?, ?, ?) on conflict(message_id, agent_id) do nothing",
);
for (const id of messageIds) {
directUpdate.run(ts, id, agentId);
receiptInsert.run(id, agentId, ts);
}
});
},
};
}