mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* feat(native-chat): report Codex background tasks in the chat strip The background-tasks strip works for Claude only; a structured Codex session shows nothing in it. Feed it from the Codex app-server stream. The strip stands for work that OUTLIVED a turn, which is what the monitoring header, Claude's foreground suppression, and the conversation command gate all already assume. Codex has no `is_backgrounded` flag, so that fact is derived from the turn boundary: a `subAgentActivity` child or a primary-thread `commandExecution` becomes visible once the turn it belongs to completes and it is still unsettled. `turn/completed` only reveals a task here, never settles one — measured on `codex app-server` 0.153.4, a spawn_agent child reported `completed` 95.8s after its parent turn ended. Only a child's own activity kind settles it. Codex exposes no honest stop: `turn/interrupt` on a child ends its turn without emitting a terminal activity item and leaves its shell running. So the state carries a new optional `supportsStopAll: false`, the strip hides a control that could not act, and the blocked-command message asks the user to wait rather than to press a button that does not exist. * refactor(codex): move session teardown out of the structured adapter Merging main crossed the 300-line cap on `codex-structured-session-adapter.ts`: the rewind backend (#19235) and this branch's close-time strip clear both landed in it. The four close paths move verbatim into `codex-structured-session-teardown.ts`, where they funnel through one `settled` helper instead of repeating the notification-retry and background-task cleanup at each call site. No ratchet bump. Also normalize a background task's description once at receipt rather than on every projection; the roster is re-projected on each observed frame. * fix(codex): drop the shell row the journal already settles A `commandExecution` still `inProgress` when its turn ends was reported as a `command` task. But `settleCodexJournalTurn` writes exactly those items to the journal as `state: 'failed'` on `turn/completed` and forgets them, so the strip row would have claimed a shell was still running at the same instant Orca recorded that it was not — two surfaces contradicting each other about the same process. A subagent is the opposite case and stays: the roster pointedly does not sweep at a turn boundary, because children measurably outlive it. That leaves the producer making exactly one claim — these spawn_agent children are still live after their turn — which the durable roster row corroborates. * fix(native-chat): track Codex background execution lifetimes * fix(native-chat): keep running tool groups from claiming completion * Fix runtime catalog and capability expectation * fix(codex): keep a child's name on the command row that outlives it A child agent's commands stay hidden behind its agent row while the child works. Once the child's turn settles with a command still running, that command surfaces as its own row labelled from the raw command string, so 'long_probe' became "/bin/zsh -lc 'ping -c 300 127.0.0.1 > /dev/null'" at the moment that row was the only remaining signal for the work. Qualify a child's command row with the child's label. Resolved on read, so a label registered after the command still lands, and bounded by the existing description cap so admission accounting stays valid. Primary- thread commands are left unqualified: they have no child to name. --------- Co-authored-by: Merge Sim <sim@local>
283 lines
9.3 KiB
TypeScript
283 lines
9.3 KiB
TypeScript
import type {
|
|
AgentJournalCursor,
|
|
AgentJournalRenderItem,
|
|
AgentJournalSubmission
|
|
} from './agent-session-journal-types'
|
|
import type {
|
|
AgentSessionBackgroundTaskState,
|
|
AgentSessionSlashCommand,
|
|
AgentSessionHandoffStatus,
|
|
AgentSessionHistoryPage,
|
|
AgentSessionSubscribeEvent,
|
|
AgentSessionTurnActivity
|
|
} from './agent-session-wire'
|
|
|
|
export type StructuredAgentSessionState = {
|
|
epoch: string | null
|
|
cursor: AgentJournalCursor | null
|
|
fence: number | null
|
|
items: AgentJournalRenderItem[]
|
|
submissions: AgentJournalSubmission[]
|
|
hasOlder: boolean
|
|
status: 'idle' | 'loading' | 'ready' | 'error'
|
|
error?: string
|
|
handoff: AgentSessionHandoffStatus | null
|
|
backgroundTasks?: AgentSessionBackgroundTaskState | null
|
|
commands?: AgentSessionSlashCommand[] | null
|
|
activity?: AgentSessionTurnActivity | null
|
|
}
|
|
|
|
export type StructuredAgentSessionAction =
|
|
| { type: 'loading' }
|
|
| { type: 'error'; message: string }
|
|
| { type: 'handoff'; handoff: AgentSessionHandoffStatus }
|
|
| { type: 'event'; event: AgentSessionSubscribeEvent }
|
|
| { type: 'tail-page'; page: AgentSessionHistoryPage }
|
|
| { type: 'older-page'; requestedEpoch: string; page: AgentSessionHistoryPage }
|
|
|
|
export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = {
|
|
epoch: null,
|
|
cursor: null,
|
|
fence: null,
|
|
items: [],
|
|
submissions: [],
|
|
hasOlder: false,
|
|
status: 'idle',
|
|
handoff: null
|
|
}
|
|
|
|
const MAX_RETAINED_SUBMISSIONS = 256
|
|
|
|
function backgroundTaskStatesEqual(
|
|
left: AgentSessionBackgroundTaskState | null | undefined,
|
|
right: AgentSessionBackgroundTaskState | null | undefined
|
|
): boolean {
|
|
if (left === right) {
|
|
return true
|
|
}
|
|
if (
|
|
!left ||
|
|
!right ||
|
|
left.state !== right.state ||
|
|
left.supportsTaskStop !== right.supportsTaskStop ||
|
|
left.supportsStopAll !== right.supportsStopAll
|
|
) {
|
|
return false
|
|
}
|
|
if (left.tasks === right.tasks) {
|
|
return true
|
|
}
|
|
if (!left.tasks || !right.tasks || left.tasks.length !== right.tasks.length) {
|
|
return false
|
|
}
|
|
return left.tasks.every(
|
|
(task, index) =>
|
|
task.id === right.tasks?.[index]?.id &&
|
|
task.kind === right.tasks[index]?.kind &&
|
|
task.description === right.tasks[index]?.description
|
|
)
|
|
}
|
|
|
|
function replacePage(
|
|
page: AgentSessionHistoryPage,
|
|
fence: number,
|
|
handoff?: AgentSessionHandoffStatus,
|
|
backgroundTasks?: AgentSessionBackgroundTaskState | null,
|
|
activity?: AgentSessionTurnActivity | null
|
|
): StructuredAgentSessionState {
|
|
return {
|
|
epoch: page.epoch,
|
|
cursor: page.liveCursor ?? page.window.nextCursor,
|
|
fence,
|
|
items: [...page.items].sort((left, right) => left.sequence - right.sequence),
|
|
submissions: page.submissions,
|
|
hasOlder: page.hasOlder,
|
|
status: 'ready',
|
|
handoff: handoff ?? null,
|
|
activity: activity ?? null,
|
|
...(backgroundTasks !== undefined
|
|
? { backgroundTasks }
|
|
: page.backgroundTasks !== undefined
|
|
? { backgroundTasks: page.backgroundTasks }
|
|
: {})
|
|
}
|
|
}
|
|
|
|
function mergeItems(
|
|
current: readonly AgentJournalRenderItem[],
|
|
incoming: readonly AgentJournalRenderItem[],
|
|
removedIds: readonly string[]
|
|
): AgentJournalRenderItem[] {
|
|
const removed = new Set(removedIds)
|
|
const byId = new Map(
|
|
current.filter((item) => !removed.has(item.itemId)).map((item) => [item.itemId, item])
|
|
)
|
|
for (const item of incoming) {
|
|
const prior = byId.get(item.itemId)
|
|
if (!prior || item.revision >= prior.revision) {
|
|
byId.set(item.itemId, item)
|
|
}
|
|
}
|
|
return [...byId.values()].sort((left, right) => left.sequence - right.sequence)
|
|
}
|
|
|
|
function mergeSubmissions(
|
|
current: readonly AgentJournalSubmission[],
|
|
incoming: readonly AgentJournalSubmission[]
|
|
): AgentJournalSubmission[] {
|
|
const byId = new Map(current.map((submission) => [submission.clientMessageId, submission]))
|
|
for (const submission of incoming) {
|
|
byId.set(submission.clientMessageId, submission)
|
|
}
|
|
return [...byId.values()]
|
|
.sort((left, right) => left.submittedAt - right.submittedAt)
|
|
.slice(-MAX_RETAINED_SUBMISSIONS)
|
|
}
|
|
|
|
export function reduceStructuredAgentSession(
|
|
state: StructuredAgentSessionState,
|
|
action: StructuredAgentSessionAction
|
|
): StructuredAgentSessionState {
|
|
if (action.type === 'loading') {
|
|
// Keep the last transcript visible while a reconnect rehydrates the stream.
|
|
return { ...state, status: 'loading', error: undefined }
|
|
}
|
|
if (action.type === 'error') {
|
|
return { ...state, status: 'error', error: action.message }
|
|
}
|
|
if (action.type === 'handoff') {
|
|
return { ...state, handoff: action.handoff }
|
|
}
|
|
if (action.type === 'tail-page') {
|
|
const pageCursor = action.page.liveCursor ?? action.page.window.newest
|
|
// An equal cursor means the page holds nothing the stream has not already
|
|
// delivered; replacing would throw away paged-in older items mid-scroll.
|
|
if (
|
|
state.epoch === action.page.epoch &&
|
|
state.cursor &&
|
|
(!pageCursor || pageCursor.sequence <= state.cursor.sequence)
|
|
) {
|
|
const backgroundTasksChanged =
|
|
action.page.backgroundTasks !== undefined &&
|
|
!backgroundTaskStatesEqual(action.page.backgroundTasks, state.backgroundTasks)
|
|
if (
|
|
pageCursor?.sequence === state.cursor.sequence &&
|
|
((action.page.fence !== undefined && action.page.fence !== state.fence) ||
|
|
backgroundTasksChanged)
|
|
) {
|
|
return {
|
|
...state,
|
|
...(action.page.fence !== undefined ? { fence: action.page.fence } : {}),
|
|
...(action.page.backgroundTasks !== undefined
|
|
? { backgroundTasks: action.page.backgroundTasks }
|
|
: {}),
|
|
status: 'ready',
|
|
error: undefined
|
|
}
|
|
}
|
|
return state
|
|
}
|
|
const sameEpoch = state.epoch === action.page.epoch
|
|
return {
|
|
epoch: action.page.epoch,
|
|
cursor: action.page.liveCursor ?? null,
|
|
fence: action.page.fence ?? null,
|
|
items: action.page.items,
|
|
submissions: sameEpoch
|
|
? mergeSubmissions(state.submissions, action.page.submissions)
|
|
: action.page.submissions,
|
|
hasOlder: action.page.hasOlder,
|
|
status: 'ready',
|
|
handoff: state.handoff,
|
|
...(sameEpoch ? { commands: state.commands } : {}),
|
|
...(sameEpoch && state.activity !== undefined ? { activity: state.activity } : {}),
|
|
...(action.page.backgroundTasks !== undefined
|
|
? { backgroundTasks: action.page.backgroundTasks }
|
|
: state.backgroundTasks !== undefined
|
|
? { backgroundTasks: state.backgroundTasks }
|
|
: {})
|
|
}
|
|
}
|
|
if (action.type === 'older-page') {
|
|
if (state.epoch !== action.requestedEpoch || action.page.epoch !== action.requestedEpoch) {
|
|
return state
|
|
}
|
|
return {
|
|
...state,
|
|
items: mergeItems(state.items, action.page.items, action.page.removedItemIds),
|
|
submissions: mergeSubmissions(state.submissions, action.page.submissions),
|
|
hasOlder: action.page.hasOlder
|
|
}
|
|
}
|
|
const event = action.event
|
|
if (event.type === 'end') {
|
|
return state
|
|
}
|
|
if (event.type === 'snapshot' || event.type === 'reset') {
|
|
return {
|
|
...replacePage(event.page, event.fence, event.handoff, event.backgroundTasks, event.activity),
|
|
commands: event.commands
|
|
}
|
|
}
|
|
if (state.epoch !== event.batch.cursor.epoch) {
|
|
return state
|
|
}
|
|
if (state.cursor && event.batch.cursor.sequence < state.cursor.sequence) {
|
|
return state
|
|
}
|
|
const backgroundTasks =
|
|
event.backgroundTasks !== undefined ? event.backgroundTasks : state.backgroundTasks
|
|
const activity = event.activity !== undefined ? event.activity : state.activity
|
|
const journalUnchanged =
|
|
event.batch.items.length === 0 &&
|
|
event.batch.removedItemIds.length === 0 &&
|
|
event.batch.submissions.length === 0
|
|
if (
|
|
event.batch.cursor.sequence === state.cursor?.sequence &&
|
|
journalUnchanged &&
|
|
(event.fence === undefined || event.fence === state.fence) &&
|
|
(event.handoff === undefined || event.handoff === state.handoff) &&
|
|
(event.commands === undefined || event.commands === state.commands) &&
|
|
backgroundTaskStatesEqual(backgroundTasks, state.backgroundTasks) &&
|
|
activity?.turnId === state.activity?.turnId &&
|
|
activity?.text === state.activity?.text &&
|
|
state.status === 'ready' &&
|
|
state.error === undefined
|
|
) {
|
|
return state
|
|
}
|
|
return {
|
|
...state,
|
|
cursor: event.batch.cursor,
|
|
fence: event.fence ?? state.fence,
|
|
items: journalUnchanged
|
|
? state.items
|
|
: mergeItems(state.items, event.batch.items, event.batch.removedItemIds),
|
|
submissions: journalUnchanged
|
|
? state.submissions
|
|
: mergeSubmissions(state.submissions, event.batch.submissions),
|
|
status: 'ready',
|
|
error: undefined,
|
|
handoff: event.handoff ?? state.handoff,
|
|
commands: event.commands !== undefined ? event.commands : state.commands,
|
|
...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
|
|
...(activity !== undefined ? { activity } : {})
|
|
}
|
|
}
|
|
|
|
export function oldestStructuredAgentSessionCursor(
|
|
state: StructuredAgentSessionState
|
|
): AgentJournalCursor | null {
|
|
const oldest = state.items[0]
|
|
return state.epoch && oldest ? { epoch: state.epoch, sequence: oldest.sequence } : null
|
|
}
|
|
|
|
export function shouldAdvanceStructuredResumeCursor(
|
|
current: AgentJournalCursor | null,
|
|
incoming: AgentJournalCursor
|
|
): boolean {
|
|
return (
|
|
current === null || (current.epoch === incoming.epoch && incoming.sequence >= current.sequence)
|
|
)
|
|
}
|