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:
amosblomqvist
2026-09-26 03:46:09 +03:00
parent 7f3d8aadcc
commit 7f51259fc6
7 changed files with 426 additions and 265 deletions
+28 -28
View File
@@ -4,7 +4,7 @@ import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import type { ModelRole } from "../../config.ts";
import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
import { deliverJobResult, registerLiveAgent, setJobRespawn, setTurnRunner, settleSteers, unregisterLiveAgent } from "./jobs.ts";
import { piTurnResponse, runPiTurn } from "./pi/ui-stream.ts";
import { effectiveSettings, stores } from "./stores.ts";
import { searchAvailable, searchProvider } from "./search.ts";
@@ -184,14 +184,16 @@ export async function handleChatInput(
// The input's native appends (pure; the honest 400 for an answer with no
// pending question call). Computed BEFORE beginTurn so a refused input
// leaves no liveness effects; read against the CURRENT record.
// leaves no liveness effects; read against the CURRENT record. Wave C:
// the idle wake carries EVERY queued marker (steerDelivered filters a
// re-fire of an already-landed one) - with claimSteers gone, this wake
// input is the only steer appender; in-run delivery rides pi's own
// follow-up queue and never builds an input event at all.
let appends;
try {
appends =
input.type === "steer"
? steerDelivered(record.messages, input.steer.id)
? []
: [input.steer]
input.type === "steers"
? input.steers.filter((s) => !steerDelivered(record.messages, s.id))
: inputAppends(record, input);
} catch (err) {
if (err instanceof ChatInputError) {
@@ -208,30 +210,13 @@ export async function handleChatInput(
return Response.json({ error: "A turn is already running for this session" }, { status: 409 });
}
// CONTINUATION COALESCING (async protocol §4, the delivery-starvation fix):
// steers finished while a quiz chain held the wake gates ride THIS turn —
// appended after the learner's answer (conversion-safe: the answer is the
// question call's tool result now). Claiming happens after beginTurn, so
// this turn owns the registry and the write-ahead persist below makes the
// claim durable. Steers already in the transcript (a wake raced in) are
// finalized by the claim itself — the append filter keeps exactly one copy.
// THE CARRIED FILTER (restored 2026-09-26, the Wave B regression of the
// 2026-09-18 duplicate-key bug): a pump-fired wake turn ALREADY CARRIES
// its steer in `appends` — and the pump only shifts its queue entry AFTER
// the turn registers, so the claim above pops that very steer too. The
// old record-side-only filter couldn't see the carried copy (it checks
// the PRE-write-ahead snapshot), and the marker persisted twice with the
// same `steer_<jobId>` id — two view messages, one React key, crash.
const carried = input.type === "steer" ? input.steer.id : undefined;
const steers = claimSteers(chatId).filter(
(s) => s.id !== carried && !steerDelivered(record.messages, s.id),
);
// Write-ahead persistence: the input's native appends go on disk BEFORE
// the turn starts — the learner's answer is durable the instant the
// the turn starts - the learner's answer is durable the instant the
// request arrives, and any remount, second tab, or server crash reads
// truth instead of the pre-answer record.
const merged = [...record.messages, ...appends, ...steers];
// truth instead of the pre-answer record. (Wave C: the wake's markers
// ride this same write-ahead - the pump de-queued them at fire, so this
// is the ONLY copy ever appended.)
const merged = [...record.messages, ...appends];
try {
stores.chatStore.saveMessages(chatId, merged);
} catch (err) {
@@ -373,6 +358,13 @@ export async function handleChatInput(
// nothing must find the persisted record current — the same
// ordering rule as endTurn.
unregisterTurn(chatId);
// Wave C steer settlement: the live agent is dead (no more
// followUp handoffs) - reconcile the steer registry against
// what this run actually persisted (in-run injections
// resolve; stranded markers return to pending), then let
// the pump take any stragglers down the idle wake.
unregisterLiveAgent(chatId);
settleSteers(chatId, stamped);
},
});
// The turn is now a live background object: register it so any view
@@ -381,6 +373,11 @@ export async function handleChatInput(
// runPiTurn call is race-free: the agent loop only ticks in later
// microtasks, so settle (and thus unregister) cannot beat us here.
registerTurn(chatId, handle);
// Wave C: the pump hands queued steer markers to THIS run's agent
// (pi's own follow-up queue) - same race-freedom as registerTurn:
// the loop only ticks in later microtasks, so settlement cannot
// beat the registration.
registerLiveAgent(chatId, constructed);
// The POST response: just this caller's view onto the running turn —
// identical wire dialect to the reattach stream.
return piTurnResponse(handle);
@@ -388,6 +385,9 @@ export async function handleChatInput(
// The stream never started, so no onFinish will fire — clear the registry
// or a malformed request would lock the chat until the TTL eviction.
endTurn(chatId);
// Wave C: the live-agent registration (if it happened) dies with the
// turn here too - nothing was handed (the loop never ticked).
unregisterLiveAgent(chatId);
console.error(`[chat ${chatId}] turn failed to start:`, err);
return Response.json({ error: "Failed to start the turn" }, { status: 500 });
}
+212 -101
View File
@@ -1,24 +1,40 @@
/**
* THE WAKE PIPELINE (contracts.ts "THE ASYNC PROTOCOL" §4) — server-side
* steer delivery for finished sub-agent jobs.
* steer delivery for finished sub-agent jobs. Wave C (2026-09-26):
* delivery rides pi's OWN follow-up queue when a run is live, and the
* idle wake otherwise. One registry, two vehicles.
*
* deliverJobResult(steer) — the runner calls this on job completion. The
* steer joins a per-chat serialized queue and a pump drives it:
* deliverJobResult(steer) — the runner calls this on job completion.
* The steer joins a per-chat serialized registry and the pump drives
* it along ONE of two paths:
*
* 1. WAIT FOR IDLE: while a turn is in flight (turn-liveness) OR a
* question tool part is pending (an unanswered client-side tool
* call breaks provider conversion — the steer fires right after
* the learner's continuation), poll. A short grace after the gates
* open avoids stealing the turn a client is about to send.
* 2. FIRE: a server-side teacher turn through the SAME composition as
* a client request (the injected turn runner wraps
* handleChatRequest): write-ahead persist, liveness, extraction —
* one code path. A 409 means a client turn raced in — retry with
* backoff. Idempotent per jobId (steerDelivered): a crashed wake
* or a duplicate deliver is skipped, not duplicated.
* 3. FLIP: only now does the job go done/error — a polling client
* that sees the flip re-syncs once and gets the finished record
* (steer + the teacher's response to it).
* 1. LIVE RUN → pi follow-up: a turn is streaming, so the marker is
* handed to the LIVE agent's followUp queue (agent.followUp).
* pi polls that queue when the run would otherwise stop and
* CONTINUES THE SAME RUN — the marker joins the context, rides
* the live event stream (message_start/message_end), and lands
* in the record at that run's persist. The question stopper
* runs BEFORE the follow-up poll and exits the loop, so an open
* quiz is never overridden: a handed marker strands (see
* settleSteers) and waits for the next run — today's pacing.
* probe-handoff markers NEVER take this path (owner ruling:
* the handoff must start the TEACHER's run — a record-level
* branch only a fresh wake can make).
* 2. IDLE WAKE: no live turn. WAIT FOR IDLE (turn-liveness OR a
* dangling question — an unanswered client-side tool call breaks
* provider conversion), then fire ONE server-side wake turn
* carrying EVERY queued marker as its input appends (write-ahead
* persist, liveness, extraction — one code path, the same
* composition as a client request). A 409 means a client turn
* raced in — re-queue and retry with backoff. Idempotent per
* jobId (steerDelivered): a crashed wake or a duplicate deliver
* is skipped, not duplicated.
*
* settleSteers(chatId, persisted) — the handler calls this at turn
* settlement (after unregisterLiveAgent): entries the run DELIVERED
* resolve + flip their jobs; entries that were handed to the dying
* run but never injected (stopper exit, abort, a missed final poll)
* return to pending and the pump restarts — no stranding, no loss.
*
* Crash recovery (recoverStaleJobs): a process restart can orphan jobs —
* "running" with a result (the wake never fired) or without one (the
@@ -44,10 +60,12 @@ import { danglingQuestionOf } from "../core/native-messages.ts";
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
import { stores } from "./stores.ts";
import { isTurnRunning } from "./turn-liveness.ts";
import type { Agent } from "@earendil-works/pi-agent-core";
/** Fires one server-side teacher turn: (chatId, input) → HTTP-ish status.
* Wave B: the input is the STEER EVENT — the handler appends the marker
* natively under its own turn claim (one composition for every turn). */
* Wave C: the input is the IDLE WAKE — every queued marker as the turn's
* input appends (the handler appends them natively under the wake's own
* turn claim; one composition for every turn). */
export type TurnRunner = (chatId: string, input: TurnInput) => Promise<number>;
/** Re-runs a job's sub-agent loop (crash recovery for orphaned jobs). */
@@ -57,13 +75,24 @@ interface QueuedSteer {
steer: SteerMessage;
resolve: () => void;
reject: (reason: Error) => void;
/** The live agent this marker was handed to via followUp() — set by the
* pump's live path. Guards double-handoff (a re-pumped loop must not
* re-queue what a run already holds) and is CLEARED at settlement for
* markers the dying run never injected. */
handedTo?: Agent;
}
interface WakeState {
turnRunner?: TurnRunner;
respawn?: JobRespawn;
/** chatId → steers awaiting delivery, in arrival order. */
/** chatId → steers awaiting delivery, in arrival order. The single
* delivery registry (Wave C: the agent's own follow-up queue is a
* per-run VEHICLE, not the registry — settlement reconciles here). */
queues: Map<string, QueuedSteer[]>;
/** chatId → the agent of the CURRENTLY streaming turn (cleared at
* settlement, before settleSteers). The pump's live path hands
* markers to this agent's follow-up queue. */
liveAgents: Map<string, Agent>;
/** Chats whose pump loop is currently active (serialization per chat). */
pumping: Set<string>;
/** Throttle bookkeeping for the stale-job sweep. */
@@ -76,7 +105,7 @@ interface LhWakeGlobal {
function state(): WakeState {
const g = globalThis as typeof globalThis & LhWakeGlobal;
return (g.__lhWakePipeline ??= { queues: new Map(), pumping: new Set(), lastSweepAt: 0 });
return (g.__lhWakePipeline ??= { queues: new Map(), liveAgents: new Map(), pumping: new Set(), lastSweepAt: 0 });
}
/** chat-handler wires this at module init: its adapter around handleChatRequest. */
@@ -145,48 +174,55 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
});
}
/**
* CONTINUATION COALESCING ("THE ASYNC PROTOCOL" §4): the chat-handler calls
* this the instant a client turn wins the turn registry, BEFORE its
* write-ahead persist. Undelivered steers for the chat ride THAT turn —
* appended after the learner's answer, which is conversion-safe (the
* answered quiz carries its output) — instead of waiting for the pump's
* idle window, which a quiz-chaining teacher may never open (the delivery
* starvation bug: every turn ends with a new pending quiz, so the gates
* never stay open long enough to fire a wake).
/** The chat-handler registers the CURRENTLY STREAMING turn's agent right
* after registerTurn — the pump's live path hands queued markers to this
* agent's pi-native follow-up queue (followUpMode "all": every handed
* marker joins the continuation turn together, matching the wake's
* carry-everything semantics). Registration is race-free the same way
* registerTurn is: the agent loop only ticks in later microtasks, so the
* settle seam cannot beat the registration. */
export function registerLiveAgent(chatId: string, agent: Agent): void {
state().liveAgents.set(chatId, agent);
}
/** Clear the live agent at settlement — MUST run before settleSteers so the
* reconciliation's re-pump can never hand a marker to the dying agent. */
export function unregisterLiveAgent(chatId: string): void {
state().liveAgents.delete(chatId);
}
/** TURN SETTLEMENT RECONCILIATION (Wave C): the handler calls this AFTER
* unregisterLiveAgent, with the run's persisted messages. Two jobs:
*
* Claimed steers resolve their deliver() promise and flip the job done
* here: the caller holds the turn registry, so the write-ahead persist is
* imminent and durable. Steers already in the transcript (a wake raced in)
* are finalized, not returned. The pump re-checks steerDelivered right
* before firing, so a claim racing the pump converges to exactly one
* delivery either way.
* - entries whose marker the run DELIVERED (in-run follow-up injection —
* their ids are in the persisted slice) resolve and flip their jobs;
* - entries handed to the dying run but never injected (the question
* stopper exited before the follow-up poll; an abort; a delivery that
* raced the final poll) return to PENDING (handedTo cleared) and the
* pump restarts — the idle wake delivers them once the gates open.
*
* incoming (the turn's request body — the wake pump fires turns that
* ALREADY CARRY the steer it dequeues only after the turn returns 200, so
* the entry is still queued here): a steer already in the incoming list is
* being delivered by THIS VERY TURN — finalized and resolved, but never
* appended again. Without this the write-ahead persist would hold two
* copies of the same steer id (a live React duplicate-key warning the
* owner hit 2026-09-18; withMessageIds only heals it at onFinish).
*/
export function claimSteers(chatId: string): SteerMessage[] {
const s = state();
const queue = s.queues.get(chatId);
if (!queue || queue.length === 0) return [];
s.queues.delete(chatId);
// The caller holds the turn registry (chat-handler claims after
// beginTurn), so the write-ahead persist is imminent and durable:
// every queued steer is claimed — its delivery is THIS turn's job.
// Steers already in the transcript (a wake raced in) still finalize
// here; the handler's append filter (steerDelivered) keeps exactly
// one copy in the record.
const claimed = queue.map((entry) => entry.steer);
* This is what makes the live hand-off safe: pi owns no retry for a marker
* stranded in a settled agent's queue, so the registry stays the truth and
* the dead agent's copy is simply abandoned (GC'd with the agent). */
export function settleSteers(chatId: string, persisted: { id?: string }[]): void {
const queue = state().queues.get(chatId);
if (!queue || queue.length === 0) return;
const deliveredIds = new Set(persisted.map((m) => m.id).filter(Boolean));
const remaining: QueuedSteer[] = [];
for (const entry of queue) {
finalizeSteer(entry.steer);
entry.resolve();
if (deliveredIds.has(entry.steer.id)) {
finalizeSteer(entry.steer);
entry.resolve();
} else {
entry.handedTo = undefined;
remaining.push(entry);
}
}
if (remaining.length === 0) state().queues.delete(chatId);
else {
state().queues.set(chatId, remaining);
void pump(chatId);
}
return claimed;
}
/** Result of an abort attempt — the endpoint maps these to HTTP. */
@@ -231,13 +267,18 @@ export function abortJob(jobId: string): AbortOutcome {
fireJobAbort(jobId); // no-op when the loop already returned (delivering)
// Drop any queued steers for this job (their deliver() resolves unfired).
// HANDED entries (already in a live run's follow-up queue) are past
// recall — same ruling as a fired wake: the result lands, and the abort
// note below dedupes against it via steerDelivered (same jobId).
const queue = state().queues.get(job.chatId);
if (queue) {
const remaining = queue.filter((e) => jobIdOf(e.steer) !== jobId);
const remaining = queue.filter(
(e) => jobIdOf(e.steer) !== jobId || e.handedTo !== undefined,
);
if (remaining.length === 0) state().queues.delete(job.chatId);
else state().queues.set(job.chatId, remaining);
for (const entry of queue) {
if (jobIdOf(entry.steer) === jobId) entry.resolve();
if (jobIdOf(entry.steer) === jobId && entry.handedTo === undefined) entry.resolve();
}
}
@@ -262,8 +303,14 @@ function chatIdOf(steer: SteerMessage): string {
}
/**
* The per-chat pump: delivers every queued steer in order, one wake turn
* each. Only one loop per chat at a time; later arrivals ride the queue.
* The per-chat pump: delivers the registry along pi's two native paths.
* Only one loop per chat at a time; later arrivals ride the registry.
*
* Each iteration first clears dead weight (gone chat, already-delivered
* markers), then picks the vehicle: a LIVE turn takes every eligible
* marker via agent.followUp (the run continues at its own stop — nothing
* more to do here until settlement re-pumps); otherwise the IDLE WAKE
* fires ONE turn carrying every pending marker as its input appends.
*/
async function pump(chatId: string): Promise<void> {
if (!chatId) return;
@@ -277,26 +324,69 @@ async function pump(chatId: string): Promise<void> {
s.queues.delete(chatId);
return;
}
const entry = queue[0]!;
const steerId = entry.steer.id;
// Gone chat: nothing to deliver into.
const record = stores.chatStore.getChat(chatId);
if (!record) {
queue.shift();
entry.reject(new Error(`chat ${chatId} not found — cannot deliver steer ${steerId}`));
s.queues.delete(chatId);
for (const entry of queue) {
entry.reject(new Error(`chat ${chatId} not found — cannot deliver steer ${entry.steer.id}`));
}
continue;
}
// Idempotent delivery: the transcript already carries this steer
// (a previous wake completed, or a duplicate deliver raced in).
if (steerDelivered(record.messages, steerId)) {
queue.shift();
finalizeSteer(entry.steer);
entry.resolve();
continue;
// Idempotent delivery: the transcript already carries these steers
// (an in-run injection persisted, a wake completed, or a duplicate
// deliver raced in). Resolve and flip without re-firing anything.
const stillPending = queue.filter((e) => !steerDelivered(record.messages, e.steer.id));
for (const entry of queue) {
if (!stillPending.includes(entry)) {
finalizeSteer(entry.steer);
entry.resolve();
}
}
if (stillPending.length === 0) {
s.queues.delete(chatId);
return;
}
s.queues.set(chatId, stillPending);
// VEHICLE 1 — THE LIVE RUN: pi's own follow-up queue. The agent of
// the streaming turn takes every marker not already handed to IT;
// pi polls the queue when the run would otherwise stop, injects
// them (message_start/message_end on the live wire), and continues
// the run. Delivery resolves at settlement (settleSteers). Nothing
// else to do in this loop iteration — the turn owns the delivery.
const live = s.liveAgents.get(chatId);
if (live && isTurnRunning(chatId)) {
const handable = stillPending.filter(
(e) => e.handedTo !== live && e.steer.customType !== "probe-handoff",
);
let handedAny = false;
for (const entry of handable) {
live.followUp(entry.steer);
entry.handedTo = live;
handedAny = true;
}
// probe-handoffs never ride a live run (owner ruling: the
// handoff must START the teacher's run) — if any remain
// pending, wait the run out and loop to the wake below.
const handoffWaits = stillPending.some(
(e) => e.steer.customType === "probe-handoff",
);
if (handedAny && !handoffWaits) {
return; // the run delivers; settleSteers re-pumps the rest
}
if (handedAny) {
await delay(POLL_MS);
await waitForIdle(chatId);
continue;
}
}
// VEHICLE 2 — THE IDLE WAKE: one server-side turn carrying EVERY
// pending marker as its input appends (the handler write-ahead
// persists them under the wake's own turn claim).
await waitForIdle(chatId);
// Re-fetch AFTER the gates open — waitForIdle can wait out a quiz
@@ -304,59 +394,80 @@ async function pump(chatId: string): Promise<void> {
// the client's answer + continuation in the write-ahead persist.
const fresh = stores.chatStore.getChat(chatId);
if (!fresh) {
queue.shift();
entry.reject(new Error(`chat ${chatId} not found — cannot deliver steer ${steerId}`));
continue;
continue; // the gone-chat branch rejects next iteration
}
// The runner is required — chat-handler sets it at init. If some
// module instance missed the wiring, fail loudly rather than hang.
if (!s.turnRunner) {
queue.shift();
s.queues.delete(chatId);
const err = new Error("wake pipeline: no turn runner wired (setTurnRunner)");
failSteer(entry.steer, err);
entry.reject(err);
continue;
for (const entry of stillPending) {
failSteer(entry.steer, err);
entry.reject(err);
}
return;
}
// A client continuation may have COALESCED this steer (claimSteers) while
// the gates were closed — re-check before firing: exactly one delivery.
if (steerDelivered(fresh.messages, steerId)) {
queue.shift();
finalizeSteer(entry.steer);
entry.resolve();
continue;
// A live run may have delivered some markers while the gates were
// closed — re-check: only still-missing markers ride the wake.
const fire = stillPending.filter((e) => !steerDelivered(fresh.messages, e.steer.id));
for (const entry of stillPending) {
if (!fire.includes(entry)) {
finalizeSteer(entry.steer);
entry.resolve();
}
}
if (fire.length === 0) {
s.queues.delete(chatId);
return;
}
// Fire the server-side teacher turn (the steer EVENT: the handler
// appends the marker natively under its own turn claim). A 409 = a
// client turn raced in between idle and firing — back off and
// retry the same steer.
const status = await s.turnRunner(chatId, { type: "steer", steer: entry.steer }).catch((err: unknown) => {
console.error(`[steer ${steerId}] wake turn crashed:`, err);
// DEQUEUE-AT-FIRE (the Wave C lesson from the Wave B regression):
// the carried markers leave the registry BEFORE the turn runs —
// the wake run's OWN follow-up poll must never see its own cargo,
// and a 409 (client raced in) must be able to push them back.
s.queues.delete(chatId);
const steers = fire.map((e) => e.steer);
// Fire the wake (every marker as the turn's input appends). A 409 =
// a client turn raced in between idle and firing — push the cargo
// back and retry after backoff (the racing turn's own settlement
// re-pumps if it beats us to the re-queue).
const status = await s.turnRunner(chatId, { type: "steers", steers }).catch((err: unknown) => {
console.error(`[steer ${steers.map((x) => x.id).join(",")}] wake turn crashed:`, err);
return -1;
});
if (status === 409) {
const back = s.queues.get(chatId) ?? [];
back.push(...fire);
s.queues.set(chatId, back);
await delay(RETRY_BACKOFF_MS[Math.min(attempt++, RETRY_BACKOFF_MS.length - 1)]!);
continue;
}
attempt = 0;
queue.shift();
if (status === 200) {
finalizeSteer(entry.steer);
entry.resolve();
for (const entry of fire) {
finalizeSteer(entry.steer);
entry.resolve();
}
} else {
const err = new Error(`wake turn failed with status ${status}`);
failSteer(entry.steer, err);
entry.reject(err);
for (const entry of fire) {
failSteer(entry.steer, err);
entry.reject(err);
}
}
}
} finally {
s.pumping.delete(chatId);
// A steer may have arrived between the last check and the finally —
// restart the pump rather than strand it.
if ((state().queues.get(chatId) ?? []).length > 0) void pump(chatId);
// restart the pump rather than strand it. Entries already handed to
// a live run are NOT stragglers (settleSteers owns their resurrection)
// — restarting for them would just park in waitForIdle forever.
const q = state().queues.get(chatId) ?? [];
if (q.some((e) => e.handedTo === undefined)) void pump(chatId);
}
}