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:
1 parent
ad9205fcc9
commit
e917c614c9
12 files changed
+264
-57
No files matched your search
@@ -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") {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user