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.
142 lines
3.8 KiB
TypeScript
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
|
|
}
|