Gate inbox delivery on worker-side steer ACK

Replace the fire-and-forget steer with a correlated ACK round-trip so main
only marks an inbox row delivered once the worker confirms the message
landed on the live Agent. Steer against a run that is still in the
`preparing` window or has already started `closing` now falls back to the
wake + inbox projection path instead of being silently dropped.

Worker publishes `run-agent-state` transitions at Agent construction and
teardown; main tracks these on `ActiveRunContext.state` and refuses to
steer runs that are not `agent_active`. A rejected or timed-out ACK
triggers `wakeAgentIfPossible` so the next `prepareRun` replays the row.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 14:38:04 -04:00
1 parent ad9205fcc9
commit e917c614c9
12 files changed
+264 -57

No files matched your search

@@ -1,53 +1,86 @@
/**
* Runtime message injector — the inward half of the Slack-like
* messaging system. When an agent has an active pi run, incoming
* direct / channel / mention messages must reach the agent *during*
* the loop, not on the next heartbeat. Pre-Task-3 the only delivery
* path was a batched `prepareRun` projection, so any message that
* arrived while the recipient was working sat dark until the next
* wake.
* Runtime message injector — the inward half of the Slack-like messaging
* system. When an agent has an active pi run, incoming direct, channel,
* or mention messages must reach the agent during the loop rather than
* on the next heartbeat. Pre-injector, the only delivery path was the
* batched `prepareRun` projection, so any message that arrived while the
* recipient was running sat dark until the next wake.
*
* This module closes that gap by routing a projected pi AgentMessage
* into the worker's active Agent via the `steer-run` ParentMessage.
* Pi's steering queue drains at the next turn boundary; the agent
* observes the new user turn and can react in-loop.
* Delivery is a two-step handshake:
*
* Delivery is best-effort and marked as delivered upon post. The
* session-store shadow still writes via the normal pi message_end
* event so the transcript remains durable. Callers fall back to the
* existing inbox+wake path when `tryInject` returns false.
* main → worker steer-run { correlationId, runId, message }
* worker → main steer-ack { correlationId, accepted }
*
* The worker's `runner.steer()` is the single authority for whether the
* message reached the live Agent. Main holds the inbox `delivered_at`
* write until the ACK lands: a positive ACK commits the write; a rejected
* or timed-out ACK leaves the row unread and re-triggers the recipient's
* wake, so the next `prepareRun` picks the message up via the regular
* inbox projection. Either way the message is observed exactly once.
*
* The injector short-circuits when the active run is not in
* `agent_active` — `preparing` and `closing` windows cannot accept a
* steer — and the caller falls back to the wake + inbox flow directly.
*/
import { randomUUID } from "node:crypto";
import type { AppDatabase } from "@main/database";
import type { AgentMessageRecord } from "@shared/types";
import type { ParentMessage } from "@main/worker/worker-helpers";
import { projectInboxMessageToPi } from "./inbox-projection";
import type { RuntimeBridge } from "./runtime-bridge";
import type { ParentMessage } from "@main/worker/worker-helpers";
export interface RuntimeMessageInjectorDeps {
db: AppDatabase;
runtimeBridge: RuntimeBridge;
postToWorker: (message: ParentMessage) => void;
/**
* Lazy wake hook. The injector is instantiated before orchestration so
* the wake function has to be resolved per-call; returning `null` means
* orchestration is not yet online, in which case the caller will have
* handled the wake path already.
*/
getWakeAgent: () => ((agentId: string, companyId: string, trigger: string) => string | null) | null;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
};
steerAckTimeoutMs?: number;
}
interface PendingSteer {
recipientAgentId: string;
companyId: string;
messageId: string;
timer: ReturnType<typeof setTimeout>;
}
const DEFAULT_STEER_ACK_TIMEOUT_MS = 5_000;
export class RuntimeMessageInjector {
constructor(private readonly deps: RuntimeMessageInjectorDeps) {}
private readonly pending = new Map<string, PendingSteer>();
private readonly ackTimeoutMs: number;
constructor(private readonly deps: RuntimeMessageInjectorDeps) {
this.ackTimeoutMs = deps.steerAckTimeoutMs ?? DEFAULT_STEER_ACK_TIMEOUT_MS;
}
/**
* Attempts to deliver a persisted inbox message into the recipient's
* live pi run. Returns true when the message was dispatched to the
* worker, false when no active run exists for the recipient.
* live pi run. Returns true when the steer was dispatched to the worker
* with a pending ACK — callers must skip the synchronous wake fallback
* for this recipient because the injector now owns the message's fate:
* it will either mark the row delivered on a positive ACK or trigger a
* wake on a negative/timed-out ACK.
*
* The caller owns the "post-delivery" decision: on true, it should
* skip the regular `wakeAgentIfPossible` path for this recipient; on
* false, it falls back to the legacy inbox+wake flow.
* Returns false when no steer was dispatched (no active run, cross-
* company mismatch, or the run is not yet `agent_active`). Callers then
* fall through to the legacy inbox + wake flow immediately.
*/
tryInject(recipientAgentId: string, companyId: string, message: AgentMessageRecord): boolean {
const ctx = this.deps.runtimeBridge.findActiveRunForAgent(recipientAgentId);
const ctx = this.deps.runtimeBridge.findActiveRunForAgent(recipientAgentId, {
requireSteerReady: true,
});
if (!ctx) return false;
if (ctx.companyId !== companyId) {
this.deps.logger.warn(
@@ -59,9 +92,15 @@ export class RuntimeMessageInjector {
const projected = projectInboxMessageToPi(message, {
getAgentName: (id) => this.resolveAgentName(id),
});
const correlationId = randomUUID();
try {
this.deps.postToWorker({ type: "steer-run", runId: ctx.runId, message: projected });
this.deps.postToWorker({
type: "steer-run",
runId: ctx.runId,
correlationId,
message: projected,
});
} catch (error) {
this.deps.logger.warn(
`[injector] postToWorker steer-run failed for run ${ctx.runId}: ${error instanceof Error ? error.message : String(error)}`,
@@ -69,14 +108,65 @@ export class RuntimeMessageInjector {
return false;
}
const timer = setTimeout(() => {
this.resolvePending(correlationId, false, "timeout");
}, this.ackTimeoutMs);
if (typeof timer.unref === "function") timer.unref();
this.pending.set(correlationId, {
recipientAgentId,
companyId,
messageId: message.id,
timer,
});
return true;
}
/**
* Routed from `worker-message-handler` on every `steer-ack`. Commits
* the inbox `delivered_at` write when the worker confirms the steer
* reached the live Agent; otherwise keeps the row unread and asks
* orchestration to wake the recipient so the next `prepareRun` picks
* the message up through the regular inbox projection.
*/
handleSteerAck(correlationId: string, accepted: boolean): void {
this.resolvePending(correlationId, accepted, "worker_ack");
}
private resolvePending(
correlationId: string,
accepted: boolean,
source: "worker_ack" | "timeout",
): void {
const entry = this.pending.get(correlationId);
if (!entry) return;
clearTimeout(entry.timer);
this.pending.delete(correlationId);
if (accepted) {
try {
this.deps.db.markAgentInboxDelivered(entry.recipientAgentId, [entry.messageId]);
} catch (error) {
this.deps.logger.warn(
`[injector] markAgentInboxDelivered failed for ${entry.messageId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
return;
}
this.deps.logger.warn(
`[injector] steer rejected (${source}) for message ${entry.messageId} to agent ${entry.recipientAgentId}; falling back to wake + inbox projection`,
);
const wake = this.deps.getWakeAgent();
if (!wake) return;
try {
this.deps.db.markAgentInboxDelivered(recipientAgentId, [message.id]);
wake(entry.recipientAgentId, entry.companyId, "message");
} catch (error) {
this.deps.logger.warn(
`[injector] markAgentInboxDelivered failed for ${message.id}: ${error instanceof Error ? error.message : String(error)}`,
`[injector] wake fallback failed for agent ${entry.recipientAgentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
return true;
}
private resolveAgentName(agentId: string): string {