mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* 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.
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
import { basename, dirname, extname, isAbsolute, join } from 'node:path'
|
|
|
|
import {
|
|
readJsonlCursor,
|
|
readTranscriptDirectory,
|
|
record,
|
|
type JsonlCursor,
|
|
type JsonRecord
|
|
} from './codex-rollout-jsonl-cursor'
|
|
|
|
import { reconcileCodexTranscriptTurn, type CodexTranscriptTurn } from './codex-turn-transcript'
|
|
|
|
import { isCodexStatusTranscriptLine } from './codex-status-transcript-line'
|
|
|
|
import { readApprovalsReviewer } from './codex-subagent-reviewer'
|
|
import type { CodexApprovalsReviewer } from './codex-subagent-reviewer'
|
|
|
|
import {
|
|
finishCodexSubagent,
|
|
setCodexSubagentModel,
|
|
upsertCodexSubagent,
|
|
type CodexSubagentRoster
|
|
} from './codex-subagent-roster'
|
|
|
|
// 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 & {
|
|
description?: string
|
|
/** Latest model seen in the child's own rollout. Retained across polls
|
|
* because the cursor is incremental: `turn_context` is emitted once per
|
|
* turn, so a later read usually carries no model at all. */
|
|
model?: string
|
|
startedAt: number
|
|
unresolvedSince?: number
|
|
}
|
|
|
|
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>
|
|
/** Reviewer ownership discovered from child rollouts, keyed by their bounded cursor paths. */
|
|
reviewersByPath: Map<string, CodexApprovalsReviewer>
|
|
/** Who resolves this turn's approvals in the parent rollout. */
|
|
approvalsReviewer?: CodexApprovalsReviewer
|
|
}
|
|
|
|
// Why: Codex files each rollout under its OWN local start date, so a session running past midnight spawns children into a sibling day directory.
|
|
function childDayDirectory(parentPath: string, startedAt: number): string | undefined {
|
|
const dayDir = dirname(parentPath)
|
|
const monthDir = dirname(dayDir)
|
|
const yearDir = dirname(monthDir)
|
|
if (
|
|
!/^\d{2}$/.test(basename(dayDir)) ||
|
|
!/^\d{2}$/.test(basename(monthDir)) ||
|
|
!/^\d{4}$/.test(basename(yearDir)) ||
|
|
!Number.isFinite(startedAt)
|
|
) {
|
|
return undefined
|
|
}
|
|
const startedOn = new Date(startedAt)
|
|
if (Number.isNaN(startedOn.getTime())) {
|
|
return undefined
|
|
}
|
|
const pad = (value: number): string => String(value).padStart(2, '0')
|
|
return join(
|
|
dirname(yearDir),
|
|
String(startedOn.getFullYear()).padStart(4, '0'),
|
|
pad(startedOn.getMonth() + 1),
|
|
pad(startedOn.getDate())
|
|
)
|
|
}
|
|
|
|
function resolveChildTranscript(
|
|
parentPath: string,
|
|
threadId: string,
|
|
startedAt: number,
|
|
entriesByDirectory: Map<string, string[]>
|
|
): string | undefined {
|
|
if (!SAFE_THREAD_ID.test(threadId)) {
|
|
return undefined
|
|
}
|
|
const suffix = `-${threadId}.jsonl`
|
|
const parentDir = dirname(parentPath)
|
|
const childDir = childDayDirectory(parentPath, startedAt)
|
|
const directories = childDir && childDir !== parentDir ? [parentDir, childDir] : [parentDir]
|
|
for (const directory of directories) {
|
|
let entries = entriesByDirectory.get(directory)
|
|
if (!entries) {
|
|
entries = readTranscriptDirectory(directory)
|
|
entriesByDirectory.set(directory, entries)
|
|
}
|
|
const fileName = entries.find((entry) => entry.endsWith(suffix))
|
|
if (fileName) {
|
|
return join(directory, fileName)
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function readActivity(recordValue: JsonRecord):
|
|
| {
|
|
id: string
|
|
description?: string
|
|
kind: 'started' | 'interacted' | 'interrupted'
|
|
startedAt: number
|
|
}
|
|
| undefined {
|
|
if (recordValue.type !== 'event_msg') {
|
|
return undefined
|
|
}
|
|
const payload = record(recordValue.payload)
|
|
if (payload?.type !== 'sub_agent_activity') {
|
|
return undefined
|
|
}
|
|
const id = typeof payload.agent_thread_id === 'string' ? payload.agent_thread_id.trim() : ''
|
|
const rawKind = typeof payload.kind === 'string' ? payload.kind.toLowerCase() : ''
|
|
if (
|
|
!SAFE_THREAD_ID.test(id) ||
|
|
(rawKind !== 'started' && rawKind !== 'interacted' && rawKind !== 'interrupted')
|
|
) {
|
|
return undefined
|
|
}
|
|
return {
|
|
id,
|
|
description:
|
|
typeof payload.agent_path === 'string' ? payload.agent_path.trim() || undefined : undefined,
|
|
kind: rawKind,
|
|
startedAt:
|
|
typeof payload.occurred_at_ms === 'number' && Number.isFinite(payload.occurred_at_ms)
|
|
? payload.occurred_at_ms
|
|
: Date.now()
|
|
}
|
|
}
|
|
|
|
/** Latest model from the child's own `turn_context` records. A child can be
|
|
* launched on a different model than its parent, so this is read from the
|
|
* child rollout rather than inherited. */
|
|
function readChildModel(records: JsonRecord[]): string | undefined {
|
|
let model: string | undefined
|
|
for (const recordValue of records) {
|
|
if (recordValue.type !== 'turn_context') {
|
|
continue
|
|
}
|
|
const payload = record(recordValue.payload)
|
|
const value = typeof payload?.model === 'string' ? payload.model.trim() : ''
|
|
if (value) {
|
|
model = value
|
|
}
|
|
}
|
|
return model
|
|
}
|
|
|
|
function normalizedTranscriptPath(transcriptPath: string | undefined): string | undefined {
|
|
const normalizedPath = transcriptPath?.trim()
|
|
return normalizedPath && isAbsolute(normalizedPath) && extname(normalizedPath) === '.jsonl'
|
|
? normalizedPath
|
|
: 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) {
|
|
if (recordValue.type !== 'event_msg') {
|
|
continue
|
|
}
|
|
const payload = record(recordValue.payload)
|
|
if (payload?.type === 'task_started') {
|
|
complete = false
|
|
} else if (payload?.type === 'task_complete') {
|
|
complete = true
|
|
}
|
|
}
|
|
return complete
|
|
}
|
|
|
|
export function createCodexSubagentTranscriptState(): CodexSubagentTranscriptState {
|
|
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 {
|
|
return Boolean(state && state.subagents.size > 0)
|
|
}
|
|
|
|
export function reconcileCodexSubagentTranscript(
|
|
state: CodexSubagentTranscriptState,
|
|
roster: CodexSubagentRoster,
|
|
transcriptPath: string | undefined
|
|
): boolean {
|
|
const normalizedPath = normalizedTranscriptPath(transcriptPath)
|
|
if (!normalizedPath) {
|
|
return false
|
|
}
|
|
let changed = false
|
|
if (state.parent.filePath !== normalizedPath) {
|
|
for (const id of state.subagents.keys()) {
|
|
finishCodexSubagent(roster, id)
|
|
}
|
|
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()
|
|
// Why: a different rollout is a different session, so its predecessor's reviewer is void.
|
|
state.approvalsReviewer = undefined
|
|
}
|
|
const parentRecords = readJsonlCursor(state.parent, isCodexStatusTranscriptLine)
|
|
changed ||=
|
|
Boolean(parentRecords?.length) ||
|
|
(parentRecords === undefined && state.approvalsReviewer !== undefined)
|
|
reconcileCodexTranscriptTurn(state.rootTurn, parentRecords ?? [])
|
|
// A stale reviewer must never turn an unreadable rollout into a hidden prompt.
|
|
state.approvalsReviewer =
|
|
parentRecords === undefined
|
|
? 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
|
|
}
|
|
if (activity.kind === 'interrupted') {
|
|
finishCodexSubagent(roster, activity.id)
|
|
state.subagents.delete(activity.id)
|
|
continue
|
|
}
|
|
const tracked = state.subagents.get(activity.id) ?? {
|
|
offset: 0,
|
|
carry: '',
|
|
startedAt: activity.startedAt
|
|
}
|
|
tracked.description = activity.description ?? tracked.description
|
|
state.subagents.set(activity.id, tracked)
|
|
upsertCodexSubagent(
|
|
roster,
|
|
activity.id,
|
|
{ description: tracked.description, state: 'working' },
|
|
tracked.startedAt
|
|
)
|
|
}
|
|
const entriesByDirectory = new Map<string, string[]>()
|
|
const now = Date.now()
|
|
for (const [id, tracked] of state.subagents) {
|
|
if (!tracked.filePath) {
|
|
tracked.filePath = resolveChildTranscript(
|
|
normalizedPath,
|
|
id,
|
|
tracked.startedAt,
|
|
entriesByDirectory
|
|
)
|
|
}
|
|
const records = readJsonlCursor(tracked, isCodexStatusTranscriptLine)
|
|
changed ||= Boolean(records?.length)
|
|
if (!records) {
|
|
// Why: a rollout that never appears (or is deleted) has no completion event, so time-box it instead of leaking a working row.
|
|
tracked.filePath = undefined
|
|
tracked.unresolvedSince ??= now
|
|
if (now - tracked.unresolvedSince <= CHILD_UNREADABLE_GRACE_MS) {
|
|
continue
|
|
}
|
|
} else {
|
|
tracked.unresolvedSince = undefined
|
|
tracked.model = readChildModel(records) ?? tracked.model
|
|
// Why: re-applied every reconcile, not just on discovery — the parent's
|
|
// own activity upsert can rebuild this child's roster entry, which would
|
|
// otherwise drop a model found on an earlier poll.
|
|
setCodexSubagentModel(roster, id, tracked.model)
|
|
if (!childIsComplete(records)) {
|
|
continue
|
|
}
|
|
}
|
|
changed = true
|
|
finishCodexSubagent(roster, id)
|
|
state.subagents.delete(id)
|
|
}
|
|
return changed
|
|
}
|