mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
* fix(agents): tell Codex subagent rows apart by task name instead of role A Codex subagent's task name only reached the sidebar when one early read of its rollout succeeded; otherwise every child fell back to its role, so two workers both read "worker". The child poll now re-reads an unnamed child's own rollout, the first-line read allows the existing 256 KiB line cap, and a child with no task name or nickname carries the tail of its id, as Codex's own agent list does. Labels keep the path below the root so nested children with the same leaf stay distinct. * fix(agents): retry an unnamed Codex child's own rollout without a parent transcript A relay restart can leave a child hook as the only evidence, sent before the child's rollout was readable. Keep that path as pending identity work and retry it through the existing transcript poll, which now stays scheduled while a child still owes its name. * fix(agents): carry the id tail to every child-row surface and bound identity retries - Full-mode sidebar rows and dashboard subagent cards use the same suffixed label as compact rows. - A role a host publishes as the name (Claude's subagent_type) no longer counts as a name. - A final identity miss no longer re-arms polling; retries take turns, eight children per poll. - Replace an error cast with a type guard. * fix(agents): keep every identity read inside the per-poll budget The parent-rollout poll now queues an unnamed child for the bounded, rotating identity retries instead of reading it directly, and full-mode rows keep the id tail for a child with no name or role. * fix(agents): keep plain names on task-only rosters and guard the queued rollout path A host that publishes only a task roster carries no role, so its names can't be told from roles and get no id tail. Update the sidebar lineage test for a role-only child's id tail. * fix(agents): tell role-named tasks apart on task-only rosters A host that publishes only a task roster sends Claude's subagent_type as the task name, so two such tasks read identically. A named agent task with no description now carries its id tail; an unnamed one keeps its kind label. * fix(agents): a task path found meanwhile wins over a queued header read Placeholder labels (e.g. unknown) count as unnamed when queueing, and a child the parent rollout named before its turn drops its pending read instead of being overwritten by the header's nickname.
332 lines
11 KiB
TypeScript
332 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 { codexSubagentPathLabel } from './codex-agent-path'
|
||
import {
|
||
finishCodexSubagent,
|
||
queueCodexSubagentIdentity,
|
||
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
|
||
/** Retain the child’s own model across incremental reads; turn_context appears once per turn. */
|
||
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:
|
||
codexSubagentPathLabel(typeof payload.agent_path === 'string' ? payload.agent_path : null) ??
|
||
undefined,
|
||
kind: rawKind,
|
||
startedAt:
|
||
typeof payload.occurred_at_ms === 'number' && Number.isFinite(payload.occurred_at_ms)
|
||
? payload.occurred_at_ms
|
||
: Date.now()
|
||
}
|
||
}
|
||
|
||
/** Read model from child turn_context; it may differ from the parent’s. */
|
||
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)
|
||
// Hook-only and restored children may predate the bounded parent transcript read.
|
||
for (const [id, child] of roster) {
|
||
if (!state.subagents.has(id)) {
|
||
state.subagents.set(id, { offset: 0, carry: '', startedAt: child.startedAt })
|
||
}
|
||
}
|
||
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
|
||
// A missed or early hook read must not leave the child unnamed; the poll's bounded retries read it.
|
||
if (tracked.filePath) {
|
||
queueCodexSubagentIdentity(roster, id, tracked.filePath)
|
||
}
|
||
tracked.model = readChildModel(records) ?? tracked.model
|
||
// Reapply each poll because parent activity can rebuild the child row and erase its discovered model.
|
||
setCodexSubagentModel(roster, id, tracked.model)
|
||
if (!childIsComplete(records)) {
|
||
continue
|
||
}
|
||
}
|
||
changed = true
|
||
finishCodexSubagent(roster, id)
|
||
state.subagents.delete(id)
|
||
}
|
||
return changed
|
||
}
|