mirror of
https://github.com/stablyai/orca.git
synced 2026-11-06 12:07:58 +00:00
* Let a Codex chat stop a sub-agent from the background-tasks strip A Codex sub-agent showed in the background-tasks strip with no Stop, and the chat's own Stop interrupts only the main turn, so a helper ran on until it finished. Codex's app-server takes `turn/interrupt` for any loaded thread, sub-agent threads included, so a sub-agent row's Stop now interrupts the turn Orca saw that sub-agent run, through the same background Stop route Codex commands use. Codex's -32600 refusal (no active turn, another turn, thread not found) means the run the row showed is over, so the Stop is quiet; any other refusal says the task wasn't stopped; a timeout or lost contact is unconfirmed. A live run showed Codex holds an interrupt that lands as the turn ends until the sub-agent's next turn ends, so a timeout on a run the session has since seen end is quiet too. Older clients' `codex-agent:` rows name no host record and stay unstoppable. * Stop a Codex sub-agent's own sub-agents and commands with it, and hold Stopping per run A sub-agent Stop interrupted only that helper's turn, so the commands it was running (Codex keeps a turn's processes when the turn is interrupted) and the helpers it had spawned kept going, and its row stayed in the strip. The Stop now interrupts the helper's run and every descendant's run, then terminates the processes those threads hold where the app-server has background terminals. The interrupt goes through interruptCodexTurn, and a small codex-background-task-stops module composes the sub-agent and terminal stops, so the terminals module is terminals only again. A Codex helper keeps one row id (its thread id) across runs, so a confirmed Stop's held button carried over to the helper's next run. Both clients now hold it for the run it was pressed on, read from the row's invocation. * Test that a sub-agent Stop leaves siblings alone and skips terminates after a timeout Adds direct tests of the per-run Stop hold (including an older host's roster), matches the desktop hold comment to mobile's, and reuses the one 10 s bound.
109 lines
4.6 KiB
TypeScript
109 lines
4.6 KiB
TypeScript
// The background-tasks strip's view of one session's wire state.
|
|
//
|
|
// Hosts select background work; newer clients also exclude their current turn's commands
|
|
// from an older host's roster. Child agents and earlier commands stay visible mid-turn.
|
|
|
|
import type {
|
|
AgentSessionBackgroundTask,
|
|
AgentSessionBackgroundTaskState
|
|
} from './agent-session-wire'
|
|
import { agentChildWorkLiveness } from './agent-status-child-work-liveness'
|
|
import type { AgentChildWorkView } from './agent-status-child-work-view'
|
|
|
|
export type StructuredSessionBackgroundTasksView = {
|
|
/** Background rows remain visible during a foreground turn. */
|
|
show: boolean
|
|
/** Idle-only: gates the animated monitoring indicator and conversation
|
|
* commands, never the strip itself. A running turn owns the voice. */
|
|
isMonitoring: boolean
|
|
tasks: AgentSessionBackgroundTask[]
|
|
settledTasks: AgentSessionBackgroundTask[]
|
|
/** The host's child records, when it publishes them; the rows then read these. */
|
|
children?: AgentChildWorkView[]
|
|
supportsStop: boolean
|
|
supportsStopAll: boolean
|
|
}
|
|
|
|
/** One shared empty list: a roster with no rows of a kind keeps the strip's memo on every render. */
|
|
const NO_TASKS: AgentSessionBackgroundTask[] = []
|
|
const NO_FOREGROUND_COMMANDS: ReadonlySet<string> = new Set()
|
|
|
|
function withoutForeground<T>(
|
|
rows: T[],
|
|
foreground: ReadonlySet<string>,
|
|
id: (row: T) => string | undefined
|
|
): T[] {
|
|
const visible = (row: T) => !foreground.has(id(row) ?? '')
|
|
return rows.every(visible) ? rows : rows.filter(visible)
|
|
}
|
|
|
|
export function structuredSessionBackgroundTasksView(
|
|
backgroundTasks: AgentSessionBackgroundTaskState | null | undefined,
|
|
turnId: string | null,
|
|
foregroundCommands: ReadonlySet<string> = NO_FOREGROUND_COMMANDS
|
|
): StructuredSessionBackgroundTasksView {
|
|
const monitoring = backgroundTasks?.state === 'monitoring'
|
|
// Decoded once where the frame entered the client's state, so its identity holds between frames.
|
|
const children =
|
|
monitoring && backgroundTasks.children
|
|
? withoutForeground(backgroundTasks.children, foregroundCommands, (child) => child.providerId)
|
|
: undefined
|
|
const tasks = withoutForeground(
|
|
backgroundTasks?.tasks ?? NO_TASKS,
|
|
foregroundCommands,
|
|
(task) => task.id
|
|
)
|
|
// Only running children arrive; a roster with none holds nothing open either way.
|
|
// An older host's roster whose listed rows have all finished shows nothing that runs.
|
|
const onlyFinished = !children && !tasks.length && Boolean(backgroundTasks?.settledTasks?.length)
|
|
const onlyForeground =
|
|
foregroundCommands.size > 0 &&
|
|
!children?.length &&
|
|
!tasks.length &&
|
|
Boolean(backgroundTasks?.children?.length || backgroundTasks?.tasks?.length)
|
|
const show = monitoring && !onlyFinished && !onlyForeground
|
|
const liveWork = children ? agentChildWorkLiveness(children) !== null : show
|
|
return {
|
|
show,
|
|
isMonitoring: turnId === null && liveWork,
|
|
tasks,
|
|
// Running work only, from any host: an older host's finished rows are not shown either.
|
|
settledTasks: NO_TASKS,
|
|
...(children ? { children } : {}),
|
|
supportsStop: backgroundTasks?.supportsTaskStop === true,
|
|
// Absent means the host predates the field and does accept an untargeted
|
|
// stop; only a host that says `false` has none to offer.
|
|
supportsStopAll: backgroundTasks?.supportsStopAll !== false
|
|
}
|
|
}
|
|
|
|
/** The run of the task the strip lists under `providerId`, live or settled: its record's
|
|
* invocation, since a provider can reuse one id across a child's runs (a Codex sub-agent's thread),
|
|
* or the id itself on an older host's roster. Null when the strip does not list it. */
|
|
export function structuredSessionListedTaskRun(
|
|
view: StructuredSessionBackgroundTasksView,
|
|
providerId: string
|
|
): string | null {
|
|
if (!view.show) {
|
|
return null
|
|
}
|
|
if (!view.children) {
|
|
return view.tasks.some((task) => task.id === providerId) ? providerId : null
|
|
}
|
|
const child = view.children.find((candidate) => candidate.providerId === providerId)
|
|
return child ? `${child.invocation.invocationId}#${child.invocation.generation}` : null
|
|
}
|
|
|
|
/** The host-confirmed Stops, by provider id and the run each was pressed on, whose run the strip
|
|
* still lists, each holding its row's button; the same map when none has left, so a caller can
|
|
* tell when to drop the rest. */
|
|
export function structuredSessionConfirmedStopsStillListed(
|
|
view: StructuredSessionBackgroundTasksView,
|
|
confirmed: ReadonlyMap<string, string>
|
|
): ReadonlyMap<string, string> {
|
|
const listed = [...confirmed].filter(
|
|
([providerId, run]) => structuredSessionListedTaskRun(view, providerId) === run
|
|
)
|
|
return listed.length === confirmed.size ? confirmed : new Map(listed)
|
|
}
|