fix(wait): a retried send-and-wait finds a screen-read dialog

`GET .../wait` answers `blocked` at once for a codex pane showing a dialog,
because a screen-read dialog is a level the server is still reading. The
duplicate branch of `POST .../input` (a tagged retry after a dropped
connection, answered from current state) still used the plain
`currentSignalFor()`, so a retry with `wait: "blocked"` on a pane the first
delivery had already blocked waited out its whole timeout for an edge that
had passed. It now uses `currentSignalForWait()` like the GET route; a
caller that asked only for `idle` still gets `idle`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-14 05:53:58 +02:00
1 parent 1c1658d039
commit 3ae6cde2d6
2 files changed
+40 -3

No files matched your search

+6 -2
View File
@@ -685,7 +685,9 @@ function currentSignalFor(session: { pid: number | null; status: SessionStatus }
* history, but this one is a level the server is still looking at, so a caller that
* asks after the dialog appeared is answered at once instead of waiting out its timeout
* for an edge that already passed. Only when `blocked` was asked for: such a pane is also
* `idle`, and a caller that asked for `idle` alone must keep getting `idle`.
* `idle`, and a caller that asked for `idle` alone must keep getting `idle`. Used by
* `GET .../wait` and by a DUPLICATE send-and-wait, every place a wait answers from the
* session's current state.
*/
function currentSignalForWait(
session: { pid: number | null; status: SessionStatus; screenBlocked?: boolean },
@@ -2017,7 +2019,9 @@ export function registerSessionRoutes(
// A DUPLICATE has no new turn coming, so it answers from the current state
// instead of blocking for a transition that already happened.
requireTransition: !duplicate,
currentSignal: duplicate ? currentSignalFor(session, workerDead) : undefined,
// The `...ForWait` variant, as on GET .../wait: a screen-read dialog the first
// delivery raised is a level, and a retry asking for `blocked` must find it.
currentSignal: duplicate ? currentSignalForWait(session, workerDead, until) : undefined,
});
} catch (err) {
if (err instanceof WaitCapacityError) {
+34 -1
View File
@@ -24,7 +24,7 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { MAX_WAIT_MS } from '../../src/config/agent-wait.js';
import { MAX_WAIT_MS, MIN_WAIT_MS } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
@@ -315,6 +315,39 @@ describe('POST /api/sessions/:id/input with wait', () => {
expect(body.data.status).toBe('busy');
});
it('a duplicate on a codex pane showing a dialog answers blocked, as GET .../wait does', async () => {
// A screen-read dialog is a level, not an edge: the original delivery may have raised
// it before the caller's connection dropped, and the retry must not wait out its
// timeout for an edge that already passed.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)! as unknown as {
mode: string;
status: string;
screenBlocked?: boolean;
writeBuffer: string[];
};
session.mode = 'codex';
await send(app, { input: 'first', clientId: 'c-codex', seq: 1 });
session.status = 'idle';
session.screenBlocked = true;
const replay = await send(app, {
input: 'first',
clientId: 'c-codex',
seq: 1,
wait: 'blocked',
waitTimeout: MIN_WAIT_MS,
});
const body = replay.json();
expect(body.data.duplicate).toBe(true);
expect(body.data.wait).toMatchObject({ signal: 'blocked', immediate: true, timedOut: false });
// ...and only to a caller that asked for it: such a pane is idle too.
const idle = await send(app, { input: 'first', clientId: 'c-codex', seq: 1, wait: 'idle' });
expect(idle.json().data.wait).toMatchObject({ signal: 'idle', immediate: true });
});
it('gives the dedup seq back when a full waiter pool rejects the request', async () => {
// Otherwise the caller's retry is refused as a duplicate and the input vanishes.
const { app, ctx } = await harness();