Files
orca/src/shared/codex-rollout-jsonl-cursor.ts
T
Brennan Benson 85a68bfa4f Codex subagent rows show their task names instead of all saying "worker" (#38491)
* 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.
2026-11-05 10:26:01 -08:00

142 lines
3.8 KiB
TypeScript

import {
closeSync,
constants,
fstatSync,
openSync,
readSync,
readdirSync,
statSync,
type Stats
} from 'node:fs'
const TRANSCRIPT_READ_MAX_BYTES = 1024 * 1024
const TRANSCRIPT_LINE_MAX_BYTES = 256 * 1024
const TRANSCRIPT_DIRECTORY_MAX_ENTRIES = 4096
export type JsonlCursor = {
filePath?: string
offset: number
carry: string
}
export type JsonRecord = Record<string, unknown>
export function record(value: unknown): JsonRecord | undefined {
return typeof value === 'object' && value !== null ? (value as JsonRecord) : undefined
}
/** 'pending' while the file or its first line is unwritten; 'unreadable' on other I/O failures; undefined once the line is final. */
export function readJsonlFirstRecord(
filePath: string
): JsonRecord | 'pending' | 'unreadable' | undefined {
let fd: number | undefined
try {
// Nonblocking open prevents a FIFO from stalling hook handling before the descriptor check.
fd = openSync(
filePath,
constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NONBLOCK)
)
if (!fstatSync(fd).isFile()) {
return undefined
}
const buffer = Buffer.allocUnsafe(TRANSCRIPT_LINE_MAX_BYTES)
const bytesRead = readSync(fd, buffer, 0, buffer.length, 0)
const newline = buffer.subarray(0, bytesRead).indexOf(0x0a)
if (newline === -1) {
return bytesRead < buffer.length ? 'pending' : undefined
}
return record(JSON.parse(buffer.toString('utf8', 0, newline)))
} catch (error) {
if (error instanceof SyntaxError) {
return undefined
}
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
? 'pending'
: 'unreadable'
} finally {
if (fd !== undefined) {
closeSync(fd)
}
}
}
/** Undefined distinguishes unreadable/vanished files from no new lines; lineFilter rejects raw substrings before JSON.parse. */
export function readJsonlCursor(
cursor: JsonlCursor,
lineFilter?: (line: string) => boolean
): JsonRecord[] | undefined {
if (!cursor.filePath) {
return undefined
}
let stats: Stats
try {
stats = statSync(cursor.filePath)
} catch {
return undefined
}
if (!stats.isFile()) {
return undefined
}
if (stats.size < cursor.offset) {
cursor.offset = 0
cursor.carry = ''
}
if (stats.size === cursor.offset) {
return []
}
const bytesToRead = Math.min(stats.size - cursor.offset, TRANSCRIPT_READ_MAX_BYTES)
const start = stats.size - cursor.offset > bytesToRead ? stats.size - bytesToRead : cursor.offset
const buffer = Buffer.allocUnsafe(bytesToRead)
let bytesRead = 0
let fd: number | undefined
try {
fd = openSync(cursor.filePath, 'r')
bytesRead = readSync(fd, buffer, 0, bytesToRead, start)
} catch {
return undefined
} finally {
if (fd !== undefined) {
closeSync(fd)
}
}
const skippedPrefix = start !== cursor.offset
const content = `${skippedPrefix ? '' : cursor.carry}${buffer.toString('utf8', 0, bytesRead)}`
const lines = content.split('\n')
cursor.offset = start + bytesRead
cursor.carry = lines.pop() ?? ''
if (skippedPrefix) {
lines.shift()
}
const records: JsonRecord[] = []
for (const line of lines) {
if (
(lineFilter && !lineFilter(line)) ||
Buffer.byteLength(line, 'utf8') > TRANSCRIPT_LINE_MAX_BYTES
) {
continue
}
try {
const parsed = record(JSON.parse(line) as unknown)
if (parsed) {
records.push(parsed)
}
} catch {
// A malformed rollout line must not block later lifecycle events.
}
}
return records
}
export function readTranscriptDirectory(directory: string): string[] {
let entries: string[]
try {
entries = readdirSync(directory)
} catch {
return []
}
if (entries.length > TRANSCRIPT_DIRECTORY_MAX_ENTRIES) {
entries = entries.slice(-TRANSCRIPT_DIRECTORY_MAX_ENTRIES)
}
return entries
}