mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-01 23:41:40 +00:00
* feat(shared): steer capability gates and live steered signal schemas - STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession gate which agents can deliver queued messages into the active turn (pi, codex, cursor ACP; legacy stream-json cursor excluded) - AgentState.steeringActive, DecryptedMessage.steered and messages-consumed live signal (never persisted by the hub) * feat(cli): queue reservations and steered messages-consumed option - MessageQueue2 gains takeByLocalId/restoreReservation/ beginReservationDispatch/commitReservation so an async steer can reserve a queued row without racing the main loop's turn/start drain - emitMessagesConsumed accepts steered: true to mark mid-turn delivery * feat(codex): mid-turn steer via app-server turn/steer (#888) - CodexAppServerClient.steerTurn + TurnSteerParams/Response types - CodexRemoteLauncher registers the steer-queued-message RPC handler: reserves the queued row, validates it against the active turn (no control commands, matching mode hash), injects via turn/steer with an epoch guard that invalidates in-flight steers on abort/cleanup - steeringActive agent state tracks the active-turn window - hub syncEngine gate opens to codex; messages-consumed relays steered * feat(web): Steered badge and steer gating for codex sessions - HappyUserMessage shows a ↳ Steered badge fed by the live messages-consumed steered signal, preserved across server echoes and refetches (mergeMessages carries the optimistic marker) - SessionChat gates canSteer via isSteeringSupportedForSession instead of the pi-only check - clearStaleQueuedStatus normalizes a queued status on an invoked message - fix(web): drop duplicate showSessionSummaryInChat in markdown test (upstream typecheck breakage) * fix(codex,shared): address bot findings on steer gate and ambiguous turn/steer - STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession advertise codex and pi only; cursor joins when its soft-steer handler lands (#1609) - turn/steer now splits dispatch (stdin accepted) from completion (turn finished): the hub RPC acks once dispatch succeeds — never on the concurrent turn's completion, which can exceed the 30s RPC window - queue row commits only after the turn settles; a rejected/aborted steer restores the row so the message still delivers via turn/start, and a dispatched steer is never restored (no duplicate delivery) - steer carries clientUserMessageId (echoed as userMessage.clientId) so ambiguous transport failures can reconcile the thread later - client tests cover dispatch/complete split and stdin-write failure * fix(codex): reconcile dispatched steers before restoring; align error copy - A dispatched turn/steer whose completion fails (disconnect / protocol error) is now reconciled via thread/read by clientUserMessageId before the queued row is restored — the instruction is only re-delivered by turn/start when the thread never received it - Reconcile targets the pinned steer thread, not whichever turn is current when completion fails - syncEngine unsupported-flavor error now matches the capability gate (Pi and Codex only until the cursor handler lands) - launcher tests cover steer success (ack on dispatch), reconcile-accepted and reconcile-rejected outcomes * fix(codex): consume the row at dispatch; drop background reconcile - The hub RPC acks and the queue row is consumed as soon as stdin accepts turn/steer; completion is background-only logging. A dispatched steer is never restored, so the same localId cannot be re-delivered via turn/start after the caller was told the steer succeeded - Dispatch failure (stdin write error) still restores the row and reports failure - steer.completed rejection is always handled (no unhandled rejection on the dispatch-failure path) - tests updated: completion failure after dispatch keeps the row consumed; dispatch failure restores it * fix(codex): distinguish definite rejection from indeterminate completion - Transport-level failures (timeout, abort, disconnect, spawn, protocol) carry an indeterminate marker; explicit JSON-RPC error responses do not - After a dispatched steer, turn completion resolves → commit + consumed; a definite app-server rejection restores the row (instruction was never accepted, so turn/start cannot duplicate it); an indeterminate outcome leaves the row reserved so it can never be delivered twice - Completion handling registers before awaiting dispatch so the dispatch-failure path cannot leak an unhandled rejection - client/launcher tests cover explicit rejection (restore), indeterminate outcome (row stays reserved) and dispatch failure * fix(codex): reconcile indeterminate steers instead of a permanent reservation - After an indeterminate completion (disconnect/protocol), reconcile the thread by clientUserMessageId immediately: accepted → commit + consumed, provably rejected → restore, still unreadable → keep the reservation and retry from the main-loop top on later passes (post-reconnect) - A row never sits in dispatching forever: the hub cannot stamp it invoked while the instruction may never have been accepted - tests: indeterminate keeps reserved while thread unreadable; accepted reconciliation consumes; rejected path restores * fix(codex): accept all thread item shapes; retry reconcile; ack through abort - Reconcile matcher accepts userMessage/user_message with clientId/ client_id, matching the shapes the thread parser supports — an accepted steer can no longer be misclassified as rejected - A pending reconciliation schedules a wakeLoop retry, so a temporary app-server outage cannot strand the reservation behind waitForTurnOrRecovery - The success-path ACK no longer checks the steer epoch: the hub already reported steered on dispatch, so commit + messages-consumed must reach it even when an abort resets the queue in between * fix(codex): reinit reconnected app-server; keep reconcile retries alive - thread/read after a disconnect auto-connects a fresh app-server, which must be initialized before any request — reconcile now ensures connect + initialize (isConnected getter added to the client) - every still-unknown loop-top reconciliation schedules the next retry, so recovery without external traffic is eventually observed - launcher mock gains isConnected * fix(codex): timer-driven reconciliation; init tracking; abort-safe ACK - Reconciliation runs on a self-rescheduling 1s timer independent of the main loop (wakes it too), so idle loops and waitForTurnOrRecovery still observe app-server recovery; abort clears nothing implicitly — the ACK path commits and consumes even when the reservation was cancelled - Absence of a durable client id is ambiguous: unmatched reads stay 'unknown' and keep retrying instead of restoring the row - CodexAppServerClient tracks initialized state (reset on disconnect/exit) so ensureAppServerInitialized re-initializes a fresh process before thread/read; initialize failures leave the flag false for the next retry - tests: accepted reconciliation via scheduled timer, indeterminate keeps reserved, explicit rejection restores * fix(codex): bind reconciliation to the launcher lifecycle - runSteerReconciliation clears any armed retry timer on entry and never installs a second one, so loop-top and timer-driven passes cannot multiply - shuttingDown is set when the main loop ends: timers are cleared and the pending map is dropped, so an unresolved steer can never respawn an app-server after cleanup (remote-to-local switch included) * fix(codex): report steered only after app-server acceptance - The handler now awaits steer.completed (the inject-acceptance response): an explicit JSON-RPC rejection surfaces as failed and restores the row for the normal turn/start path instead of a false steered - Transport failure after dispatch reports 'Steer outcome is being reconciled' and keeps the row reserved while the timer-driven thread reconciliation runs - dispatch-failure path also swallows the paired completion rejection * fix(steer): tri-state cancel, clear-safe reservations, bounded acceptance wait - MessageQueue2.cancelByLocalId returns 'in-flight' for a dispatching steer reservation: the hub neither deletes the row nor stamps invoked_at (new CancelMessageResponse 'busy' status; web restores the optimistic row); pushIsolateAndClear and reset/close share cancelReservations so /clear-style commands cannot have a rejected steer resurrect a discarded prompt - turn/steer acceptance wait bounded at 25s (< hub 30s RPC timeout): a lost response is indeterminate and funnels into thread reconciliation instead of stranding the reservation - tests updated for the tri-state cancel contract * fix(codex,web): busy-aware edit flow; bound reconciliation reads - QueuedMessagesBar edit flow treats a 'busy' cancel as unsuccessful: it never prefills the composer when the row is inside an async steer, so a second client cannot send a duplicate - reconcileSteerByClientId bounds thread/read with a 5s timeout so a connected-but-silent app-server cannot hold the reservation in-flight indefinitely * fix(steer): inFlight-dominated cancel acks; bounded reconciliation - hub cancel-queued-message acks check inFlight before removed: a stale duplicate socket reporting removed can no longer delete the durable row while another socket is dispatching the steer - reconciliation entries expire after 60s and mark delivered: after the rejection window, a dispatched steer that the app-server never proved (client ids dropped on restart) is committed instead of polling thread/read forever - pre-dispatch failures (abort before write included) never enter reconciliation — they restore the row and report failure * fix(steer): persist indeterminate outcomes without replay * fix(steer): make ambiguous delivery restart-safe * fix(steer): recover crash-held rows and preserve retry dedup * fix(steer): ack retries and bound stdin dispatch * fix(steer): reconcile indeterminate dispatches and serialize retries * fix(codex): classify stdin callback failures as indeterminate * fix(steer): recheck indeterminate cancels after ACK * fix(steer): close retry and abort races * fix(steer): serialize live retries and abort admission * fix(steer): distinguish live dispatching from unknown * fix(steer): keep ACK failures held and reconcile busy cancel * fix(steer): distinguish held cancel from removal * fix(store): combine schema v24 migrations * fix(store): reserve schema v25 for steer delivery state * fix(steer): keep held cancel state and notify requeue * fix(steer): release explicitly cancelled unknown reservations * fix(codex): reject cancelled reservations before native steer * fix(codex): make reservation restore atomic with state * fix(codex): terminate abandoned transport writes * fix(steer): own abandoned app-server lifecycle and consume races * fix(codex): confirm dispatch and recover abandoned turns * test(codex): mock abandoned transport callback * fix(codex): clear visible turn state on transport loss * fix(steer): claim retries and cover native delivery state * fix(native): preserve indeterminate state on Android hydration * fix(steer): make retry claims single-winner * fix(steer): serialize concurrent retry claims * fix(socket): tolerate missing steer-state ACK callbacks * fix(native): serialize retry operations * docs(web): document unknown steer delivery and retry controls * fix(steer): handle retry failures and abort-before-connect * fix(steer): reinitialize after transport loss and finish iOS retry errors * fix(steer): preserve indeterminate rows across reconnect gaps * test(web): mock indeterminate queued recovery state * fix(steer): recover consumed ACK tombstones * fix(steer): expose consumed cancel tombstones
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { CodexAppServerClient } from './codexAppServerClient';
|
||||
import { CodexAppServerClient, isIndeterminateError } from './codexAppServerClient';
|
||||
import { CodexPermissionHandler } from './utils/permissionHandler';
|
||||
import { ReasoningProcessor } from './utils/reasoningProcessor';
|
||||
import { DiffProcessor } from './utils/diffProcessor';
|
||||
@@ -11,6 +11,7 @@ import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge';
|
||||
import { emitReadyIfIdle } from './utils/emitReadyIfIdle';
|
||||
import type { CodexSession } from './session';
|
||||
import type { EnhancedMode } from './loop';
|
||||
import type { QueueReservation } from '@/utils/MessageQueue2';
|
||||
import { hasCodexCliOverrides } from './utils/codexCliOverrides';
|
||||
import { AppServerEventConverter } from './utils/appServerEventConverter';
|
||||
import { registerGeneratedImageFromPath } from '@/modules/common/generatedImages';
|
||||
@@ -212,6 +213,14 @@ function stripAnsi(value: string): string {
|
||||
return value.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
export function isCurrentSteerHandler(
|
||||
currentEpoch: number,
|
||||
handlerEpoch: number,
|
||||
shouldExit: boolean
|
||||
): boolean {
|
||||
return currentEpoch === handlerEpoch && !shouldExit;
|
||||
}
|
||||
|
||||
class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
private readonly session: CodexSession;
|
||||
private readonly appServerClient: CodexAppServerClient;
|
||||
@@ -220,6 +229,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
private diffProcessor: DiffProcessor | null = null;
|
||||
private happyServer: HappyServer | null = null;
|
||||
private abortController: AbortController = new AbortController();
|
||||
/** Invalidates queued-message steer handlers after abort or cleanup. */
|
||||
private steerEpoch = 0;
|
||||
private abortInProgress = false;
|
||||
private currentThreadId: string | null = null;
|
||||
private currentTurnId: string | null = null;
|
||||
private readonly activeChildTurns = new Map<string, string>();
|
||||
@@ -276,13 +288,17 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
}
|
||||
|
||||
private async handleAbort(): Promise<void> {
|
||||
this.abortInProgress = true;
|
||||
this.steerEpoch++;
|
||||
logger.debug('[Codex] Abort requested - stopping current task');
|
||||
try {
|
||||
await this.interruptActiveTurns('abort');
|
||||
this.currentTurnId = null;
|
||||
|
||||
this.abortController.abort();
|
||||
this.session.queue.reset();
|
||||
// A dispatched steer may still reconcile after Abort; preserve its
|
||||
// reservation so a positive thread/read result can acknowledge it.
|
||||
this.session.queue.reset({ preserveDispatchingReservations: true });
|
||||
this.permissionHandler?.reset();
|
||||
this.reasoningProcessor?.abort();
|
||||
this.diffProcessor?.reset();
|
||||
@@ -291,6 +307,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
logger.debug('[Codex] Error during abort:', error);
|
||||
} finally {
|
||||
this.abortController = new AbortController();
|
||||
this.abortInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,6 +688,11 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
let scheduleReadyAfterTurn: (() => void) | null = null;
|
||||
let clearReadyAfterTurnTimer: (() => void) | null = null;
|
||||
let turnInFlight = false;
|
||||
const setTurnInFlight = (value: boolean) => {
|
||||
turnInFlight = value;
|
||||
session.client.updateAgentState((state) => ({ ...state, steeringActive: value }));
|
||||
};
|
||||
setTurnInFlight(false);
|
||||
let usageModel: string | null = null;
|
||||
let allowAnonymousTerminalEvent = false;
|
||||
let invalidThreadId: string | null = null;
|
||||
@@ -1875,6 +1897,18 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
waiter();
|
||||
};
|
||||
|
||||
appServerClient.setTransportAbandonedHandler(() => {
|
||||
// The old process is gone; its foreground turn cannot deliver more
|
||||
// notifications. Let the loop recover on a fresh app-server.
|
||||
setTurnInFlight(false);
|
||||
this.conversationHistory.setBusy(false);
|
||||
if (session.thinking) session.onThinkingChange(false);
|
||||
recoveryInFlight = false;
|
||||
activeMessage = null;
|
||||
this.currentTurnId = null;
|
||||
wakeLoop();
|
||||
});
|
||||
|
||||
const waitForTurnOrRecovery = (signal: AbortSignal): Promise<void> => new Promise((resolve) => {
|
||||
if (!turnInFlight && !recoveryInFlight) {
|
||||
resolve();
|
||||
@@ -1893,6 +1927,316 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
signal.addEventListener('abort', finish, { once: true });
|
||||
});
|
||||
|
||||
// Steers whose transport outcome is indeterminate (disconnect / protocol
|
||||
// failure after dispatch). Reconciled independently of the main loop by
|
||||
// a self-rescheduling timer: accepted → commit + consumed; still
|
||||
// unreadable or no matching client id → retry later (absence of a
|
||||
// durable client id is NOT proof of rejection).
|
||||
const pendingSteerReconciliations = new Map<string, {
|
||||
threadId: string;
|
||||
taken: QueueReservation<EnhancedMode>;
|
||||
batch: QueuedMessage;
|
||||
expiresAt: number;
|
||||
}>();
|
||||
let steerReconcileTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let steerReconcileInProgress = false;
|
||||
let shuttingDown = false;
|
||||
|
||||
const settleReconcileEntry = (localId: string, entry: { taken: QueueReservation<EnhancedMode>; batch: QueuedMessage }): void => {
|
||||
// An explicit retry/cancel may have superseded this poll while its
|
||||
// readThread request was in flight.
|
||||
if (pendingSteerReconciliations.get(localId) !== entry) return;
|
||||
// The hub already knows this row is indeterminate. Only a positive
|
||||
// thread/read match may stamp it delivered.
|
||||
const committed = session.queue.commitReservation(entry.taken);
|
||||
pendingSteerReconciliations.delete(localId);
|
||||
if (!committed) return;
|
||||
messageBuffer.addMessage(entry.batch.message, 'user');
|
||||
session.client.emitMessagesConsumed([localId], { steered: true });
|
||||
};
|
||||
|
||||
const markReconcileEntryIndeterminate = (localId: string, entry: { taken: QueueReservation<EnhancedMode>; batch: QueuedMessage }): void => {
|
||||
// Keep the reservation out of the automatic queue, but leave an
|
||||
// explicit retry/cancel path. The hub persists the same state so a
|
||||
// runner restart cannot replay an unproven steer.
|
||||
session.queue.markReservationIndeterminate(entry.taken);
|
||||
session.client.emitSteerIndeterminate([localId]);
|
||||
};
|
||||
|
||||
const runSteerReconciliation = async (): Promise<void> => {
|
||||
if (steerReconcileTimer) {
|
||||
clearTimeout(steerReconcileTimer);
|
||||
steerReconcileTimer = null;
|
||||
}
|
||||
if (shuttingDown || steerReconcileInProgress || pendingSteerReconciliations.size === 0) {
|
||||
return;
|
||||
}
|
||||
steerReconcileInProgress = true;
|
||||
try {
|
||||
for (const [localId, entry] of Array.from(pendingSteerReconciliations.entries())) {
|
||||
if (pendingSteerReconciliations.get(localId) !== entry) continue;
|
||||
if (Date.now() >= entry.expiresAt) {
|
||||
// Terminal outcome: keep the durable indeterminate state.
|
||||
// Never convert missing client-id evidence into a
|
||||
// messages-consumed ACK; the user must explicitly retry
|
||||
// or cancel the held row.
|
||||
logger.debug(`[Codex] steer ${localId} reconciliation expired; keeping indeterminate`);
|
||||
pendingSteerReconciliations.delete(localId);
|
||||
continue;
|
||||
}
|
||||
const outcome = await reconcileSteerByClientId(entry.threadId, localId);
|
||||
if (outcome === 'accepted') {
|
||||
settleReconcileEntry(localId, entry);
|
||||
}
|
||||
// 'unknown' → keep the entry; the timer reschedules below.
|
||||
}
|
||||
} finally {
|
||||
steerReconcileInProgress = false;
|
||||
if (!shuttingDown && pendingSteerReconciliations.size > 0) {
|
||||
steerReconcileTimer = setTimeout(() => {
|
||||
steerReconcileTimer = null;
|
||||
void runSteerReconciliation();
|
||||
// Wake the main loop too (it may sit in
|
||||
// waitForTurnOrRecovery while the turn is in flight).
|
||||
wakeLoop();
|
||||
}, 1_000);
|
||||
steerReconcileTimer.unref?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSteerReconcileRetry = () => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
if (steerReconcileTimer || steerReconcileInProgress) {
|
||||
return;
|
||||
}
|
||||
steerReconcileTimer = setTimeout(() => {
|
||||
steerReconcileTimer = null;
|
||||
void runSteerReconciliation();
|
||||
}, 1_000);
|
||||
steerReconcileTimer.unref?.();
|
||||
};
|
||||
|
||||
// thread/read auto-connects after a disconnect, but a freshly spawned
|
||||
// app-server must be initialized before any request — the startup block
|
||||
// only ran for the original process.
|
||||
const ensureAppServerInitialized = async () => {
|
||||
if (appServerClient.isConnected() && appServerClient.isInitialized()) {
|
||||
return;
|
||||
}
|
||||
await appServerClient.connect();
|
||||
await appServerClient.initialize({
|
||||
clientInfo: {
|
||||
name: 'hapi-codex-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
capabilities: {
|
||||
experimentalApi: true
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Returns 'accepted' when the thread contains the steered user message
|
||||
// (echoed as userMessage.clientId). Absence of the id is ambiguous
|
||||
// (client ids are not guaranteed durable), so anything unreadable or
|
||||
// unmatched stays 'unknown' and is retried.
|
||||
const reconcileSteerByClientId = async (threadId: string, localId: string): Promise<'accepted' | 'unknown'> => {
|
||||
try {
|
||||
await ensureAppServerInitialized();
|
||||
const response = await appServerClient.readThread(
|
||||
{ threadId, includeTurns: true },
|
||||
{ signal: AbortSignal.timeout(5_000) }
|
||||
);
|
||||
for (const turn of response.thread.turns ?? []) {
|
||||
for (const item of turn.items ?? []) {
|
||||
const record = asRecord(item);
|
||||
// Accept both wire shapes the app-server has used for
|
||||
// user messages (see conversationHistory.ts parsing).
|
||||
const type = asString(record?.type) ?? asString(record?.itemType);
|
||||
const clientId = asString(record?.clientId) ?? asString(record?.client_id);
|
||||
if ((type === 'userMessage' || type === 'user_message') && clientId === localId) {
|
||||
return 'accepted';
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'unknown';
|
||||
} catch (error) {
|
||||
logger.debug(`[Codex] steer reconcile unavailable (${error instanceof Error ? error.message : String(error)})`);
|
||||
return 'unknown';
|
||||
}
|
||||
};
|
||||
|
||||
// Non-interrupting mid-turn inject via app-server `turn/steer`.
|
||||
// Returns null when the turn ended or is review/compact (not steerable).
|
||||
const trySteerActiveTurn = async (
|
||||
batch: QueuedMessage,
|
||||
localId: string
|
||||
): Promise<{ dispatched: Promise<void>; completed: Promise<unknown> } | null> => {
|
||||
const threadId = this.currentThreadId;
|
||||
const turnId = this.currentTurnId;
|
||||
if (!threadId || !turnId || !turnInFlight) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await appServerClient.steerTurn({
|
||||
threadId,
|
||||
input: [{ type: 'text', text: batch.message }],
|
||||
expectedTurnId: turnId,
|
||||
// Echoed back as userMessage.clientId — lets an ambiguous
|
||||
// transport failure reconcile the thread later.
|
||||
clientUserMessageId: localId
|
||||
}, { signal: this.abortController.signal });
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
logger.debug(`[Codex] turn/steer failed (${detail})`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Per-message steer from the waiting queue (web "Steer" button).
|
||||
session.client.rpcHandlerManager.registerHandler(
|
||||
RPC_METHODS.SteerQueuedMessage,
|
||||
async (payload: unknown) => {
|
||||
const localId = typeof (payload as { localId?: unknown } | null)?.localId === 'string'
|
||||
? (payload as { localId: string }).localId
|
||||
: '';
|
||||
if (!localId) {
|
||||
return { steered: false, error: 'Missing localId' };
|
||||
}
|
||||
if (this.abortInProgress || !turnInFlight || !this.currentThreadId || !this.currentTurnId) {
|
||||
return { steered: false, error: 'No active steerable turn' };
|
||||
}
|
||||
// Reserve before awaiting turn/steer so the main loop cannot
|
||||
// collect the same row for turn/start while steer is in flight.
|
||||
const taken = session.queue.takeByLocalId(localId);
|
||||
if (!taken) {
|
||||
return { steered: false, error: 'Message not in queue' };
|
||||
}
|
||||
// An explicit retry supersedes any old reconciliation poll for
|
||||
// the same held row.
|
||||
pendingSteerReconciliations.delete(localId);
|
||||
const isControlCommand = Boolean(taken.item.isolate)
|
||||
|| Boolean(parseCodexSpecialCommand(taken.item.message).type);
|
||||
if (isControlCommand) {
|
||||
session.queue.restoreReservation(taken);
|
||||
return { steered: false, error: 'Control commands cannot be steered' };
|
||||
}
|
||||
if (activeMessage?.hash !== taken.item.modeHash) {
|
||||
session.queue.restoreReservation(taken);
|
||||
return { steered: false, error: 'Queued message mode differs from the active turn' };
|
||||
}
|
||||
const batch: QueuedMessage = {
|
||||
message: taken.item.message,
|
||||
mode: taken.item.mode,
|
||||
isolate: Boolean(taken.item.isolate),
|
||||
hash: taken.item.modeHash
|
||||
};
|
||||
const steerEpoch = this.steerEpoch;
|
||||
// Pin the thread this steer targets: reconciliation must look at
|
||||
// the steer's thread, not whichever turn is current later.
|
||||
const steerThreadId = this.currentThreadId;
|
||||
const steerTurnId = this.currentTurnId;
|
||||
if (!session.queue.beginReservationDispatch(taken)) {
|
||||
return { steered: false, error: 'Steer cancelled' };
|
||||
}
|
||||
const dispatchStatePersisted = await session.client.setSteerDeliveryState([localId], 'dispatching');
|
||||
if (!dispatchStatePersisted) {
|
||||
session.queue.markReservationIndeterminate(taken);
|
||||
session.client.emitSteerIndeterminate([localId]);
|
||||
return { steered: false, error: 'Steer state is indeterminate' };
|
||||
}
|
||||
const restoreQueuedReservation = async (): Promise<boolean> => {
|
||||
if (!taken.originIndeterminate) {
|
||||
const persisted = await session.client.setSteerDeliveryState([localId], 'queued');
|
||||
if (!persisted) {
|
||||
session.queue.markReservationIndeterminate(taken);
|
||||
session.client.emitSteerIndeterminate([localId]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (taken.state !== 'dispatching' || !session.queue.restoreReservation(taken)) {
|
||||
session.client.emitSteerIndeterminate([localId]);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
if (taken.state !== 'dispatching') {
|
||||
session.client.emitSteerIndeterminate([localId]);
|
||||
return { steered: false, error: 'Steer cancelled' };
|
||||
}
|
||||
if (!turnInFlight
|
||||
|| this.currentThreadId !== steerThreadId
|
||||
|| this.currentTurnId !== steerTurnId
|
||||
|| !isCurrentSteerHandler(this.steerEpoch, steerEpoch, this.shouldExit)) {
|
||||
await restoreQueuedReservation();
|
||||
return { steered: false, error: 'Active turn changed' };
|
||||
}
|
||||
const steer = await trySteerActiveTurn(batch, localId);
|
||||
if (steer) {
|
||||
const reconcileDispatchedSteer = (
|
||||
localId: string,
|
||||
taken: QueueReservation<EnhancedMode>,
|
||||
batch: QueuedMessage
|
||||
) => {
|
||||
const threadId = steerThreadId;
|
||||
if (!threadId) {
|
||||
session.queue.markReservationIndeterminate(taken);
|
||||
session.client.emitSteerIndeterminate([localId]);
|
||||
return;
|
||||
}
|
||||
const entry = { threadId, taken, batch, expiresAt: Date.now() + 60_000 };
|
||||
pendingSteerReconciliations.set(localId, entry);
|
||||
markReconcileEntryIndeterminate(localId, entry);
|
||||
scheduleSteerReconcileRetry();
|
||||
};
|
||||
try {
|
||||
await steer.dispatched;
|
||||
} catch (error) {
|
||||
// A stalled/aborted stdin callback is indeterminate: the
|
||||
// bytes may have reached Codex even though dispatch did
|
||||
// not report success. Reconcile instead of replaying.
|
||||
void steer.completed.catch(() => {});
|
||||
if (isIndeterminateError(error)) {
|
||||
reconcileDispatchedSteer(localId, taken, batch);
|
||||
return { steered: false, error: 'Steer outcome is being reconciled' };
|
||||
}
|
||||
await restoreQueuedReservation();
|
||||
return { steered: false, error: error instanceof Error ? error.message : 'Steer failed' };
|
||||
}
|
||||
try {
|
||||
// Await app-server acceptance before reporting success:
|
||||
// an explicit JSON-RPC rejection must surface as failed
|
||||
// (row restored, normal turn/start delivers it later)
|
||||
// rather than a false steered. turn/steer's response is
|
||||
// the inject acceptance, not the full turn completion.
|
||||
await steer.completed;
|
||||
} catch (error) {
|
||||
if (isIndeterminateError(error)) {
|
||||
// Transport failure after dispatch: the outcome is
|
||||
// unknown — keep the row reserved and reconcile the
|
||||
// thread once the app-server is reachable again.
|
||||
reconcileDispatchedSteer(localId, taken, batch);
|
||||
return { steered: false, error: 'Steer outcome is being reconciled' };
|
||||
}
|
||||
await restoreQueuedReservation();
|
||||
return { steered: false, error: error instanceof Error ? error.message : 'Steer failed' };
|
||||
}
|
||||
session.queue.commitReservation(taken);
|
||||
messageBuffer.addMessage(batch.message, 'user');
|
||||
session.client.emitMessagesConsumed([localId], { steered: true });
|
||||
return { steered: true };
|
||||
}
|
||||
if (!isCurrentSteerHandler(this.steerEpoch, steerEpoch, this.shouldExit)) {
|
||||
await restoreQueuedReservation();
|
||||
return { steered: false, error: 'Steer cancelled' };
|
||||
}
|
||||
await restoreQueuedReservation();
|
||||
return { steered: false, error: 'Active turn is not steerable' };
|
||||
}
|
||||
);
|
||||
|
||||
const clearCompactRecovery = (recovery: typeof compactRecovery) => {
|
||||
if (!recovery) {
|
||||
return;
|
||||
@@ -2198,7 +2542,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
});
|
||||
|
||||
lastFinalizedTurnId = request.turnId;
|
||||
turnInFlight = false;
|
||||
setTurnInFlight(false);
|
||||
allowAnonymousTerminalEvent = false;
|
||||
this.currentTurnId = null;
|
||||
sameThreadRetryAttempt = 0;
|
||||
@@ -2221,7 +2565,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
} catch (error) {
|
||||
if (interrupted) {
|
||||
lastFinalizedTurnId = request.turnId;
|
||||
turnInFlight = false;
|
||||
setTurnInFlight(false);
|
||||
allowAnonymousTerminalEvent = false;
|
||||
this.currentTurnId = null;
|
||||
activeMessage = null;
|
||||
@@ -2746,7 +3090,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
|
||||
if (msgType === 'task_started') {
|
||||
clearReadyAfterTurnTimer?.();
|
||||
turnInFlight = true;
|
||||
setTurnInFlight(true);
|
||||
if (!eventTurnId && !this.currentTurnId) {
|
||||
allowAnonymousTerminalEvent = true;
|
||||
}
|
||||
@@ -2756,7 +3100,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
}
|
||||
}
|
||||
if (isTerminalEvent) {
|
||||
turnInFlight = false;
|
||||
setTurnInFlight(false);
|
||||
this.conversationHistory.setBusy(false);
|
||||
allowAnonymousTerminalEvent = false;
|
||||
if (session.thinking) {
|
||||
@@ -3340,7 +3684,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
const resetCurrentTurnState = () => {
|
||||
clearDeferredThreadStatusFailure();
|
||||
cancelSafetyBufferingRequest('Session reset');
|
||||
turnInFlight = false;
|
||||
setTurnInFlight(false);
|
||||
allowAnonymousTerminalEvent = false;
|
||||
this.currentTurnId = null;
|
||||
permissionHandler.reset();
|
||||
@@ -3646,6 +3990,12 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
|
||||
while (!this.shouldExit) {
|
||||
logActiveHandles('loop-top');
|
||||
if (!appServerClient.isConnected() || !appServerClient.isInitialized()) {
|
||||
await ensureAppServerInitialized();
|
||||
}
|
||||
if (pendingSteerReconciliations.size > 0) {
|
||||
await runSteerReconciliation();
|
||||
}
|
||||
if (!pending && recoveryInFlight) {
|
||||
await waitForTurnOrRecovery(this.abortController.signal);
|
||||
if (this.abortController.signal.aborted && !this.shouldExit) {
|
||||
@@ -3790,7 +4140,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
}
|
||||
}
|
||||
|
||||
turnInFlight = true;
|
||||
setTurnInFlight(true);
|
||||
this.conversationHistory.setBusy(true);
|
||||
allowAnonymousTerminalEvent = false;
|
||||
const mode = {
|
||||
@@ -3870,7 +4220,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
} catch (error) {
|
||||
logger.warn('Error in codex session:', error);
|
||||
const isAbortError = error instanceof Error && error.name === 'AbortError';
|
||||
turnInFlight = false;
|
||||
setTurnInFlight(false);
|
||||
this.conversationHistory.setBusy(false);
|
||||
allowAnonymousTerminalEvent = false;
|
||||
this.currentTurnId = null;
|
||||
@@ -3922,10 +4272,19 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
clearDeferredThreadStatusFailure();
|
||||
cancelSafetyBufferingRequest('Session ended');
|
||||
cancelAllPendingThrottledAgentRunUpdates();
|
||||
// Stop reconciliation: the launcher is leaving; no pending steer may
|
||||
// spawn a fresh app-server after cleanup.
|
||||
shuttingDown = true;
|
||||
if (steerReconcileTimer) {
|
||||
clearTimeout(steerReconcileTimer);
|
||||
steerReconcileTimer = null;
|
||||
}
|
||||
pendingSteerReconciliations.clear();
|
||||
}
|
||||
|
||||
protected async cleanup(): Promise<void> {
|
||||
logger.debug('[codex-remote]: cleanup start');
|
||||
this.appServerClient.setTransportAbandonedHandler(null);
|
||||
this.appServerClient.setStderrHandler(null);
|
||||
try {
|
||||
await this.appServerClient.disconnect();
|
||||
|
||||
Reference in New Issue
Block a user