feat(codex): mid-turn Steer via app-server turn/steer (#888) (#1606)

* 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:
SSU-WEI HUANG
2026-08-19 20:07:39 +08:00
committed by GitHub
parent 2fbd98dfe4
commit f0e5ba9c0f
82 changed files with 2735 additions and 227 deletions
+368 -9
View File
@@ -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();