feat: Wave C - pi-native steer/follow-up delivery
Delivery now rides pi's OWN follow-up queue when a run is live, and a carry-everything idle wake otherwise. One registry, two vehicles; claimSteers and continuation coalescing are deleted (owner rulings, 2026-09-26: follow-ups only; one queue; the wake is the only appender). THE LIVE RUN (vehicle 1): the chat-handler registers the streaming turn's agent (registerLiveAgent, next to registerTurn); the pump hands every eligible queued marker to agent.followUp() - pi polls that queue when the run would otherwise stop, injects the markers (message_start/message_end on the live wire), and CONTINUES the same run. followUpMode "all" joins every handed marker in one continuation turn. The question stopper runs before the follow-up poll, so an open quiz is never overridden - a handed marker strands at settlement and waits (today's pacing). probe-handoffs never take this path (the handoff must START the teacher's run - a record-level branch). SETTLEMENT RECONCILIATION: settleSteers(chatId, persisted) at onFinish (after unregisterLiveAgent) resolves+flips entries the run delivered and returns stranded ones to pending - the registry stays the truth; the dead agent's queued copy is abandoned. This is what makes the handoff safe: pi owns no retry for a stranded follow-up. THE IDLE WAKE (vehicle 2): waitForIdle (unchanged gates) then ONE turn carrying EVERY pending marker as its input appends - dequeue-at-fire (the Wave B regression lesson: the wake run's own follow-up poll must never see its own cargo; a 409 pushes the cargo back and retries). CLIENT: applyWireEvent now upserts non-assistant messages by id, so in-run injected markers render live instead of dropping until settle. Tests: jobs 44/44 (live handoff, settlement stranding, handoff-waits, one-wake-carries-all); chat-flow 23/23 (the double-append pin drives the real pump through the new input); session-core 28/28 (custom-role wire appends). Full suite 997/997, tsc/lint/build green. Live smoke (PORT=3004): research job delivered mid-probe - parked behind the dangling questions exactly per semantics, landed once on the open gates, teacher folded the brief in and re-dispatched; the second (good) result rendered the full 10-source brief card and the lesson continued from it. No duplicate ids anywhere in the record. Known gap flagged to owner (NOT from this change): the server accepts malformed answer events via raw API (answers without the answers[] shape) and AnsweredCard then crashes the page render - surfaced by the smoke's scripted answers, repaired by hand in the scratch record.
This commit is contained in:
+118
-133
@@ -47,7 +47,16 @@ import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-ag
|
||||
import { createTeacherTools } from "../src/agent/tools.ts";
|
||||
import { webTools } from "../src/agent/tools/web.ts";
|
||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||
import { abortJob, claimSteers, deliverJobResult, recoverStaleJobs, setJobRespawn, setTurnRunner } from "../src/server/jobs.ts";
|
||||
import {
|
||||
abortJob,
|
||||
deliverJobResult,
|
||||
registerLiveAgent,
|
||||
recoverStaleJobs,
|
||||
setJobRespawn,
|
||||
setTurnRunner,
|
||||
settleSteers,
|
||||
unregisterLiveAgent,
|
||||
} from "../src/server/jobs.ts";
|
||||
import { registerJobAbort, unregisterJobAbort } from "../src/agent/subagents/aborts.ts";
|
||||
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
|
||||
import { persistTurn } from "../src/server/turn-service.ts";
|
||||
@@ -708,9 +717,9 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
// open question.
|
||||
chatStore.saveMessages(chat.id, nativeToolMessages("teach me calc", "probe_done", "call-pd", { ok: true }));
|
||||
const handoff = buildProbeHandoffSteer(chat.id);
|
||||
let seen: { chatId: string; steer: SteerMessage } | undefined;
|
||||
let seen: { chatId: string; steers: SteerMessage[] } | undefined;
|
||||
setTurnRunner(async (cid, input) => {
|
||||
seen = { chatId: cid, steer: (input as SteerInput).steer as SteerMessage };
|
||||
seen = { chatId: cid, steers: (input as SteerInput).steers as SteerMessage[] };
|
||||
return 200;
|
||||
});
|
||||
|
||||
@@ -718,7 +727,8 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
await waitFor(() => seen !== undefined, 8000, "handoff wake fired");
|
||||
const firedHandoff = seen!;
|
||||
assert.equal(firedHandoff.chatId, chat.id);
|
||||
assert.equal(firedHandoff.steer.id, `steer_probe_${chat.id}`, "the handoff IS the wake turn's input");
|
||||
assert.equal(firedHandoff.steers.length, 1);
|
||||
assert.equal(firedHandoff.steers[0]!.id, `steer_probe_${chat.id}`, "the handoff IS the wake turn's input");
|
||||
});
|
||||
|
||||
it("a redelivered handoff dedupes via the transcript (idempotent by steer id)", async () => {
|
||||
@@ -739,9 +749,9 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
|
||||
it("delivers: fires the turn runner with the record + steer, then flips the job done", async () => {
|
||||
const { chatId, jobId, steer } = setup();
|
||||
let seen: { chatId: string; steer: SteerMessage } | undefined;
|
||||
let seen: { chatId: string; steers: SteerMessage[] } | undefined;
|
||||
setTurnRunner(async (cid, input) => {
|
||||
seen = { chatId: cid, steer: (input as SteerInput).steer as SteerMessage };
|
||||
seen = { chatId: cid, steers: (input as SteerInput).steers as SteerMessage[] };
|
||||
return 200;
|
||||
});
|
||||
|
||||
@@ -752,7 +762,8 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
assert.equal(jobStore.getJob(jobId)!.activity, "delivered");
|
||||
assert.ok(seen, "the turn runner fired");
|
||||
assert.equal(seen.chatId, chatId);
|
||||
assert.equal(seen.steer.id, steer.id, "the steer IS the wake turn's input");
|
||||
assert.equal(seen.steers.length, 1);
|
||||
assert.equal(seen.steers[0]!.id, steer.id, "the steer IS the wake turn's input");
|
||||
});
|
||||
|
||||
it("waits while a question part is pending, and fires once it clears", async () => {
|
||||
@@ -843,145 +854,119 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
|
||||
// ─── continuation coalescing (the delivery-starvation fix) ───────────────────
|
||||
|
||||
describe("claimSteers + pump duplicate-guard (continuation coalescing)", () => {
|
||||
it("claims queued undelivered steers: resolves deliver(), flips the job done, empties the queue", async () => {
|
||||
describe("Wave C: live follow-up handoff + settlement reconciliation", () => {
|
||||
/** A minimal stand-in for pi's Agent — the pump touches only followUp(). */
|
||||
function fakeAgent(): { handed: SteerMessage[]; agent: unknown } {
|
||||
const handed: SteerMessage[] = [];
|
||||
return { handed, agent: { followUp: (m: SteerMessage) => handed.push(m) } };
|
||||
}
|
||||
|
||||
it("hands queued markers to the LIVE run (pi followUp) — no wake fires, the registry waits", async () => {
|
||||
const { chatId, jobId, steer } = setup();
|
||||
const { handed, agent } = fakeAgent();
|
||||
let fired = 0;
|
||||
setTurnRunner(async () => {
|
||||
fired++;
|
||||
return 200;
|
||||
});
|
||||
|
||||
const delivery = deliverJobResult(steer);
|
||||
await delay(300); // the pump is inside waitForIdle's grace (750ms)
|
||||
const claimed = claimSteers(chatId);
|
||||
// A live turn is streaming (turn registry + live agent registered).
|
||||
assert.ok(beginTurn(chatId));
|
||||
registerLiveAgent(chatId, agent as never);
|
||||
|
||||
assert.equal(claimed.length, 1, "the queued steer is claimed");
|
||||
assert.equal(claimed[0]!.id, steer.id);
|
||||
// The handler's write-ahead persist lands the claim immediately after
|
||||
// claiming (the very next statement in handleChatRequest).
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
chatStore.saveMessages(chatId, [...record.messages, ...claimed] as never);
|
||||
await delivery; // the runner's deliver() promise resolves on claim
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "done", "job flips done at claim");
|
||||
assert.deepEqual(claimSteers(chatId), [], "queue is empty after the claim");
|
||||
await delay(800);
|
||||
assert.equal(fired, 0, "the pump never fires a wake for a claimed steer");
|
||||
});
|
||||
|
||||
it("finalizes already-delivered steers; the handler's steerDelivered filter keeps one copy (a wake raced in)", async () => {
|
||||
const { chatId, jobId, steer } = setup();
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
chatStore.saveMessages(chatId, [...record.messages, steer] as never);
|
||||
|
||||
const delivery = deliverJobResult(steer);
|
||||
await delay(100); // let the pump's top-of-loop idempotent skip run
|
||||
const claimed = claimSteers(chatId);
|
||||
// The pump itself finalizes already-delivered steers at the top of
|
||||
// its loop — the queue is empty by the time a racing turn claims.
|
||||
// (A steer that DID ride the queue gets claimed whole; the HANDLER's
|
||||
// steerDelivered filter keeps exactly one copy at append.)
|
||||
assert.equal(claimed.length, 0, "the pump finalized the delivered steer without firing a wake");
|
||||
assert.ok(steerDelivered(chatStore.getChat(chatId)!.messages, steer.id), "the record already holds it");
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "done");
|
||||
await delivery;
|
||||
});
|
||||
|
||||
it("claimSteers on a chat with no queue returns [] and touches nothing", () => {
|
||||
assert.deepEqual(claimSteers("chat_never_existed"), []);
|
||||
});
|
||||
|
||||
it("pump path: a steer coalesced into the record mid-flight is claimed, filtered at append, never double-fired (duplicate-key bug, 2026-09-18)", async () => {
|
||||
const { chatId, jobId, steer } = setup();
|
||||
// Own runner: this chat's gates are OPEN, so the pump would otherwise
|
||||
// fire whatever runner the NEXT test registers (leaked counter).
|
||||
let fired = 0;
|
||||
setTurnRunner(async () => {
|
||||
fired++;
|
||||
return 200;
|
||||
});
|
||||
// The racing turn's write-ahead ALREADY landed the steer in the record
|
||||
// while the entry is still queued — the exact window the handler
|
||||
// claims in (the pump dequeues only after the turn returns 200).
|
||||
const delivery = deliverJobResult(steer);
|
||||
await delay(300); // the pump is inside waitForIdle's grace (750ms)
|
||||
const incoming = [...chatStore.getChat(chatId)!.messages, steer];
|
||||
chatStore.saveMessages(chatId, incoming); // the racing turn's write-ahead persist
|
||||
const claimed = claimSteers(chatId);
|
||||
assert.equal(claimed.length, 1, "the queued steer is claimed — its delivery rides the turn");
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "done", "job still finalizes — the claim resolves the delivery");
|
||||
await delivery;
|
||||
assert.deepEqual(claimSteers(chatId), [], "queue is drained");
|
||||
// The handler appends only steers NOT already in the record
|
||||
// (steerDelivered) — the write-ahead keeps exactly ONE copy of the
|
||||
// steer id, and the pump's post-idle re-check reads the RECORD and
|
||||
// skips the delivered id.
|
||||
const appends = claimed.filter((c) => !steerDelivered(chatStore.getChat(chatId)!.messages, c.id));
|
||||
assert.equal(appends.length, 0, "the already-persisted steer never appends again");
|
||||
const outgoing = [...chatStore.getChat(chatId)!.messages, ...appends];
|
||||
const ids = outgoing.map((m) => m.id);
|
||||
assert.equal(ids.filter((id) => id === steer.id).length, 1, "exactly one steer copy persists");
|
||||
await delay(800); // the pump's grace elapses — its re-check sees the delivered id
|
||||
assert.equal(fired, 0, "the claimed-and-persisted steer never double-fires a wake");
|
||||
});
|
||||
|
||||
it("pump duplicate-guard: a steer coalesced into the record while the gates were closed is never double-fired", async () => {
|
||||
const { chatId, jobId, steer } = setup();
|
||||
let fired = 0;
|
||||
setTurnRunner(async () => {
|
||||
fired++;
|
||||
return 200;
|
||||
});
|
||||
|
||||
// Gates closed: a dangling question call parks the pump in waitForIdle.
|
||||
chatStore.saveMessages(chatId, [
|
||||
{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never,
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "quiz-call", name: "quiz", arguments: { question: "q", choices: ["a", "b"] } as never }],
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
]);
|
||||
const delivery = deliverJobResult(steer);
|
||||
const delivery = deliverJobResult(steer); // floats — resolves at settlement
|
||||
await waitFor(() => handed.length === 1, 8000, "marker handed to the live agent");
|
||||
await delay(1200);
|
||||
assert.equal(fired, 0, "parked behind the pending quiz");
|
||||
|
||||
// The client turn coalesces: it claims the steer and lands it in the record.
|
||||
const claimed = claimSteers(chatId);
|
||||
assert.equal(claimed.length, 1, "coalescing claims the parked steer");
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
chatStore.saveMessages(chatId, [...record.messages, steer] as never); // write-ahead persist
|
||||
|
||||
// The learner answers: the gates open — the parked pump must see the
|
||||
// delivered steer via its post-idle re-check, NOT fire a second turn.
|
||||
const answeredRecord = chatStore.getChat(chatId)!;
|
||||
chatStore.saveMessages(chatId, [
|
||||
...answeredRecord.messages,
|
||||
{
|
||||
id: "r1",
|
||||
role: "toolResult",
|
||||
toolCallId: "quiz-call",
|
||||
toolName: "quiz",
|
||||
content: [{ type: "text", text: "answered" }],
|
||||
details: { answer: "a", correct: true, explanation: "" },
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
]);
|
||||
assert.equal(fired, 0, "no wake while the run is live");
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "running", "job flips only at delivery");
|
||||
|
||||
// Settlement: the run persisted the injected marker.
|
||||
unregisterLiveAgent(chatId);
|
||||
settleSteers(chatId, [steer as never]);
|
||||
await delivery;
|
||||
await delay(1500);
|
||||
assert.equal(fired, 0, "no duplicate wake after coalescing");
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "done", "delivery resolves at settlement");
|
||||
assert.equal(fired, 0, "never needed a wake");
|
||||
endTurn(chatId);
|
||||
});
|
||||
|
||||
it("a handed marker the run never injected (stopper exit / missed poll) strands at settlement — the idle wake delivers it", async () => {
|
||||
const { chatId, jobId, steer } = setup();
|
||||
const { handed, agent } = fakeAgent();
|
||||
let fired = 0;
|
||||
setTurnRunner(async () => {
|
||||
fired++;
|
||||
return 200;
|
||||
});
|
||||
|
||||
assert.ok(beginTurn(chatId));
|
||||
registerLiveAgent(chatId, agent as never);
|
||||
|
||||
const delivery = deliverJobResult(steer);
|
||||
await waitFor(() => handed.length === 1, 8000, "handed");
|
||||
// The run settles WITHOUT the marker (question stopper exited before
|
||||
// the follow-up poll) — reconcile against the persisted truth.
|
||||
unregisterLiveAgent(chatId);
|
||||
settleSteers(chatId, []);
|
||||
endTurn(chatId);
|
||||
|
||||
// The marker returned to pending — the pump's idle wake delivers it.
|
||||
await delivery;
|
||||
assert.equal(fired, 1, "the stranded marker rode the idle wake");
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "done");
|
||||
});
|
||||
|
||||
it("a probe-handoff NEVER rides a live run — it waits the turn out and wakes", async () => {
|
||||
const chat = chatStore.createChat("Test: handoff never live");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, nativeToolMessages("teach me calc", "probe_done", "call-pd", { ok: true }));
|
||||
const handoff = buildProbeHandoffSteer(chat.id);
|
||||
const { handed, agent } = fakeAgent();
|
||||
const fired: SteerMessage[][] = [];
|
||||
setTurnRunner(async (_cid, input) => {
|
||||
fired.push((input as SteerInput).steers as SteerMessage[]);
|
||||
return 200;
|
||||
});
|
||||
|
||||
assert.ok(beginTurn(chat.id));
|
||||
registerLiveAgent(chat.id, agent as never);
|
||||
|
||||
const delivery = deliverJobResult(handoff);
|
||||
await delay(1200);
|
||||
assert.equal(handed.length, 0, "the handoff was NOT handed to the live agent");
|
||||
assert.equal(fired.length, 0, "no wake while the prober's turn streams");
|
||||
|
||||
// The prober's turn settles — now the handoff wakes the TEACHER.
|
||||
unregisterLiveAgent(chat.id);
|
||||
settleSteers(chat.id, []);
|
||||
endTurn(chat.id);
|
||||
await delivery;
|
||||
assert.equal(fired.length, 1);
|
||||
assert.equal(fired[0]![0]!.id, handoff.id, "the handoff rode the wake");
|
||||
});
|
||||
|
||||
it("the idle wake carries EVERY pending marker in ONE turn", async () => {
|
||||
const { chatId, steer } = setup();
|
||||
const job2 = jobStore.createJob({ kind: "research", chatId, toolCallId: "call-2" });
|
||||
trackJob(job2.id);
|
||||
const result: ResearchResult = { brief: "Second result.", sources: [] };
|
||||
jobStore.updateJob(job2.id, { result });
|
||||
const steer2 = buildSubagentSteer(job2, result);
|
||||
|
||||
const fired: SteerMessage[][] = [];
|
||||
setTurnRunner(async (_cid, input) => {
|
||||
fired.push((input as SteerInput).steers as SteerMessage[]);
|
||||
return 200;
|
||||
});
|
||||
|
||||
await Promise.all([deliverJobResult(steer), deliverJobResult(steer2)]);
|
||||
assert.equal(fired.length, 1, "one wake for the whole queue");
|
||||
assert.equal(fired[0]!.length, 2, "both markers ride the same turn");
|
||||
const ids = fired[0]!.map((s) => s.id).sort();
|
||||
assert.deepEqual(ids, [steer.id, steer2.id].sort());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("fails the job (error status) when the wake turn errors unrecoverably", async () => {
|
||||
const { jobId, steer } = setup();
|
||||
setTurnRunner(async () => 500);
|
||||
@@ -1238,7 +1223,7 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
||||
const { jobId } = newChatWithResearchJob("abort running job", "call-abort1");
|
||||
const fired: SteerMessage[] = [];
|
||||
setTurnRunner(async (_cid, input) => {
|
||||
fired.push((input as SteerInput).steer as SteerMessage);
|
||||
fired.push(...((input as SteerInput).steers as SteerMessage[]));
|
||||
return 200;
|
||||
});
|
||||
researchModelOverride.current = hangingModel();
|
||||
@@ -1316,7 +1301,7 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
||||
assert.ok(beginTurn(chat.id));
|
||||
const fired: SteerMessage[] = [];
|
||||
setTurnRunner(async (_cid, input) => {
|
||||
fired.push((input as SteerInput).steer as SteerMessage);
|
||||
fired.push(...((input as SteerInput).steers as SteerMessage[]));
|
||||
return 200;
|
||||
});
|
||||
void deliverJobResult(buildSubagentSteer(job, result)).catch(() => {
|
||||
|
||||
@@ -394,6 +394,46 @@ describe("send (one input event, verbatim consumption)", () => {
|
||||
await done;
|
||||
});
|
||||
|
||||
it("Wave C: markers injected mid-run (custom role, follow-up delivery) append to the tail by id", () => {
|
||||
// A never-settling fetch: the send only needs to flip the model into its
|
||||
// streaming status synchronously — its POST floats (never rejects) while
|
||||
// the injected wire events are asserted.
|
||||
const m = model({ fetch: () => new Promise<Response>(() => {}) });
|
||||
m.adoptRecord([
|
||||
{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never,
|
||||
] as never);
|
||||
void m.send({ type: "text", text: "hello" });
|
||||
|
||||
// pi emits message_start + message_end for an injected follow-up marker
|
||||
// (no streaming partials) — both must UPSERT the same tail slot by id.
|
||||
const marker = {
|
||||
id: "steer_job_x_1",
|
||||
role: "custom",
|
||||
customType: "subagent-steer",
|
||||
content: "The research result arrived.",
|
||||
display: true,
|
||||
details: { jobId: "job_x", kind: "research" },
|
||||
timestamp: 2,
|
||||
};
|
||||
m.applyWireEvent({ type: "message_start", message: marker } as never);
|
||||
m.applyWireEvent({ type: "message_end", message: marker } as never);
|
||||
|
||||
const state = m.getState();
|
||||
const ids = state.messages.map((v) => v.parts.length);
|
||||
assert.ok(state.messages.length >= 2, "the marker produced a view message");
|
||||
const tail = state.messages.at(-1)!;
|
||||
assert.equal(tail.role, "user", "a steer marker projects as a user-role view message");
|
||||
assert.ok(
|
||||
tail.parts.some((p) => p.type === "data-subagent-artifact"),
|
||||
"the marker renders its artifact part (SubagentSteerCard)",
|
||||
);
|
||||
|
||||
// A re-delivery of the same id (message_end replay) must NOT duplicate.
|
||||
m.applyWireEvent({ type: "message_end", message: marker } as never);
|
||||
const again = m.getState();
|
||||
assert.equal(again.messages.length, state.messages.length, "same id upserts, never appends a second copy");
|
||||
});
|
||||
|
||||
it("a provider error rides VERBATIM (owner ruling) — no masking anywhere", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
sseResponse(
|
||||
|
||||
Reference in New Issue
Block a user