Consolidate the two runtime messaging modules under src/main/messaging/. Storage stays in database/messages.ts — that is the SQL layer and belongs with the other DB modules; messaging/ covers the dispatch/normalize runtime around it.
- message-dispatch.ts -> messaging/dispatch.ts
- message-normalize.ts -> messaging/normalize.ts
- add messaging/index.ts barrel for future consumers (existing call sites import the concrete module path directly)
- 10 call sites updated across src/main and tests
The operator's mental model: a chat is a room, @X means "X takes the
turn", unmentioned participants keep working. Two separate gaps were
letting 张工程师 answer when the user wrote "@CEO 更新一下进度" inside
their Board↔张工程师 chat:
* `resolveMessageWakeTargets` always woke the DM recipient, regardless
of who the body @-mentioned. CEO woke too, but 张工程师 — the
addressed party — replied first and CEO's busy-queued wake arrived
after the operator had already taken the earlier reply as "the
answer". Explicit mentions now shadow the implicit DM recipient:
with mentions, only the named agents wake; the recipient still sees
the message in their inbox, they just aren't obliged to answer.
No mentions → fall back to the historical "DM wakes the recipient"
contract so every existing flow is untouched.
* `channel-utils` keyed each chat by `sort(fromAgent, toAgent)`. CEO's
reply with `toAgentId=__board__, parentMessageId=<original>` landed
in a fresh Board↔CEO pane — the exact "new session" the operator
complained about. Walk `parentMessageId` back to the root of the
reply chain and use the root's participant pair: every reply in a
thread now collapses into the chat the sender originally opened,
cycle-guarded against malformed parent pointers.
Pinned by a new unit test: DM to CTO with "@CEO 更新一下进度。" wakes
only CEO while keeping the message visible to both CTO's and CEO's
inbox.
A DM addressed to one agent that @-mentions another was silently
dropping the second agent out of the conversation:
* `dispatchAgentMessage` added the mentioned agent to the wake set,
so CEO's heartbeat fired — but
* `listAgentInboxUnread` only surfaced messages where the current
agent was `to_agent_id` or sat in a matching channel/project/
department. Mentions were invisible, so CEO's next turn saw nothing
new and returned silently. From the operator's chair this looked
exactly like "CEO ignored me; 张工程师 answered instead".
* `findMentionedAgentIds` had a latent bug on top: its unique-alias
filter used `ids.length === 1`, but a single agent whose name
matched its role registered itself twice, tripped the filter, and
every `@CEO` in a scaffold with role === name was a no-op. Once
inbox visibility was fixed the bug became load-bearing.
Root-cause fixes:
* New migration v48 adds `agent_message_mentions(message_id, agent_id,
company_id, created_at)` so mention fan-out is a first-class join.
* `sendAgentMessage` now takes the resolved mention set and writes
the row + mentions in a single transaction — a crash between the
two writes can no longer leave a message visible to the recipient
but invisible to the mentioned agent.
* `dispatchAgentMessage` computes the mention set exactly once via
`resolveMentionedAgentIds` and hands the same list to both the DB
write and `resolveMessageWakeTargets`, so persistence and wake
stay in lockstep.
* `listAgentInboxUnread` and `listAgentMessages` gain an `id IN
(select message_id from agent_message_mentions …)` clause so the
mentioned agent's inbox actually contains the message that woke it.
* `findMentionedAgentIds` now stores per-alias agent IDs in a `Set`
and filters on `ids.size === 1`, dropping the self-duplication
bug while preserving the "refuse ambiguous aliases" semantics.
* Company teardown drops `agent_message_mentions` alongside the
existing `agent_message_reads` cleanup so deletes stay clean.
Tests pin the new behaviour: a DM with a `@CEO` mention reaches
CEO's inbox and both agents get woken, while a DM whose recipient
is `__board__` never attempts to wake the human sentinel.
"Company" channel fell through to `listTopLevelAgentIds`, so a
broadcast on the most ambient-sounding channel in the product only
reached agents with `reports_to IS NULL`. The label said company;
the semantics said board-only. The rest of the org never knew a
message existed.
Add `listCompanyBroadcastAgentIds` — returns every non-terminated,
non-paused, non-pending agent for the company — and wire it through
the `company` case in `resolveMessageWakeTargets`. Board-only
fan-outs are still reachable: the `incident` channel keeps the
broader pingable set (paused-by-budget included) and direct DMs to
top-level agents work as before.
The prompt doc catches up to the runtime: company is explicitly
framed as all-hands, with board-only guidance redirected to DMs or
incidents. The message-dispatch unit test asserts the new fan-out.
The earlier sweep cleaned the hot path (worker/runtime/automation/api-
server/ipc core — 59 sites). This pass finishes the deferred long tail:
main/* services, database/* CRUD modules, api-server/* routes, ipc-*
handlers, thread-integration, automation helpers, browser, skills.
Default strategy: catch (error) { logger.warn("[module] context:", ...) }
— preserve current behavior, make the failure visible instead of
silently hidden. Widened 3 logger interface signatures to include warn
where callers only passed info/error previously (thread-helpers,
host-environment-service, ipc-domain-common).
Legitimate boundaries left intact: JSON.parse on untrusted input, DNS
resolver no-records paths, explicit safe* helpers (safeJson, parseJson,
parseTriggerDetail), URL-parse predicates, and `<unserialisable>`
fallbacks inside the prompt-builder summarisers. Each kept catch now
either has a reason in context or is a documented safe* helper.
Tests adjusted only where test-constructed no-op loggers needed a warn
stub (message-dispatch, company-mode-switch, ipc-agent-graph-handlers).
With the earlier pass, AgentCompany no longer has silently-swallowing
catch blocks in main-process or renderer business code.