mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 16:02:49 +00:00
* fix: preserve Codex status on ambiguous Ctrl+C input * fix: confirm Codex turn cancellations from host rollout records * fix: keep ephemeral side hooks separate from the Codex main turn * perf: watch active Codex rollouts and skip unrelated records * fix: retain confirmed Codex cancellation across late relay events
290 lines
9.4 KiB
TypeScript
290 lines
9.4 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 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
|
|
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 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 },
|
|
subagents: new Map(),
|
|
reviewerCursorsByPath: new Map(),
|
|
reviewersByPath: new Map()
|
|
}
|
|
}
|
|
|
|
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.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 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
|
|
}
|