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

+7 -5
View File
@@ -129,11 +129,13 @@ parentPort.on("message", (event) => {
return;
}
if (message.type === "steer-run") {
runner.steer(message.runId, message.message);
return;
}
if (message.type === "followup-run") {
runner.followUp(message.runId, message.message);
const accepted = runner.steer(message.runId, message.message);
post({
type: "steer-ack",
runId: message.runId,
correlationId: message.correlationId,
accepted,
});
return;
}
if (message.type === "rpc-response") {
+16 -7
View File
@@ -24,17 +24,24 @@ export interface RunJob {
}
/**
* Parent → worker control-plane messages. `steer-run` and `followup-run`
* are the inward half of pi's mid-run injection contract: the worker owns
* the Agent instance, main owns the inbox DB, so main posts the enqueued
* user message here and the worker calls `agent.steer()` / `agent.followUp()`.
* Parent → worker control-plane messages. `steer-run` is the inward half
* of pi's mid-run injection contract: the worker owns the Agent instance,
* main owns the inbox DB, so main posts the enqueued user message here and
* the worker calls `agent.steer()`.
*
* `correlationId` pairs each steer with a `steer-ack` child reply so main
* can hold the "inbox delivered" write until the worker has queued the
* message onto the live Agent. Without the ACK, a steer that lost the
* race against run teardown would be silently dropped after main already
* marked the row delivered.
*/
export type RunAgentLifecycleState = "agent_active" | "closing";
export type ParentMessage =
| { type: "enqueue-run"; payload: RunJob }
| { type: "cancel-run"; runId: string }
| { type: "shutdown" }
| { type: "steer-run"; runId: string; message: AgentMessage }
| { type: "followup-run"; runId: string; message: AgentMessage }
| { type: "steer-run"; runId: string; correlationId: string; message: AgentMessage }
| { type: "rpc-response"; rpcId: string; response: { ok: true; result: unknown } | { ok: false; error: string } };
export type ChildMessage =
@@ -62,7 +69,9 @@ export type ChildMessage =
emittedAt: string;
event: { type: string } & Record<string, unknown>;
}
| { type: "rpc-request"; rpcId: string; runId: string; method: string; payload: unknown };
| { type: "rpc-request"; rpcId: string; runId: string; method: string; payload: unknown }
| { type: "run-agent-state"; runId: string; state: RunAgentLifecycleState }
| { type: "steer-ack"; runId: string; correlationId: string; accepted: boolean };
export function buildRedactor(env: Record<string, string>): (text: string) => string {
const sensitivePatterns: string[] = [];
+13 -2
View File
@@ -1,4 +1,4 @@
import type { RuntimeBridge } from "../agent-runtime";
import type { RuntimeBridge, RuntimeMessageInjector } from "../agent-runtime";
import type { workerMessageSchema } from "./worker-types";
import { handleRunLog } from "./handlers/run-log-handler";
import { handleRunStatus } from "./handlers/run-status-handler";
@@ -8,7 +8,10 @@ import type { HandlerContext, HandlerDeps } from "./handlers/handler-context";
export async function handleWorkerMessage(
message: unknown,
worker: ReturnType<typeof import("electron").utilityProcess.fork> | null,
deps: HandlerDeps & { getRuntimeBridge?: () => RuntimeBridge | null },
deps: HandlerDeps & {
getRuntimeBridge?: () => RuntimeBridge | null;
getRuntimeMessageInjector?: () => RuntimeMessageInjector | null;
},
workerMessageSchemaRef: typeof workerMessageSchema,
destroyed: boolean,
MAX_RETRY_ATTEMPTS: number,
@@ -83,5 +86,13 @@ export async function handleWorkerMessage(
}
return;
}
case "run-agent-state": {
deps.getRuntimeBridge?.()?.setRunAgentState(event.runId, event.state);
return;
}
case "steer-ack": {
deps.getRuntimeMessageInjector?.()?.handleSteerAck(event.correlationId, event.accepted);
return;
}
}
}
+15 -11
View File
@@ -28,8 +28,10 @@ interface RunnerDeps {
* - Builds an `Agent` via `createPiAgent` with the full Pi contract.
* - Streams every pi `AgentEvent` into the worker → main pipe.
* - Persists each `message_end` message through `persistMessage` RPC.
* - Exposes `steer` / `followUp` to the worker entry so mid-run
* injections from main land on the in-process Agent instance.
* - Exposes `steer` to the worker entry so mid-run injections from
* main land on the in-process Agent instance.
* - Publishes `run-agent-state` transitions so main can tell whether
* an active run is actually ready to accept steer messages.
*/
export class WorkerRunner {
private readonly abortControllers = new Map<string, AbortController>();
@@ -43,7 +45,15 @@ export class WorkerRunner {
if (controller) controller.abort();
}
/** Enqueue a steering message onto the active Agent for this run. */
/**
* Enqueue a steering message onto the active Agent for this run. Returns
* true iff the Agent instance exists in `activeAgents` and the message
* was appended to its steering queue. A false return means the run has
* either not finished construction (pre-`agent_active`) or has already
* torn down (post-`closing`); callers must treat the steer as rejected
* so main can hold the inbox `delivered_at` write and fall back to the
* inbox + wake path instead.
*/
steer(runId: string, message: AgentMessage): boolean {
const agent = this.activeAgents.get(runId);
if (!agent) return false;
@@ -51,14 +61,6 @@ export class WorkerRunner {
return true;
}
/** Enqueue a follow-up message onto the active Agent for this run. */
followUp(runId: string, message: AgentMessage): boolean {
const agent = this.activeAgents.get(runId);
if (!agent) return false;
agent.followUp(message);
return true;
}
/**
* Aborts every in-flight run and returns a promise that resolves once
* each `run()` has settled (success, failure, or abort). Callers (the
@@ -150,6 +152,7 @@ export class WorkerRunner {
rpc,
});
this.activeAgents.set(job.runId, agent);
post({ type: "run-agent-state", runId: job.runId, state: "agent_active" });
const unsubscribe = agent.subscribe(async (event) => {
const timestamp = new Date().toISOString();
@@ -192,6 +195,7 @@ export class WorkerRunner {
abortController.signal.addEventListener("abort", () => agent.abort(), { once: true });
await runPromise;
} finally {
post({ type: "run-agent-state", runId: job.runId, state: "closing" });
unsubscribe();
this.activeAgents.delete(job.runId);
}
+17 -1
View File
@@ -4,7 +4,7 @@ import type { DesktopEvent, RunRecord } from "@shared/types";
import type { NotificationOptions } from "../notification-service";
import type { createBudgetEnforcementService } from "../budget-enforcement-service";
import type { createSessionManagementService } from "../session-management-service";
import type { RuntimeBridge } from "../agent-runtime";
import type { RuntimeBridge, RuntimeMessageInjector } from "../agent-runtime";
const runStatusSchema = z.enum(["queued", "running", "succeeded", "failed", "cancelled", "timed_out", "interrupted"]);
@@ -56,6 +56,17 @@ export const workerMessageSchema = z.union([
]),
payload: z.unknown(),
}),
z.object({
type: z.literal("run-agent-state"),
runId: z.string().uuid(),
state: z.enum(["agent_active", "closing"]),
}),
z.object({
type: z.literal("steer-ack"),
runId: z.string().uuid(),
correlationId: z.string(),
accepted: z.boolean(),
}),
]);
export interface WorkerServiceDependencies {
@@ -92,4 +103,9 @@ export interface WorkerServiceDependencies {
sessionService?: ReturnType<typeof createSessionManagementService>;
/** Forwards `agent-event` from worker to renderer and routes RPC to main services. */
getRuntimeBridge?: () => RuntimeBridge | null;
/**
* Resolves the shared runtime message injector so `steer-ack` child
* messages can commit inbox delivery or trigger a wake fallback.
*/
getRuntimeMessageInjector?: () => RuntimeMessageInjector | null;
}