Mark completed Codex reviews Done when Stop is absent (#28860)

* Settle Codex review turns from their session completion

* Preserve interruption relay metadata when settling Codex reviews

* Keep the Codex turn id across status writers and settle through the existing host verdict path

The turn id that matches a rollout turn end was dropped by every lead writer
that did not pass it, so a failed turn after a child update never settled.
Drop the synthetic Stop label: the unlabelled poll update already carries the
host's main-agent verdict, including a failure, to current desktops. A remote
desktop now takes the host's settled verdict for a late hook, and a rollout
settle retires hook-only children like Stop does.

* Retire Codex children through the poll and start a fresh turn on any non-compaction session start

A rollout-settled turn deleted the child roster, so the local reconcile
re-seeded the child from the last row as running and the pane stayed Working.
Clear it instead, only from the poll (a child hook settling the root must not
drop that live child), and let a remote desktop clear its roster when the host
reports a done turn with no children. A resumed or forked session no longer
inherits the previous turn id. Also drops accidental pnpm-lock churn.

* Replay a Codex rollout turn end as the root Stop it stands in for

Codex fires no Stop for internal subagents such as /review, so the rollout's
task_complete was settling the main agent through a second path that every
other status writer (local and remote reconcile, child hooks, polls) had to
learn about. The transcript poll now replays that end as an ordinary root Stop
through hook normalization, and a root hook for an already ended turn is
classified as that Stop. A failed end carries its failure outcome and is
published unlabelled like a cancellation, since desktops derive a labelled
Stop's outcome as success. The separate settle path, the remote host-verdict
adoption and the child-hook rollout reads are gone.

* Keep an ended Codex turn retired across compaction, and keep WSL polls alive

A compaction SessionStart queued before the next prompt carried the previous,
already ended turn id, so the poll replayed its Stop: a Done flash and a
second "finished" notification. A compaction now keeps the turn only while
it is still running and its rollout end is unrecorded. The WSL guest
rescheduled its transcript poll with the update instead of the stored row,
so the poll stopped after its first update and a later turn end never
replayed there. A Stop also clears the child roster instead of deleting it,
so a reconcile cannot re-seed retired children from the previous row.

* Restate an ended Codex turn's hook in provider dispatch

Keeps the shared hook normalizer within its size limit after main's merge;
the Codex dispatch returns the event it normalized, which the envelope uses
for the published name and prompt classification.

* Type the rollout record helper in the Codex turn-end test

* Accept Codex's turn_complete name and keep only replayed Stops labelled

The rollout reader also records turn_complete, the name the existing
interruption reader already accepts. A poll row that is not a replay no
longer restates the last hook's event name, so only a replayed Stop can
carry the Stop label.

* Drop a delayed Codex hook for an ended turn the lead has moved past

A late root hook for an older finished turn was restated as Stop and marked
the newer running turn Done; passing it through would instead rewind the
lead's turn id so the poll replays the old end.
This commit is contained in:
Brennan Benson
2026-10-18 14:49:21 -07:00
committed by GitHub
parent d3026df586
commit 4298ba998d
13 changed files with 347 additions and 33 deletions
+35
View File
@@ -24,6 +24,7 @@ import {
// Why: retire a child whose rollout stays unreadable this long, else a deleted/never-written file pins a phantom row forever.
const CHILD_UNREADABLE_GRACE_MS = 60_000
const MAX_TRACKED_TURN_ENDS = 16
const SAFE_THREAD_ID = /^[A-Za-z0-9-]{1,64}$/
type TrackedTranscriptSubagent = JsonlCursor & {
@@ -39,6 +40,7 @@ type TrackedTranscriptSubagent = JsonlCursor & {
export type CodexSubagentTranscriptState = {
parent: JsonlCursor
rootTurn: CodexTranscriptTurn
turnEnds: Map<string, 'completed' | 'failed'>
subagents: Map<string, TrackedTranscriptSubagent>
/** Incremental reviewer cursors for child rollouts, which must not replace the parent cursor. */
reviewerCursorsByPath: Map<string, JsonlCursor>
@@ -161,6 +163,20 @@ function normalizedTranscriptPath(transcriptPath: string | undefined): string |
: undefined
}
function readTurnEnd(
recordValue: JsonRecord
): { turnId: string; end: 'completed' | 'failed' } | undefined {
if (recordValue.type !== 'event_msg') {
return undefined
}
const payload = record(recordValue.payload)
const turnId = typeof payload?.turn_id === 'string' ? payload.turn_id.trim() : ''
if ((payload?.type !== 'task_complete' && payload?.type !== 'turn_complete') || !turnId) {
return undefined
}
return { turnId, end: payload.error == null ? 'completed' : 'failed' }
}
function childIsComplete(records: JsonRecord[]): boolean {
let complete = false
for (const recordValue of records) {
@@ -181,12 +197,20 @@ export function createCodexSubagentTranscriptState(): CodexSubagentTranscriptSta
return {
parent: { offset: 0, carry: '' },
rootTurn: { interrupted: false },
turnEnds: new Map(),
subagents: new Map(),
reviewerCursorsByPath: new Map(),
reviewersByPath: new Map()
}
}
export function codexTranscriptTurnEnd(
state: CodexSubagentTranscriptState | undefined,
turnId: string
): 'completed' | 'failed' | undefined {
return state?.turnEnds.get(turnId)
}
export function hasTrackedCodexTranscriptSubagents(
state: CodexSubagentTranscriptState | undefined
): boolean {
@@ -210,6 +234,7 @@ export function reconcileCodexSubagentTranscript(
changed = true
state.parent = { filePath: normalizedPath, offset: 0, carry: '' }
state.rootTurn = { interrupted: false }
state.turnEnds.clear()
state.subagents.clear()
state.reviewerCursorsByPath.clear()
state.reviewersByPath.clear()
@@ -227,6 +252,16 @@ export function reconcileCodexSubagentTranscript(
? undefined
: (readApprovalsReviewer(parentRecords) ?? state.approvalsReviewer)
for (const recordValue of parentRecords ?? []) {
const turnEnd = readTurnEnd(recordValue)
if (turnEnd) {
state.turnEnds.delete(turnEnd.turnId)
state.turnEnds.set(turnEnd.turnId, turnEnd.end)
const oldest = state.turnEnds.keys().next()
if (state.turnEnds.size > MAX_TRACKED_TURN_ENDS && !oldest.done) {
state.turnEnds.delete(oldest.value)
}
continue
}
const activity = readActivity(recordValue)
if (!activity) {
continue