History

feat: give every Hermes instance a Home thread for proactive delivery

Hermes has a first-class "home channel": the default destination for output nobody asked for at a specific address — a cron result, an agent-initiated `send_message`, a gateway lifecycle notice, a `/handoff`. The T3 plugin opted out, suppressing Hermes' "Type /sethome" nudge without designating anything. A cron job with `deliver=t3` went nowhere, `send_message` to a bare `t3` target errored, and `/handoff t3` hard-failed. Each enrolled instance now gets one Home thread, pinned first in its agent project, fully conversational, undeletable, created automatically. Protocol v3. `connection.hello` gains `role: "gateway" | "delivery"`; `connection.accepted` carries `homeThreadId`. Two new frames: `home.deliver` and `home.deliver.ack`. Designation converges on read, like agent projects — a precondition every handshake runs rather than an event one caller owns, so an instance enrolled before this shipped, or one whose thread creation failed once, heals on the next connect. It lives in the instance's settings blob beside `connectorUrl` and `revoked`; the plugin's `T3_HOME_CHANNEL` is a cache of it, reconciled every handshake. The existence check deliberately ignores archive state. Every other thread read filters `archived_at IS NULL`, which is right for "what should the user see" and wrong for "does this row exist" — an archived Home read as deleted and the self-healing path replaced it, stranding the history in a thread the user had merely parked. A delivery to an archived Home now un-archives it, the same way incoming activity wakes a settled or snoozed thread. Deliveries bypass the turn machinery entirely. One may arrive while the home thread has a live user turn, so it appends as a turnless assistant message carrying `notification` provenance rather than joining the turn — attributing it would fold it away behind that turn's "Worked for …". Everything except a lifecycle notice un-settles the thread and pushes; lifecycle notices land quietly. Idempotency is load-bearing, because the plugin's durable queue retries anything it has not seen acked. T3 dedupes on `deliveryId` against `projection_thread_messages` before dispatching — the decider's own scan sees the in-memory read model, which is built with `messages: []`, so it cannot catch a replay after a restart. The ack is sent only after the write lands. An out-of-process cron run dials in with `role: "delivery"`: authenticated identically, but never registered as the instance's primary connection, so it cannot displace a live gateway socket or answer its pending requests. Plugin side, the proactive branch in `send()` gates on provenance rather than turn absence. The naive rule — "no active turn, so deliver" — would let a notify-stamped cron delivery complete a user's live turn with cron output as its answer. A bare session-key mismatch is not enough either, since `HERMES_SESSION_KEY` resolves empty whenever the context did not propagate; that would tear a genuine reply out of its turn. Bypassing a live turn requires positively identified cron/lifecycle/handoff provenance.

Ben Davis committed 3 months ago

Commit 784fcb71 parent 5bfb90d73 files · +5908 −120Browse files

Showing the changes to apps/server/src/project/ProjectSetupScriptRunner.test.ts (1 of 73 files). Show all

Unified · Split

apps/server/src/project/ProjectSetupScriptRunner.test.ts+1−0
@@ -44,6 +44,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => · 43 unmodified lines
4444 getThreadCheckpointContext: () => Effect.die("unused"),
4545 getFullThreadDiffContext: () => Effect.die("unused"),
4646 getThreadShellById: () => Effect.die("unused"),
47+ getThreadArchiveStateById: () => Effect.die("unused"),
4748 getThreadDetailById: () => Effect.die("unused"),
4849 getThreadDetailSnapshot: () => Effect.die("unused"),
4950 });