feat(messaging): route messages to live runs via pi steer

Incoming messages (human → agent, agent → agent, channel broadcast)
persisted to the inbox and were only projected into the recipient's
transcript on the next `prepareRun`. When the recipient was already
running, every message waited for the next wake — a Slack-like
system operating at heartbeat cadence, not in real time.

`RuntimeMessageInjector` closes the gap: it looks up the recipient's
active run through `RuntimeBridge.findActiveRunForAgent`, projects
the persisted `AgentMessageRecord` via the existing inbox-projection
helper, and posts a `steer-run` ParentMessage into the worker. The
worker's runner enqueues the projection on the Agent's steering
queue; pi drains it at the next turn boundary and emits the usual
`message_end` → `persistMessage` sequence, so the session store
shadow is preserved without double-writing.

`dispatchAgentMessage` gains an optional `injectRuntimeMessage` hook
used by every API-side dispatch. Injection succeeds → we mark the
inbox row delivered and skip `wakeAgentIfPossible`. Injection fails
(no active run) → the legacy inbox + wake path still fires, so
sleeping agents continue to receive their messages on the next turn.

The injector is owned by the service graph alongside `workerService`
and `piRuntimeRef`, which gives it both the worker postMessage
channel and the RuntimeBridge active-run map without additional
coupling. Internal dispatch call sites (sweepStaleReviews,
automation actions) leave the hook unset; those system messages
target idle agents by design and do not need the runtime path.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 00:58:39 -04:00
1 parent 795008f56b
commit 5ee3d1394a
7 files changed
+151 -2

No files matched your search

+15
View File
@@ -6,6 +6,7 @@ import { BrowserManager } from "./browser-manager";
import type { DesktopResult } from "@shared/contracts";
import { createConnectorHealthService } from "./connector-health-service";
import { createPiRuntimeServices, type PiRuntimeServices } from "./service-graph-pi-runtime";
import { RuntimeMessageInjector } from "./agent-runtime";
import type { DesktopEvent } from "@shared/types";
import type { NotificationOptions } from "./notification-service";
import type { createAutomationService } from "./automation-service";
@@ -98,6 +99,7 @@ export function createServiceGraph(deps: ServiceGraphDependencies) {
let updaterService: ReturnType<typeof createUpdaterService> | null = null;
let appLifecycleService: ReturnType<typeof createLifecycle> | null = null;
let leaf: ReturnType<typeof createLeafServices> | null = null;
let runtimeMessageInjector: RuntimeMessageInjector | null = null;
const piRuntimeRef: { current: PiRuntimeServices | null } = { current: null };
// Late-bound slots resolve circular construction: worker needs orchestration
@@ -171,6 +173,17 @@ export function createServiceGraph(deps: ServiceGraphDependencies) {
piRuntimeRef,
});
runtimeMessageInjector = new RuntimeMessageInjector({
db,
runtimeBridge: piRuntimeRef.current.runtimeBridge,
postToWorker: (message) => {
const worker = workerService?.getWorker();
if (!worker) throw new Error("Worker is not initialised — cannot deliver steer message.");
worker.postMessage(message);
},
logger,
});
orchestrationService = buildOrchestrationService({
db,
logger,
@@ -233,6 +246,7 @@ export function createServiceGraph(deps: ServiceGraphDependencies) {
orchestrationService = null;
updaterService = null;
appLifecycleService = null;
runtimeMessageInjector = null;
leaf = null;
},
lateBindings: [
@@ -259,5 +273,6 @@ export function createServiceGraph(deps: ServiceGraphDependencies) {
getOrCreateBudgetEnforcementService: () => requireLeaf().budgetEnforcementService,
getOrCreateSessionManagementService: () => requireLeaf().sessionManagementService,
getPiRuntime: () => piRuntimeRef.current,
getRuntimeMessageInjector: () => runtimeMessageInjector,
};
}