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

+25 -3
View File
@@ -13,6 +13,7 @@ import {
handleAfterToolCall,
handleTransformContext,
type ActiveRunContext,
type ActiveRunLifecycleState,
} from "./hook-handlers";
import type {
AfterToolCallRpcPayload,
@@ -79,14 +80,34 @@ export class RuntimeBridge {
/** Returns the active run context for a given agent, if any. Used by
* main-side services (inbox dispatcher) to decide whether a new inbox
* row should route to `agent.steer()` instead of waiting for the next
* `prepareRun` cycle. */
findActiveRunForAgent(agentId: string): ActiveRunContext | null {
* `prepareRun` cycle. When `requireSteerReady` is set, only runs whose
* worker-side Agent is actually subscribed (`agent_active`) are
* returned — this avoids steering into `preparing` or `closing` runs
* where the steer would be silently dropped. */
findActiveRunForAgent(
agentId: string,
options?: { requireSteerReady?: boolean },
): ActiveRunContext | null {
for (const ctx of this.activeRuns.values()) {
if (ctx.agentId === agentId) return ctx;
if (ctx.agentId !== agentId) continue;
if (options?.requireSteerReady && ctx.state !== "agent_active") continue;
return ctx;
}
return null;
}
/**
* Transition an active run between lifecycle phases as the worker
* publishes `run-agent-state`. Unknown run ids are ignored: the worker
* may publish state for a run that main has already evicted when a
* `run-finished` races ahead of the trailing state message.
*/
setRunAgentState(runId: string, state: ActiveRunLifecycleState): void {
const ctx = this.activeRuns.get(runId);
if (!ctx) return;
ctx.state = state;
}
private getOrCreateStore(taskSessionId: string): SqliteSessionStore {
const existing = this.sessionStores.get(taskSessionId);
if (existing) return existing;
@@ -268,6 +289,7 @@ export class RuntimeBridge {
modelId: payload.modelId,
taskSessionId: payload.taskSessionId,
workspacePath: payload.workspacePath,
state: "preparing",
});
const definition = getConnectorDefinition(payload.connectorId);
const resolved = await this.deps.credentialResolver.resolve(payload.connectorId);