mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
* feat(pi): queue mid-turn messages by default; steer only via explicit per-message Steer button Pi (PyAgent) was the only flavor whose ordinary composer submission while streaming bypassed the queue: the web resolved it to deliveryMode 'steer' and the CLI dispatched a native steer into the running turn immediately, with no waiting state. This makes Pi match Codex/Claude behavior (issue #1466): mid-turn messages wait in the queue by default, and the operator delivers one into the running turn with the new per-queued-message Steer button. - web: resolveMessageDeliveryMode now queues for every flavor; QueuedMessagesBar gains a Steer button (pi + thinking + remote-controlled + immediate rows) backed by a new useSteerQueuedMessage hook + api.steerMessage. - hub: POST /sessions/:id/messages/:messageId/steer -> syncEngine.steerQueuedMessage (pi-only gate, remote-only, scheduled/absent/invoked rejection) -> RPC. - cli: pi runner registers 'steer-queued-message'; a queued message is promoted into the active turn via the existing PiSteerDispatcher (target generation captured at promote time; turn-ended steers fall back to the prompt FIFO). Steers requested while the message is still preparing are deferred and promoted right after preparation completes. - Removed the now-dead Alt+Enter / touch-hold queue gesture (its only purpose was opting out of the removed automatic steer). Verified: bun typecheck; cli/hub/web/shared suites (env-dependent runner integration + kimi wire-locator flakes reproduce on pristine upstream and are unrelated to this diff). * fix(pi): preserve queued messages on rejected steers and pin the steering generation Addresses both Major findings from the HAPI Bot review of PR #1480. - steerDispatcher: a deterministic native rejection (Pi responded error) now degrades the message to the ordinary prompt FIFO instead of emitting messages-consumed. A promoted queued message must not be lost just because the steer was rejected; the hub row stays queued until the FIFO delivers it. The indeterminate-timeout path keeps its fail-closed consume + escalate behavior (a duplicate delivery would be worse). - runPi: the deferred-steer path now captures the streaming generation at RPC request time (Map<localId, generation>) instead of reading it after preparation completes, so a steer requested against turn G1 can never be injected into a turn G2 that started while the message was preparing — the dispatcher's generation-mismatch check degrades it to the FIFO. Regression coverage: negative steer response preserves the entry via the FIFO (no consume); generation rollover while preparing delivers as a normal prompt at the next settle (no steer into the new turn). Verified: bun typecheck; cli pi suites (48 tests), hub 1041, web 2301, shared 240 — all green; only the pre-existing environment-dependent runner integration test fails locally (reproduces on pristine upstream). * fix(pi): reject all scheduled steers and always clear deferred-steer bookkeeping Addresses the two Minor findings from the HAPI Bot follow-up review. - hub: steerQueuedMessage rejects every scheduled row — mature ones included — aligning the endpoint with the web UI (Steer is never offered on scheduled rows) and preserving scheduled-FIFO delivery semantics. - cli: the deferred-steer bookkeeping map is now cleared in a finally on the preparation chain, covering the early exits (cancellation before/after attachment I/O, empty prepared message, preparation failure) that previously could leave a stale generation entry behind for the session lifetime. Regression coverage: hub steer gate tests (mature scheduled row stays queued, non-pi flavor rejected) and a runPi test proving cancellation wins over a deferred steer (no steer/prompt/consume after preparation completes). Verified: bun typecheck; hub 1043 pass, cli 2421 pass (only the pre-existing environment-dependent runner integration suite fails locally), web 2301 and shared 240 unchanged since their green runs. * fix(web): reconcile stale queued rows when a steer returns invoked Addresses the remaining Minor finding from the HAPI Bot follow-up review: when the steer endpoint reports the message was already invoked and the messages-consumed SSE was missed while the row was still queued, the hook now marks the row consumed locally (mirroring useCancelQueuedMessage) so the queued bar cannot keep a stale actionable row until the next sync. Regression coverage: steer returning status 'invoked' reconciles the row via markMessagesConsumed and shows no toast. Verified: bun typecheck; web 2302 pass (hub/cli/shared unchanged since their green runs).
169 lines
7.5 KiB
TypeScript
169 lines
7.5 KiB
TypeScript
import { PiRpcTimeoutError, sendPiRpcAndWait } from './loop';
|
|
import type { PiConversationHistory } from './conversationHistory';
|
|
import type { PiSession } from './session';
|
|
import type { PiTransport } from './piTransport';
|
|
import type { PiPreparedPrompt } from './promptQueue';
|
|
|
|
export type PiPreparedSteer = PiPreparedPrompt & {
|
|
/** Active Pi generation observed when this message arrived at HAPI. */
|
|
targetStreamingGeneration: number | null;
|
|
};
|
|
|
|
type ActiveSteer = {
|
|
entry: PiPreparedSteer;
|
|
cancelled: boolean;
|
|
nativeSent: boolean;
|
|
};
|
|
|
|
/**
|
|
* Serializes explicit native Pi steers by their RPC responses. Pi lifecycle
|
|
* events intentionally do not affect this dispatcher: a steer can be accepted
|
|
* while the main prompt continues streaming, and Pi's steeringMode is only a
|
|
* native policy, not a HAPI ordering signal.
|
|
*/
|
|
export class PiSteerDispatcher {
|
|
private readonly entries: PiPreparedSteer[] = [];
|
|
private active: ActiveSteer | null = null;
|
|
private stopped = false;
|
|
|
|
constructor(private readonly options: {
|
|
session: PiSession;
|
|
transport: PiTransport;
|
|
conversationHistory: PiConversationHistory;
|
|
enqueuePrompt: (prompt: PiPreparedPrompt) => void;
|
|
onIndeterminateTimeout: (error: PiRpcTimeoutError) => void;
|
|
/** Re-attempt normal prompt pumping after local steer work drains. */
|
|
onPendingStateChange?: () => void;
|
|
}) {}
|
|
|
|
/** A prompt must wait while any earlier steer can still fall back ahead of it. */
|
|
get hasPending(): boolean {
|
|
// A locally cancelled active entry cannot reach Pi or fall back, so it
|
|
// must release the normal prompt pump even if it is still awaiting the
|
|
// runtime lock merely to finish its own cancellation path.
|
|
return !this.stopped && ((this.active !== null && !this.active.cancelled) || this.entries.length > 0);
|
|
}
|
|
|
|
enqueue(entry: PiPreparedSteer): void {
|
|
if (this.stopped) return;
|
|
this.entries.push(entry);
|
|
this.pump();
|
|
this.options.onPendingStateChange?.();
|
|
}
|
|
|
|
/** True only while the entry is still local and has not reached Pi stdin. */
|
|
cancelByLocalId(localId: string): boolean {
|
|
const index = this.entries.findIndex((entry) => entry.localId === localId);
|
|
if (index !== -1) {
|
|
this.entries.splice(index, 1);
|
|
this.options.onPendingStateChange?.();
|
|
return true;
|
|
}
|
|
if (this.active?.entry.localId === localId && !this.active.nativeSent) {
|
|
this.active.cancelled = true;
|
|
this.options.onPendingStateChange?.();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Stop all local work; never turn a shutdown race into a queued prompt. */
|
|
stop(): void {
|
|
this.stopped = true;
|
|
this.entries.length = 0;
|
|
if (this.active && !this.active.nativeSent) this.active.cancelled = true;
|
|
// Shutdown must never wake the ordinary prompt pump: transport error
|
|
// handlers call stop() before cleanup marks the runner terminal.
|
|
}
|
|
|
|
private pump(): void {
|
|
if (this.stopped || this.active || this.entries.length === 0) return;
|
|
const entry = this.entries.shift()!;
|
|
const active: ActiveSteer = { entry, cancelled: false, nativeSent: false };
|
|
this.active = active;
|
|
void this.dispatch(active).finally(() => {
|
|
if (this.active !== active) return;
|
|
this.active = null;
|
|
this.pump();
|
|
this.options.onPendingStateChange?.();
|
|
});
|
|
}
|
|
|
|
private async dispatch(active: ActiveSteer): Promise<void> {
|
|
try {
|
|
await this.options.session.runRuntimeMutation(async () => {
|
|
if (this.stopped || active.cancelled) return;
|
|
|
|
// A steer held behind config/history work may only reach this
|
|
// point after the original Pi turn ended. At that boundary,
|
|
// preserve normal prompt FIFO semantics instead of issuing an
|
|
// invalid/meaningless native steer.
|
|
const currentGeneration = this.options.session.currentStreamingGeneration;
|
|
if (
|
|
!this.options.session.isReady
|
|
|| currentGeneration === null
|
|
|| currentGeneration !== active.entry.targetStreamingGeneration
|
|
) {
|
|
this.options.enqueuePrompt({
|
|
message: active.entry.message,
|
|
images: active.entry.images,
|
|
outboundSequence: active.entry.outboundSequence,
|
|
...(active.entry.localId ? { localId: active.entry.localId } : {}),
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Pi appends a user entry for both prompt and steer. Record it
|
|
// immediately before sending so native entry arrival remains
|
|
// FIFO-correlated even with identical message text.
|
|
this.options.conversationHistory.registerUserEntry(active.entry.localId);
|
|
active.nativeSent = true;
|
|
await sendPiRpcAndWait(this.options.session, this.options.transport, {
|
|
type: 'steer',
|
|
message: active.entry.message,
|
|
...(active.entry.images.length > 0 ? { images: active.entry.images } : {}),
|
|
});
|
|
|
|
if (this.stopped) return;
|
|
if (active.entry.localId) this.options.session.emitMessagesConsumed([active.entry.localId]);
|
|
}, { poisonOnError: (error) => error instanceof PiRpcTimeoutError });
|
|
} catch (error) {
|
|
// Transport teardown rejects outstanding RPCs. Its cleanup owns the
|
|
// session terminal state, so do not emit an error/consume event or
|
|
// degrade this steer into the prompt queue afterwards.
|
|
if (this.stopped || active.cancelled || !active.nativeSent) return;
|
|
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
this.options.conversationHistory.rejectPendingEntry(active.entry.localId);
|
|
|
|
// A deterministic native rejection means Pi never accepted the
|
|
// steer. Preserve the message by degrading it to the ordinary
|
|
// prompt FIFO (it is delivered when the agent settles) instead of
|
|
// consuming the hub row — a promoted queued message must not be
|
|
// lost just because the steer was rejected.
|
|
if (!(error instanceof PiRpcTimeoutError)) {
|
|
this.options.enqueuePrompt({
|
|
message: active.entry.message,
|
|
images: active.entry.images,
|
|
outboundSequence: active.entry.outboundSequence,
|
|
...(active.entry.localId ? { localId: active.entry.localId } : {}),
|
|
});
|
|
this.options.session.sendSessionEvent({ type: 'message', message: `Pi steer failed: ${detail}` });
|
|
return;
|
|
}
|
|
|
|
// Indeterminate timeout: Pi may or may not have accepted the
|
|
// steer. Keep the fail-closed handling (consume + escalate) rather
|
|
// than risking a duplicate delivery via the prompt FIFO.
|
|
if (active.entry.localId) {
|
|
this.options.session.emitMessagesConsumed(
|
|
[active.entry.localId],
|
|
{ clearQueuedThinkingGrace: true },
|
|
);
|
|
}
|
|
this.options.session.sendSessionEvent({ type: 'message', message: `Pi steer failed: ${detail}` });
|
|
this.options.onIndeterminateTimeout(error);
|
|
}
|
|
}
|
|
}
|