mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 00:02:43 +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>
220 lines
7.6 KiB
TypeScript
220 lines
7.6 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
|
|
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
|
|
import { CodexBackgroundCommandTracker } from './codex-background-command-tracker'
|
|
import {
|
|
CodexItemStreamRetention,
|
|
MAX_CODEX_ITEM_STREAM_METADATA_BYTES
|
|
} from './codex-item-stream-retention'
|
|
import { CodexJournalItems } from './codex-structured-journal-items'
|
|
import { settleCodexJournalTurn } from './codex-structured-journal-settlement'
|
|
|
|
function command(threadId: string, id: string, method = 'item/started') {
|
|
return {
|
|
threadId,
|
|
method,
|
|
params: {
|
|
turnId: 'turn',
|
|
item: {
|
|
type: 'commandExecution',
|
|
id,
|
|
source: 'unifiedExecStartup',
|
|
command: `sleep 30 # ${threadId}/${id}`,
|
|
cwd: '/workspace',
|
|
status: method === 'item/completed' ? 'completed' : 'inProgress',
|
|
...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: 'BEFORE\nAFTER\n' } : {})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function fixture(maxMetadataBytes?: number) {
|
|
const rows = new Map<string, AgentJournalItemBody>()
|
|
const scheduled = new Set<() => void>()
|
|
const sink = {
|
|
appendItem: (
|
|
identity: Parameters<typeof agentJournalItemKey>[0],
|
|
body: AgentJournalItemBody
|
|
) => {
|
|
rows.set(agentJournalItemKey(identity), body)
|
|
},
|
|
appendTombstone() {},
|
|
publish() {}
|
|
}
|
|
const items = new CodexJournalItems(
|
|
{
|
|
sink,
|
|
maxMetadataBytes,
|
|
schedule: (run) => {
|
|
scheduled.add(run)
|
|
return () => {
|
|
scheduled.delete(run)
|
|
}
|
|
}
|
|
},
|
|
() => 'turn',
|
|
() => {}
|
|
)
|
|
return { items, sink, rows, scheduled }
|
|
}
|
|
|
|
describe('persistent command retention', () => {
|
|
it('does not rebuild unchanged persistent output on every later lifecycle flush', () => {
|
|
const { items } = fixture()
|
|
const event = command('root', 'quiet')
|
|
items.handle(event)
|
|
items.streams.handle('root', 'item/commandExecution/outputDelta', {
|
|
itemId: 'quiet',
|
|
delta: 'retained-prefix'
|
|
})
|
|
items.streams.flush()
|
|
const originalJoin = Array.prototype.join
|
|
let retainedJoins = 0
|
|
const spy = vi
|
|
.spyOn(Array.prototype, 'join')
|
|
.mockImplementation(function (this: unknown[], separator) {
|
|
if (this[0] === 'retained-prefix') {
|
|
retainedJoins += 1
|
|
}
|
|
return originalJoin.call(this, separator)
|
|
})
|
|
try {
|
|
for (let index = 0; index < 100; index += 1) {
|
|
items.streams.flush()
|
|
}
|
|
} finally {
|
|
spy.mockRestore()
|
|
items.dispose()
|
|
}
|
|
expect(retainedJoins).toBe(0)
|
|
})
|
|
|
|
it('retains 448 live commands through completed turns, late output, and process completion', () => {
|
|
const { items, sink, rows, scheduled } = fixture()
|
|
const tracker = new CodexBackgroundCommandTracker('thread-0')
|
|
const events = Array.from({ length: 7 }, (_, thread) =>
|
|
Array.from({ length: 64 }, (_, index) => command(`thread-${thread}`, `exec-${index}`))
|
|
).flat()
|
|
for (const event of events) {
|
|
expect(tracker.canObserve(event)).toBe(true)
|
|
expect(items.handle(event)).toMatchObject({ admission: { accepted: true } })
|
|
tracker.observe(event)
|
|
expect(
|
|
items.streams.handle(event.threadId, 'item/commandExecution/outputDelta', {
|
|
turnId: 'turn',
|
|
itemId: event.params.item.id,
|
|
delta: 'BEFORE\n'
|
|
}).admission
|
|
).toEqual({ accepted: true })
|
|
}
|
|
for (let thread = 0; thread < 7; thread += 1) {
|
|
expect(
|
|
settleCodexJournalTurn({
|
|
sessionId: 'session',
|
|
threadId: `thread-${thread}`,
|
|
turnId: 'turn',
|
|
sink,
|
|
streams: items.streams,
|
|
activeItems: items.activeItems
|
|
})
|
|
).toEqual({ accepted: true })
|
|
}
|
|
expect(items.activeItems.size).toBe(448)
|
|
expect(items.streams.persistentCount).toBe(448)
|
|
expect(tracker.tasks()).toHaveLength(448)
|
|
expect(tracker.retainedMetadataBytes).toBeLessThan(256 * 1024)
|
|
for (const event of events) {
|
|
items.streams.handle(event.threadId, 'item/commandExecution/outputDelta', {
|
|
turnId: 'turn',
|
|
itemId: event.params.item.id,
|
|
delta: 'AFTER\n'
|
|
})
|
|
}
|
|
expect(items.streams.flush()).toBe(true)
|
|
for (const event of events) {
|
|
const key = agentJournalItemKey({
|
|
provider: 'orca',
|
|
clientMessageId: `codex-item:${event.threadId}:${event.params.item.id}`
|
|
})
|
|
expect(rows.get(key)).toMatchObject({
|
|
state: 'running',
|
|
input: { command: event.params.item.command, cwd: '/workspace' },
|
|
output: { head: 'BEFORE\nAFTER\n' }
|
|
})
|
|
const completed = command(event.threadId, event.params.item.id, 'item/completed')
|
|
expect(items.handle(completed)).toMatchObject({ admission: { accepted: true } })
|
|
tracker.observe(completed)
|
|
expect(rows.get(key)).toMatchObject({
|
|
state: 'completed',
|
|
output: { head: 'BEFORE\nAFTER\n' }
|
|
})
|
|
expect(items.streams.snapshot(event.threadId, event.params.item.id)).toBeNull()
|
|
}
|
|
expect(items.activeItems.size).toBe(0)
|
|
expect(items.streams.persistentCount).toBe(0)
|
|
expect(tracker.tasks()).toEqual([])
|
|
expect(tracker.retainedMetadataBytes).toBeLessThan(64 * 1024)
|
|
items.dispose()
|
|
tracker.clear()
|
|
expect(tracker.retainedMetadataBytes).toBe(0)
|
|
expect(scheduled.size).toBe(0)
|
|
})
|
|
|
|
it('rejects command metadata exhaustion before appending or evicting live state and frees it on completion', () => {
|
|
const { items, rows } = fixture(800)
|
|
const first = command('root', 'first')
|
|
const second = command('root', 'second')
|
|
expect(items.handle(first)).toMatchObject({ admission: { accepted: true } })
|
|
const prior = [...rows]
|
|
expect(items.handle(second)).toMatchObject({ admission: { accepted: false, reason: 'failed' } })
|
|
expect([...rows]).toEqual(prior)
|
|
expect(items.activeItems.size).toBe(1)
|
|
expect(items.handle(command('root', 'first', 'item/completed'))).toMatchObject({
|
|
admission: { accepted: true }
|
|
})
|
|
expect(items.handle(second)).toMatchObject({ admission: { accepted: true } })
|
|
items.dispose()
|
|
})
|
|
|
|
it('accounts metadata bytes instead of interpreting the item count as liveness', () => {
|
|
const retention = new CodexItemStreamRetention()
|
|
for (let index = 0; index < 448; index += 1) {
|
|
const item = command('root', `exec-${index}`).params.item
|
|
expect(
|
|
retention.retain(item.id, {
|
|
item,
|
|
identity: { provider: 'orca', clientMessageId: item.id }
|
|
})
|
|
).toBe(true)
|
|
}
|
|
expect(retention.size).toBe(448)
|
|
expect(retention.retainedBytes).toBeLessThan(256 * 1024)
|
|
expect(retention.retainedBytes).toBeLessThan(MAX_CODEX_ITEM_STREAM_METADATA_BYTES)
|
|
expect(retention.overCapacity).toBe(false)
|
|
expect(retention.oldestEvictable()).toBeUndefined()
|
|
retention.clear()
|
|
expect(retention.retainedBytes).toBe(0)
|
|
expect(retention.persistentSize).toBe(0)
|
|
})
|
|
|
|
it('retains startup provenance when large command metadata is bounded', () => {
|
|
const { items, sink } = fixture()
|
|
const event = command('root', 'large')
|
|
event.params.item.command = 'x'.repeat(128 * 1024)
|
|
expect(items.handle(event)).toMatchObject({ admission: { accepted: true } })
|
|
expect(
|
|
settleCodexJournalTurn({
|
|
sessionId: 'session',
|
|
threadId: 'root',
|
|
turnId: 'turn',
|
|
sink,
|
|
streams: items.streams,
|
|
activeItems: items.activeItems
|
|
})
|
|
).toEqual({ accepted: true })
|
|
expect(items.activeItems.size).toBe(1)
|
|
expect(items.streams.persistentCount).toBe(1)
|
|
items.dispose()
|
|
})
|
|
})
|