diff --git a/mobile/src/session/MobileNativeChatBackgroundTasks.test.tsx b/mobile/src/session/MobileNativeChatBackgroundTasks.test.tsx index e20551af489..332aa29512b 100644 --- a/mobile/src/session/MobileNativeChatBackgroundTasks.test.tsx +++ b/mobile/src/session/MobileNativeChatBackgroundTasks.test.tsx @@ -324,6 +324,32 @@ describe('MobileNativeChatBackgroundTasks', () => { expect(stopButtons(mounted)[0]!.props.disabled).toBe(false) }) + it("offers Stop again on a helper's next run, which reuses the stopped run's row id", async () => { + let finish: (result: StopResult) => void = () => {} + const stop = vi.fn(() => new Promise((resolve) => (finish = resolve))) + const running = { state: 'monitoring' as const, supportsTaskStop: true } + const mounted = mount(tasksFor({ ...running, children: [view('a')] }, { stop })) + expand(mounted) + act(() => stopButtons(mounted)[0]!.props.onPress()) + await act(async () => finish(STOPPED)) + expect(stopButtons(mounted)[0]!.props.disabled).toBe(true) + // The helper's next run, with no render between: same provider id, a new invocation. + act(() => { + mounted.update( + createElement(MobileNativeChatBackgroundTasks, { + tasks: tasksFor( + { + ...running, + children: [view('a', { invocation: { invocationId: 'turn-2', generation: 2 } })] + }, + { stop } + ) + }) + ) + }) + expect(stopButtons(mounted)[0]!.props.disabled).toBe(false) + }) + it('stops a backgrounded Codex command by its own id, and offers none on the subagent beside it', () => { const stop = vi.fn(async () => REJECTED) const mounted = mount( diff --git a/mobile/src/session/use-mobile-background-task-stops.ts b/mobile/src/session/use-mobile-background-task-stops.ts index 7f989d61d5d..56c752c692c 100644 --- a/mobile/src/session/use-mobile-background-task-stops.ts +++ b/mobile/src/session/use-mobile-background-task-stops.ts @@ -1,10 +1,14 @@ import { useRef, useState } from 'react' -import { structuredSessionConfirmedStopsStillListed } from '../../../src/shared/structured-session-background-tasks-view' +import { + structuredSessionConfirmedStopsStillListed, + structuredSessionListedTaskRun +} from '../../../src/shared/structured-session-background-tasks-view' import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks' export type MobileBackgroundTaskStopping = { taskIds: ReadonlySet; all: boolean } const NO_TASKS: ReadonlySet = new Set() +const NO_CONFIRMED_STOPS: ReadonlyMap = new Map() const NOT_STOPPING: MobileBackgroundTaskStopping = { taskIds: NO_TASKS, all: false } /** The strip's Stop presses: which buttons hold, and the press that sends one. */ @@ -14,8 +18,9 @@ export function useMobileBackgroundTaskStops( const { view, stop } = tasks const [stopping, setStopping] = useState(NOT_STOPPING) const stoppingRef = useRef(NOT_STOPPING) - // Stops the host confirmed: each row keeps its stopping button until it leaves the strip. - const [confirmed, setConfirmed] = useState>(NO_TASKS) + // Stops the host confirmed, with the run each was pressed on: each row keeps its stopping button + // until that run leaves the strip. + const [confirmed, setConfirmed] = useState>(NO_CONFIRMED_STOPS) const confirmedListed = structuredSessionConfirmedStopsStillListed(view, confirmed) if (confirmedListed !== confirmed) { // A row that left and came back is a task the Stop did not end, so it offers Stop again. @@ -32,14 +37,16 @@ export function useMobileBackgroundTaskStops( if (taskId ? current.taskIds.has(taskId) : current.all) { return } + // A provider may reuse the row's id for the task's next run, which this Stop did not end. + const run = taskId ? structuredSessionListedTaskRun(view, taskId) : null updateStopping({ taskIds: taskId ? new Set([...current.taskIds, taskId]) : current.taskIds, all: taskId ? current.all : true }) void stop(taskId) .then((result) => { - if (taskId && result.status === 'accepted' && result.value.cancelled) { - setConfirmed((held) => new Set([...held, taskId])) + if (taskId && run !== null && result.status === 'accepted' && result.value.cancelled) { + setConfirmed((held) => new Map([...held, [taskId, run]])) } }) .finally(() => { @@ -54,7 +61,7 @@ export function useMobileBackgroundTaskStops( return { stopping: confirmedListed.size - ? { ...stopping, taskIds: new Set([...stopping.taskIds, ...confirmedListed]) } + ? { ...stopping, taskIds: new Set([...stopping.taskIds, ...confirmedListed.keys()]) } : stopping, onStop } diff --git a/src/main/codex/codex-background-task-stops.ts b/src/main/codex/codex-background-task-stops.ts new file mode 100644 index 00000000000..3e8f1599398 --- /dev/null +++ b/src/main/codex/codex-background-task-stops.ts @@ -0,0 +1,89 @@ +// A Codex chat's background Stop: a sub-agent row stops by interrupting its run and its +// descendants' runs (`codex-subagent-interrupts.ts`), and a command row, or a process one of those +// sub-agents holds, by its app-server's background terminals (`codex-background-terminals.ts`). + +import { CodexAppServerTimeoutError } from './codex-app-server-session' +import { + boundedTimeout, + terminateCodexBackgroundTerminals, + type CodexBackgroundTerminal +} from './codex-background-terminals' +import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' +import { interruptCodexSubagents } from './codex-subagent-interrupts' +import type { AgentSessionBackgroundTaskStops } from '../../shared/agent-child-work-stop-targets' + +type StopTally = { stopped: number; survived: boolean } + +/** A sub-agent stops by its own turn's interrupt on any Codex; a command row offers its Stop only + * once the app-server proved it can terminate background terminals. */ +export function codexBackgroundTaskStops( + session: CodexSession | undefined +): AgentSessionBackgroundTaskStops | undefined { + return session + ? { supportsTaskStop: true, supportsStopAll: session.backgroundTasks.stopsTerminals } + : undefined +} + +function distinctTerminals( + terminals: readonly CodexBackgroundTerminal[] +): CodexBackgroundTerminal[] { + const byKey = new Map( + terminals.map((terminal) => [JSON.stringify([terminal.threadId, terminal.processId]), terminal]) + ) + return [...byKey.values()] +} + +/** Stops the sub-agents and backgrounded commands the named tasks are, while `session` is still + * the one the host asked about. Cancelled when at least one is gone; throws, after trying every + * other, when one got no answer and none was shown still running. */ +export async function stopCodexBackgroundTasks( + sessions: Map, + input: { sessionId: string; fence: number; taskIds: readonly string[] }, + timeoutMs: number | undefined +): Promise<{ cancelled: boolean; stillRunning?: true }> { + const session = requireLiveCodexSession(sessions, input.sessionId) + const { backgroundTasks } = session + const subagents = backgroundTasks.subagentStopTargets(input.taskIds) + // Interrupting a turn leaves the processes it started running, so a sub-agent's go too. + const terminals = distinctTerminals([ + ...backgroundTasks.backgroundProcesses(input.taskIds), + ...subagents.terminals + ]) + const { acquisitionGeneration } = session + const options = { + timeoutMs: boundedTimeout(timeoutMs), + isCurrent: () => + sessions.get(input.sessionId) === session && + !session.ended && + session.fence === input.fence && + session.acquisitionGeneration === acquisitionGeneration + } + const none: StopTally = { stopped: 0, survived: false } + let failure: { error: unknown } | undefined + const settle = (stop: Promise) => + stop.catch((error: unknown) => { + failure ??= { error } + return none + }) + const children = subagents.turns.length + ? await settle( + interruptCodexSubagents(session, subagents.turns, { + ...options, + ended: ({ threadId, turnId }) => !backgroundTasks.runsSubagentTurn(threadId, turnId) + }) + ) + : none + // An app-server that is not answering is not asked again within the same Stop. + const commands = + terminals.length && !(failure?.error instanceof CodexAppServerTimeoutError) + ? await settle(terminateCodexBackgroundTerminals(session.connection, terminals, options)) + : none + const survived = children.survived || commands.survived + if (failure && !survived) { + throw failure.error + } + return { + cancelled: children.stopped + commands.stopped > 0, + ...(survived ? { stillRunning: true as const } : {}) + } +} diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index 06853e36f50..3456fec089b 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -87,6 +87,44 @@ export class CodexBackgroundTaskTracker { return this.stopsTerminals ? this.commands.backgroundProcesses(taskIds) : [] } + /** What a Stop of the named sub-agents reaches: the run each one and each of its descendants is + * working on, and, when this app-server can terminate them, the processes their threads hold. + * A sub-agent row's providerId is its thread id. */ + subagentStopTargets(taskIds: readonly string[]): { + turns: { threadId: string; turnId: string }[] + terminals: { threadId: string; processId: string }[] + } { + const named = new Set(taskIds) + const lineage = this.executions + .workingChildren() + .filter((child) => named.has(child.agentThreadId)) + .flatMap((child) => this.executions.lineage(child.agentThreadId)) + const threads = [...new Set(lineage.map((child) => child.agentThreadId))] + return { + turns: threads.flatMap((threadId) => { + const execution = this.executions.find(threadId)?.execution + return execution?.state === 'working' ? [{ threadId, turnId: execution.turnId }] : [] + }), + terminals: this.stopsTerminals + ? threads.flatMap((threadId) => + this.commands + .threadCommands(threadId) + .flatMap((command) => + command.type === 'started' && command.processId !== undefined + ? [{ threadId, processId: command.processId }] + : [] + ) + ) + : [] + } + } + + /** Whether the sub-agent is still working on this run. */ + runsSubagentTurn(threadId: string, turnId: string): boolean { + const execution = this.executions.find(threadId)?.execution + return execution?.state === 'working' && execution.turnId === turnId + } + get state(): AgentSessionBackgroundTaskState | null { // Journal admission precedes observe; readers must not see its pending facts. return this.publishedState diff --git a/src/main/codex/codex-background-terminals.test.ts b/src/main/codex/codex-background-terminals.test.ts index 1fcc28855c2..818c8d2a6a0 100644 --- a/src/main/codex/codex-background-terminals.test.ts +++ b/src/main/codex/codex-background-terminals.test.ts @@ -162,8 +162,9 @@ describe('Codex background-command Stop', () => { await backgroundedDevServer(codex) + // Per-row Stop stays offered for sub-agents; no command row is stoppable and no Stop all. expect(codex.adapter.backgroundTaskStops('session-1')).toEqual({ - supportsTaskStop: false, + supportsTaskStop: true, supportsStopAll: false }) expect(lastCommandStoppable(codex.evidence)).toBe(false) @@ -185,8 +186,9 @@ describe('Codex background-command Stop', () => { }) await backgroundedDevServer(codex) expect(lastCommandStoppable(codex.evidence)).toBe(false) + // Per-row Stop stays offered for sub-agents; no command row is stoppable and no Stop all. expect(codex.adapter.backgroundTaskStops('session-1')).toEqual({ - supportsTaskStop: false, + supportsTaskStop: true, supportsStopAll: false }) @@ -194,7 +196,7 @@ describe('Codex background-command Stop', () => { await new Promise((resolve) => setImmediate(resolve)) expect(lastCommandStoppable(codex.evidence)).toBe(true) - expect(codex.adapter.backgroundTaskStops('session-1')?.supportsTaskStop).toBe(true) + expect(codex.adapter.backgroundTaskStops('session-1')?.supportsStopAll).toBe(true) }) it('probes the app-server once, not per command', async () => { @@ -236,7 +238,7 @@ describe('Codex background-command Stop', () => { }) await new Promise((resolve) => setImmediate(resolve)) expect(probes).toBe(1) - expect(codex.adapter.backgroundTaskStops('session-1')?.supportsTaskStop).toBe(false) + expect(codex.adapter.backgroundTaskStops('session-1')?.supportsStopAll).toBe(false) codex.notify('turn/completed', { threadId: THREAD_ID, @@ -245,7 +247,7 @@ describe('Codex background-command Stop', () => { await new Promise((resolve) => setImmediate(resolve)) expect(probes).toBe(2) - expect(codex.adapter.backgroundTaskStops('session-1')?.supportsTaskStop).toBe(true) + expect(codex.adapter.backgroundTaskStops('session-1')?.supportsStopAll).toBe(true) expect(lastCommandStoppable(codex.evidence)).toBe(true) }) diff --git a/src/main/codex/codex-background-terminals.ts b/src/main/codex/codex-background-terminals.ts index f8dcd1f98fe..d32b91ef096 100644 --- a/src/main/codex/codex-background-terminals.ts +++ b/src/main/codex/codex-background-terminals.ts @@ -11,12 +11,10 @@ import { } from './codex-app-server-session' import type { CodexTerminalStopSupport } from './codex-background-task-tracker' import { readRecord } from './codex-item-field-readers' -import { - requireLiveCodexSession, - type CodexSession, - type CodexStructuredSessionAdapterDeps +import type { + CodexSession, + CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' -import type { AgentSessionBackgroundTaskStops } from '../../shared/agent-child-work-stop-targets' type CodexTerminalRpc = Pick @@ -27,7 +25,7 @@ export type CodexBackgroundTerminal = { threadId: string; processId: string } const CODEX_BACKGROUND_TERMINAL_TIMEOUT_MS = 10_000 const MAX_LIST_PAGES = 32 -function boundedTimeout(timeoutMs: number | undefined): number { +export function boundedTimeout(timeoutMs: number | undefined): number { return Math.min( timeoutMs ?? CODEX_BACKGROUND_TERMINAL_TIMEOUT_MS, CODEX_BACKGROUND_TERMINAL_TIMEOUT_MS @@ -171,15 +169,6 @@ export async function terminateCodexBackgroundTerminals( return { stopped, survived } } -/** A backgrounded command stops through its app-server's background terminals, once that - * app-server proved it has them; a child thread has no stop here. */ -export function codexBackgroundTaskStops( - session: CodexSession | undefined -): AgentSessionBackgroundTaskStops | undefined { - const stops = session?.backgroundTasks.stopsTerminals - return stops === undefined ? undefined : { supportsTaskStop: stops, supportsStopAll: stops } -} - /** Probes the session's app-server off the frame path when a running command has a process a stop * could name, until it answers; a yes restates the running commands as stoppable. */ export function startCodexTerminalStopProbe( @@ -211,31 +200,3 @@ export function startCodexTerminalStopProbe( } }) } - -/** Stops the backgrounded commands the named tasks run, while `session` is still the one the host - * asked about. Cancelled when at least one is gone. */ -export async function stopCodexBackgroundCommands( - sessions: Map, - input: { sessionId: string; fence: number; taskIds: readonly string[] }, - timeoutMs: number | undefined -): Promise<{ cancelled: boolean; stillRunning?: true }> { - const session = requireLiveCodexSession(sessions, input.sessionId) - const terminals = session.backgroundTasks.backgroundProcesses(input.taskIds) - if (terminals.length === 0) { - return { cancelled: false } - } - const { acquisitionGeneration } = session - const { stopped, survived } = await terminateCodexBackgroundTerminals( - session.connection, - terminals, - { - timeoutMs, - isCurrent: () => - sessions.get(input.sessionId) === session && - !session.ended && - session.fence === input.fence && - session.acquisitionGeneration === acquisitionGeneration - } - ) - return { cancelled: stopped > 0, ...(survived ? { stillRunning: true as const } : {}) } -} diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index c0526cdddca..c8b3dfb43c4 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -124,7 +124,7 @@ describe('Codex child-work evidence', () => { residency: 'background', description: 'audit_build', invocation: { invocationId: 'c1', generation: 1 }, - stoppable: false + stoppable: true }) ]) const aliases = store.getAliasesForChild(records()[0]!.childWorkId) diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index a55c7a5b3fc..2eb57d7d6f9 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -274,7 +274,8 @@ export class CodexChildWorkEvidence { ...(facts.totalTokens !== undefined ? { totalTokens: facts.totalTokens } : {}), ...(lastMessage ? { lastMessage } : {}), ...(spawner && spawner !== this.primaryThreadId ? { ownerId: spawner } : {}), - stoppable: false + // `turn/interrupt` reaches a child thread's turn on every Codex Orca runs. + stoppable: true } } diff --git a/src/main/codex/codex-child-work-translation-retention.test.ts b/src/main/codex/codex-child-work-translation-retention.test.ts index b7efaa59033..7ab4e135a10 100644 --- a/src/main/codex/codex-child-work-translation-retention.test.ts +++ b/src/main/codex/codex-child-work-translation-retention.test.ts @@ -96,7 +96,7 @@ describe('Codex child message preview ownership', () => { state: 'working', description: 'report', lastMessage: preview, - stoppable: false, + stoppable: true, operation: null } } diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index f4da69bb32c..f515e426153 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -40,11 +40,8 @@ import { import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import { acquireCodexStructuredSession } from './codex-structured-session-acquire' import { changeCodexThreadGoal } from './codex-structured-thread-goal' -import { - codexBackgroundTaskStops, - startCodexTerminalStopProbe, - stopCodexBackgroundCommands -} from './codex-background-terminals' +import { startCodexTerminalStopProbe } from './codex-background-terminals' +import { codexBackgroundTaskStops, stopCodexBackgroundTasks } from './codex-background-task-stops' import { answerCodexStructuredPrompt, cancelCodexStructuredTurn @@ -194,7 +191,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap stopBackgroundTasks: NonNullable = ( input - ) => stopCodexBackgroundCommands(this.sessions, input, this.deps.requestTimeoutMs) + ) => stopCodexBackgroundTasks(this.sessions, input, this.deps.requestTimeoutMs) bindPromptItemId = ( sessionId: string, diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts index 0d0fe0c5bf4..8d7874b97da 100644 --- a/src/main/codex/codex-subagent-executions.ts +++ b/src/main/codex/codex-subagent-executions.ts @@ -134,6 +134,27 @@ export class CodexSubagentExecutions { return this.children.get(agentThreadId) } + /** The child and every child it spawned, at any depth. */ + lineage(agentThreadId: string): CodexExecutionChild[] { + const lineage: CodexExecutionChild[] = [] + const seen = new Set() + const pending = [agentThreadId] + for (let id = pending.pop(); id !== undefined; id = pending.pop()) { + const child = this.children.get(id) + if (!child || seen.has(id)) { + continue + } + seen.add(id) + lineage.push(child) + for (const spawned of this.children.values()) { + if (spawned.spawnerThreadId === id) { + pending.push(spawned.agentThreadId) + } + } + } + return lineage + } + workingChildren(): CodexExecutionChild[] { return [...this.children.values()].filter( (child) => child.registered && child.execution?.state === 'working' diff --git a/src/main/codex/codex-subagent-interrupts.test.ts b/src/main/codex/codex-subagent-interrupts.test.ts new file mode 100644 index 00000000000..ea04708fe45 --- /dev/null +++ b/src/main/codex/codex-subagent-interrupts.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from 'vitest' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { codexPrimaryCommandTaskId } from '../../shared/structured-session-foreground-commands' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import { CodexAppServerTimeoutError } from './codex-app-server-session' +import { + adapterFor, + fakeCodex, + identityFor, + THREAD_ID, + type Route +} from './codex-structured-session-adapter-fixture' + +const HELPER = 'thread-helper' +const HELPER_TURN = 'helper-turn-1' +const identity = identityFor('session-1') + +function refusal(code: number, message: string): CodexAppServerRequestError { + return new CodexAppServerRequestError( + 'turn/interrupt', + code, + `codex app-server turn/interrupt failed: ${message}`, + message + ) +} + +/** A session whose turn spawned `HELPER`, which is still working on its own first turn. */ +async function runningHelper(routes: Record) { + const codex = fakeCodex(routes) + const evidence: AgentChildWorkEvidence[] = [] + const adapter = adapterFor(codex, {}, [], { + onChildWorkEvidence: (_sessionId, edges) => evidence.push(...edges) + }) + const live = () => codex.connections.at(-1)! + const notify = (method: string, params: unknown): void => + live().handlers.onNotification?.(method, params) + await adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' }) + notify('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-1', status: 'inProgress' } }) + notify('item/started', { + threadId: THREAD_ID, + turnId: 'turn-1', + item: { + type: 'subAgentActivity', + id: 'spawn-helper', + kind: 'started', + agentThreadId: HELPER, + agentPath: '/root/helper' + } + }) + notify('turn/started', { threadId: HELPER, turn: { id: HELPER_TURN, status: 'inProgress' } }) + notify('turn/completed', { threadId: THREAD_ID, turn: { id: 'turn-1', status: 'completed' } }) + await new Promise((resolve) => setImmediate(resolve)) + const interrupts = () => + live() + .calls.filter((call) => call.method === 'turn/interrupt') + .map((call) => call.params) + const stop = (taskIds: string[], fence = 7) => + adapter.stopBackgroundTasks({ sessionId: 'session-1', fence, taskIds }) + return { adapter, evidence, notify, interrupts, stop } +} + +function helperEdges(evidence: AgentChildWorkEvidence[]) { + return evidence.filter( + (edge) => + (edge.type === 'live' + ? edge.child.handle.id + : edge.type === 'ended' + ? edge.handle.id + : null) === HELPER + ) +} + +describe('Codex sub-agent Stop', () => { + it('offers a sub-agent its own Stop and interrupts the turn it runs', async () => { + const codex = await runningHelper({ 'turn/interrupt': () => ({}) }) + expect(codex.adapter.backgroundTaskStops('session-1')).toEqual({ + supportsTaskStop: true, + supportsStopAll: false + }) + expect(helperEdges(codex.evidence).at(-1)).toMatchObject({ + type: 'live', + child: { kind: 'agent', stoppable: true } + }) + + await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: true }) + expect(codex.interrupts()).toEqual([{ threadId: HELPER, turnId: HELPER_TURN }]) + + codex.notify('turn/completed', { + threadId: HELPER, + turn: { id: HELPER_TURN, status: 'interrupted' } + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(helperEdges(codex.evidence).at(-1)).toMatchObject({ + type: 'ended', + outcome: 'cancelled' + }) + // Its turn is over: a second Stop has nothing to name. + await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: false }) + expect(codex.interrupts()).toHaveLength(1) + }) + + it.each([ + ['has no turn running', 'no active turn to interrupt'], + [ + 'has moved on to another turn', + `expected active turn id ${HELPER_TURN} but found helper-turn-2` + ], + ['was unloaded', `thread not found: ${HELPER}`] + ])('stays quiet when Codex says the child %s', async (_, message) => { + const codex = await runningHelper({ + 'turn/interrupt': () => { + throw refusal(-32600, message) + } + }) + await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: false }) + }) + + it('says the sub-agent still runs when Codex could not carry the interrupt out', async () => { + const codex = await runningHelper({ + 'turn/interrupt': () => { + throw refusal(-32603, 'failed to interrupt turn') + } + }) + await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: false, stillRunning: true }) + }) + + it.each([ + ['times out', new CodexAppServerTimeoutError('codex app-server turn/interrupt timed out')], + ['loses the app-server', new Error('codex app-server connection closed')] + ])('leaves the Stop unconfirmed when the interrupt %s', async (_, error) => { + const codex = await runningHelper({ + 'turn/interrupt': () => { + throw error + } + }) + await expect(codex.stop([HELPER])).rejects.toBe(error) + }) + + it('stays quiet when the interrupt landed as the run ended and Codex held it unanswered', async () => { + let endRun = (): void => {} + const codex = await runningHelper({ + 'turn/interrupt': async () => { + endRun() + await new Promise((resolve) => setImmediate(resolve)) + throw new CodexAppServerTimeoutError('codex app-server turn/interrupt timed out') + } + }) + endRun = () => + codex.notify('turn/completed', { + threadId: HELPER, + turn: { id: HELPER_TURN, status: 'completed' } + }) + await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: false }) + }) + + it('sends nothing for a task that is not a running sub-agent, or for a stale fence', async () => { + const codex = await runningHelper({ 'turn/interrupt': () => ({}) }) + await expect(codex.stop(['thread-unknown'])).resolves.toEqual({ cancelled: false }) + await expect(codex.stop([HELPER], 6)).resolves.toEqual({ cancelled: false }) + expect(codex.interrupts()).toEqual([]) + }) + + it("ends the helper's own sub-agents with it", async () => { + const codex = await runningHelper({ 'turn/interrupt': () => ({}) }) + codex.notify('item/started', { + threadId: HELPER, + turnId: HELPER_TURN, + item: { + type: 'subAgentActivity', + id: 'spawn-nested', + kind: 'started', + agentThreadId: 'thread-nested', + agentPath: '/root/helper/nested' + } + }) + codex.notify('turn/started', { + threadId: 'thread-nested', + turn: { id: 'nested-turn-1', status: 'inProgress' } + }) + await new Promise((resolve) => setImmediate(resolve)) + + await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: true }) + expect(codex.interrupts()).toEqual([ + { threadId: HELPER, turnId: HELPER_TURN }, + { threadId: 'thread-nested', turnId: 'nested-turn-1' } + ]) + }) + + it('terminates the command a helper is running, which its interrupt leaves running', async () => { + const running = ['4343'] + const codex = await runningHelper({ + 'turn/interrupt': () => ({}), + 'thread/backgroundTerminals/list': () => ({ + data: running.map((processId) => ({ processId })), + nextCursor: null + }), + 'thread/backgroundTerminals/terminate': (params) => { + running.splice(running.indexOf(String(params?.processId)), 1) + return { terminated: true } + } + }) + const command = (status: 'inProgress' | 'completed') => ({ + threadId: HELPER, + turnId: HELPER_TURN, + item: { + type: 'commandExecution', + id: 'helper-exec', + processId: '4343', + source: 'unifiedExecStartup', + command: 'sleep 90', + status + } + }) + codex.notify('item/started', command('inProgress')) + await new Promise((resolve) => setImmediate(resolve)) + + await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: true }) + expect(codex.interrupts()).toEqual([{ threadId: HELPER, turnId: HELPER_TURN }]) + expect(running).toEqual([]) + codex.notify('turn/completed', { + threadId: HELPER, + turn: { id: HELPER_TURN, status: 'interrupted' } + }) + codex.notify('item/completed', command('completed')) + await new Promise((resolve) => setImmediate(resolve)) + expect(helperEdges(codex.evidence).at(-1)).toMatchObject({ + type: 'ended', + outcome: 'cancelled' + }) + expect(codex.evidence.at(-1)).toMatchObject({ type: 'removed' }) + }) + + /** `runningHelper`, plus a dev server the session's own turn left running. */ + async function helperAndDevServer(interrupt: Route) { + const running = ['4242'] + const terminated: unknown[] = [] + const codex = await runningHelper({ + 'turn/interrupt': interrupt, + 'thread/backgroundTerminals/list': () => ({ + data: running.map((processId) => ({ processId })), + nextCursor: null + }), + 'thread/backgroundTerminals/terminate': (params) => { + terminated.push(params) + running.splice(running.indexOf(String(params?.processId)), 1) + return { terminated: true } + } + }) + codex.notify('item/started', { + threadId: THREAD_ID, + turnId: 'turn-1', + item: { + type: 'commandExecution', + id: 'exec-1', + processId: '4242', + source: 'unifiedExecStartup', + command: 'pnpm dev', + status: 'inProgress' + } + }) + await new Promise((resolve) => setImmediate(resolve)) + const stopBoth = () => codex.stop([HELPER, codexPrimaryCommandTaskId('exec-1')]) + return { ...codex, running, terminated, stopBoth } + } + + it('stops a sub-agent and a backgrounded command in one Stop', async () => { + const codex = await helperAndDevServer(() => ({})) + await expect(codex.stopBoth()).resolves.toEqual({ cancelled: true }) + expect(codex.interrupts()).toEqual([{ threadId: HELPER, turnId: HELPER_TURN }]) + expect(codex.running).toEqual([]) + }) + + it('does not ask an app-server that timed out on the sub-agent to stop the command too', async () => { + const timeout = new CodexAppServerTimeoutError('codex app-server turn/interrupt timed out') + const codex = await helperAndDevServer(() => { + throw timeout + }) + await expect(codex.stopBoth()).rejects.toBe(timeout) + expect(codex.terminated).toEqual([]) + }) + + it("leaves a helper's sibling and the session's own background process alone", async () => { + const codex = await helperAndDevServer(() => ({})) + codex.notify('item/started', { + threadId: THREAD_ID, + turnId: 'turn-1', + item: { + type: 'subAgentActivity', + id: 'spawn-sibling', + kind: 'started', + agentThreadId: 'thread-sibling', + agentPath: '/root/sibling' + } + }) + codex.notify('turn/started', { + threadId: 'thread-sibling', + turn: { id: 'sibling-turn-1', status: 'inProgress' } + }) + await new Promise((resolve) => setImmediate(resolve)) + + await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: true }) + expect(codex.interrupts()).toEqual([{ threadId: HELPER, turnId: HELPER_TURN }]) + expect(codex.terminated).toEqual([]) + expect(codex.running).toEqual(['4242']) + }) + + it("does not terminate a helper's processes once its interrupt timed out", async () => { + const timeout = new CodexAppServerTimeoutError('codex app-server turn/interrupt timed out') + const codex = await helperAndDevServer(() => { + throw timeout + }) + codex.notify('item/started', { + threadId: HELPER, + turnId: HELPER_TURN, + item: { + type: 'commandExecution', + id: 'helper-exec', + processId: '4343', + source: 'unifiedExecStartup', + command: 'sleep 90', + status: 'inProgress' + } + }) + await new Promise((resolve) => setImmediate(resolve)) + + await expect(codex.stop([HELPER])).rejects.toBe(timeout) + expect(codex.terminated).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-subagent-interrupts.ts b/src/main/codex/codex-subagent-interrupts.ts new file mode 100644 index 00000000000..2ab875be0e2 --- /dev/null +++ b/src/main/codex/codex-subagent-interrupts.ts @@ -0,0 +1,64 @@ +// Stopping a Codex sub-agent: its thread is an ordinary loaded thread on the same app-server, so +// `turn/interrupt` names the child thread and the turn it runs. Codex answers when that turn ends, +// aborted or not, and the child's own `turn/completed` then removes its row. + +import { CodexAppServerTimeoutError } from './codex-app-server-session' +import type { CodexSession } from './codex-structured-session-state' +import { interruptCodexTurn } from './codex-structured-turn-cancellation' + +/** One child run a Stop names: the child's thread and the turn the host last saw it working on. */ +export type CodexSubagentTurn = { threadId: string; turnId: string } + +/** + * Interrupts each named run. A refusal saying the turn is not running means the run is over: + * nothing to stop. Any other refusal leaves the child running. Throws when a request got no answer + * and no refusal said a child survived, since the effect is then unknown; every other child is + * tried first. + */ +export async function interruptCodexSubagents( + session: CodexSession, + turns: readonly CodexSubagentTurn[], + options: { + timeoutMs: number + isCurrent: () => boolean + /** Whether the session has since seen this run end. */ + ended: (turn: CodexSubagentTurn) => boolean + } +): Promise<{ stopped: number; survived: boolean }> { + let stopped = 0 + let survived = false + let failure: { error: unknown } | undefined + for (const turn of turns) { + if (!options.isCurrent()) { + break + } + try { + const outcome = await interruptCodexTurn({ + session, + ...turn, + requestTimeoutMs: options.timeoutMs + }) + if (outcome.cancelled) { + stopped += 1 + } else if (!outcome.refusal?.turnNotRunning) { + survived = true + } + } catch (error) { + // Codex holds an interrupt that lands as the turn ends, answering it only when the child's + // next turn ends; that one runs on. A run seen ended had nothing left to stop. + const timedOut = error instanceof CodexAppServerTimeoutError + if (timedOut && options.isCurrent() && options.ended(turn)) { + continue + } + failure ??= { error } + // An app-server that is not answering would make every later request wait out its deadline. + if (timedOut) { + break + } + } + } + if (failure && !survived) { + throw failure.error + } + return { stopped, survived } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts index 89e47785983..79efe4a0bc5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts @@ -33,14 +33,14 @@ const USER_IDENTITY = { ordinal: 1 } as const /** The legacy row an older client reads for `childView()`, derived from it. These sessions are - * Codex's, so a subagent keeps the id its row carried before views. */ + * Codex's, so a subagent keeps the id its row carried before views, which no Stop can name. */ const LEGACY_TASK = { id: 'codex-agent:task-1', kind: 'agent', name: 'deep_review', state: 'working', startedAt: 100, - stoppable: true + stoppable: false } as const let root: string diff --git a/src/main/runtime/structured-foreground-command-strip.test.ts b/src/main/runtime/structured-foreground-command-strip.test.ts index 76b16902d58..3d9bd0060c0 100644 --- a/src/main/runtime/structured-foreground-command-strip.test.ts +++ b/src/main/runtime/structured-foreground-command-strip.test.ts @@ -109,18 +109,26 @@ describe('foreground commands in the background-task channel', () => { status: method === 'item/completed' ? 'completed' : 'inProgress' } }) - return { host, rosters, turn, command, fence: attached.ok ? attached.value.fence : null } + return { + host, + rosters, + notify, + turn, + command, + fence: attached.ok ? attached.value.fence : null + } } type StripHost = Awaited>['host'] - /** The strip's Stop on the `server` command, as a client sends it. */ - function stopFromStrip(host: StripHost, fence: number | null, operation: string) { - const fields = { - turnId: 'background-tasks', - scope: 'background-tasks' as const, - taskId: 'codex-command:primary:server' - } + /** The strip's Stop on a row (the `server` command by default), as a client sends it. */ + function stopFromStrip( + host: StripHost, + fence: number | null, + operation: string, + taskId = 'codex-command:primary:server' + ) { + const fields = { turnId: 'background-tasks', scope: 'background-tasks' as const, taskId } return host.cancel( { callerKey: 'strip-test' }, { @@ -246,4 +254,109 @@ describe('foreground commands in the background-task channel', () => { children: [{ providerId: 'codex-command:primary:server', stoppable: true }] }) }) + + it('stops a sub-agent from the strip by interrupting its own turn', async () => { + const helper = 'thread-helper' + const interrupts: unknown[] = [] + const { host, rosters, notify, turn, fence } = await rig({ + 'turn/interrupt': (params) => { + interrupts.push(params) + return {} + } + }) + await turn('turn/started', 'turn-1') + await notify('item/started', { + threadId: THREAD_ID, + turnId: 'turn-1', + item: { + type: 'subAgentActivity', + id: 'spawn-helper', + kind: 'started', + agentThreadId: helper, + agentPath: '/root/helper' + } + }) + await notify('turn/started', { + threadId: helper, + turn: { id: 'helper-turn', status: 'inProgress' } + }) + await turn('turn/completed', 'turn-1') + expect(rosters.at(-1)).toMatchObject({ + supportsTaskStop: true, + children: [{ providerId: helper, kind: 'agent', stoppable: true }], + // An older client's row names `codex-agent:`, which no record carries. + tasks: [{ id: `codex-agent:${helper}`, stoppable: false }] + }) + + await expect(stopFromStrip(host, fence, '5', helper)).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) + expect(interrupts).toEqual([{ threadId: helper, turnId: 'helper-turn' }]) + // The child's own interrupted turn is what takes its row off the strip. + await notify('turn/completed', { + threadId: helper, + turn: { id: 'helper-turn', status: 'interrupted' } + }) + expect(rosters.at(-1)).toBeNull() + }) + + it("stops a sub-agent's running command with it, and the row leaves", async () => { + const helper = 'thread-helper' + let running = ['5151'] + const { host, rosters, notify, turn, fence } = await rig({ + 'turn/interrupt': () => ({}), + 'thread/backgroundTerminals/list': () => ({ + data: running.map((processId) => ({ processId })), + nextCursor: null + }), + 'thread/backgroundTerminals/terminate': (params) => { + running = running.filter((processId) => processId !== params?.processId) + return { terminated: true } + } + }) + const helperCommand = (status: 'inProgress' | 'completed') => + notify(status === 'completed' ? 'item/completed' : 'item/started', { + threadId: helper, + turnId: 'helper-turn', + item: { + type: 'commandExecution', + id: 'helper-exec', + processId: '5151', + command: 'sleep 90', + source: 'unifiedExecStartup', + status + } + }) + await turn('turn/started', 'turn-1') + await notify('item/started', { + threadId: THREAD_ID, + turnId: 'turn-1', + item: { + type: 'subAgentActivity', + id: 'spawn-helper', + kind: 'started', + agentThreadId: helper, + agentPath: '/root/helper' + } + }) + await notify('turn/started', { + threadId: helper, + turn: { id: 'helper-turn', status: 'inProgress' } + }) + await helperCommand('inProgress') + await turn('turn/completed', 'turn-1') + + await expect(stopFromStrip(host, fence, '6', helper)).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) + expect(running).toEqual([]) + await notify('turn/completed', { + threadId: helper, + turn: { id: 'helper-turn', status: 'interrupted' } + }) + await helperCommand('completed') + expect(rosters.at(-1)).toBeNull() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx new file mode 100644 index 00000000000..b8f463aed58 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionCancelResult } from '../../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' +import { structuredSessionBackgroundTasksView } from '../../../../shared/structured-session-background-tasks-view' +import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSessionStatus' + +vi.mock('./use-structured-session-child-row-context', () => ({ + useStructuredSessionChildRowContext: () => undefined +})) + +afterEach(cleanup) + +function helper(invocation: AgentChildWorkView['invocation']): AgentChildWorkView { + return { + id: 'helper', + providerId: 'thread-helper', + kind: 'agent', + description: 'audit_build', + state: 'working', + membership: 'live', + firstObservedAt: Date.now() - 65_000, + observedAt: Date.now() - 1_000, + stoppable: true, + invocation + } +} + +describe('NativeChatStructuredSessionStatus', () => { + it("offers Stop again on a helper's next run, which reuses the stopped run's row id", async () => { + let finish: (result: AgentSessionCancelResult) => void = () => {} + const stopBackgroundTask = vi.fn( + () => new Promise((resolve) => (finish = resolve)) + ) + const strip = (children: AgentChildWorkView[]) => ( + + ) + const { rerender } = render(strip([helper({ invocationId: 'turn-1', generation: 1 })])) + fireEvent.click(screen.getByRole('button', { expanded: false })) + fireEvent.click(screen.getByLabelText('Stop audit_build')) + await act(async () => finish({ cancelled: true })) + expect(screen.getByLabelText('Stop audit_build')).toBeDisabled() + + // The helper's next run, with no render between: same provider id, a new invocation. + rerender(strip([helper({ invocationId: 'turn-2', generation: 2 })])) + expect(screen.getByLabelText('Stop audit_build')).toBeEnabled() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx index e5fb813cb44..f88f7aaf319 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx @@ -3,6 +3,7 @@ import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStat import type { AgentSessionCancelResult } from '../../../../shared/agent-session-wire' import { structuredSessionConfirmedStopsStillListed, + structuredSessionListedTaskRun, type StructuredSessionBackgroundTasksView } from '../../../../shared/structured-session-background-tasks-view' import { useStructuredSessionChildRowContext } from './use-structured-session-child-row-context' @@ -13,9 +14,11 @@ type StoppingBackgroundTasks = { all: boolean } -type ConfirmedStops = { sessionId: string; taskIds: ReadonlySet } +/** Each confirmed Stop's task, with the run it was pressed on. */ +type ConfirmedStops = { sessionId: string; taskIds: ReadonlyMap } const NO_STOPPING_TASKS: ReadonlySet = new Set() +const NO_CONFIRMED_STOPS: ReadonlyMap = new Map() export function NativeChatStructuredSessionStatus(props: { sessionId: string @@ -26,7 +29,8 @@ export function NativeChatStructuredSessionStatus(props: { stopBackgroundTask: (taskId?: string) => Promise }): React.JSX.Element { const [stopping, setStopping] = useState(null) - // Stops the host confirmed: each row keeps its stopping button until it leaves the strip. + // Stops the host confirmed, with the run each was pressed on: each row keeps its stopping button + // until that run leaves the strip. const [confirmed, setConfirmed] = useState(null) const [expanded, setExpanded] = useState<{ sessionId: string; expanded: boolean } | null>(null) const activeStopping = stopping?.sessionId === props.sessionId ? stopping : null @@ -41,12 +45,14 @@ export function NativeChatStructuredSessionStatus(props: { ) } const stoppingTaskIds = confirmedListed?.size - ? new Set([...(activeStopping?.taskIds ?? NO_STOPPING_TASKS), ...confirmedListed]) + ? new Set([...(activeStopping?.taskIds ?? NO_STOPPING_TASKS), ...confirmedListed.keys()]) : (activeStopping?.taskIds ?? NO_STOPPING_TASKS) const childRowContext = useStructuredSessionChildRowContext(props.paneKey) const onStop = (taskId?: string) => { const sessionId = props.sessionId + // A provider may reuse the row's id for the task's next run, which this Stop did not end. + const run = taskId ? structuredSessionListedTaskRun(props.backgroundTasks, taskId) : null setStopping((current) => { const taskIds = new Set( current?.sessionId === sessionId ? current.taskIds : NO_STOPPING_TASKS @@ -63,12 +69,12 @@ export function NativeChatStructuredSessionStatus(props: { void props .stopBackgroundTask(taskId) .then((result) => { - if (taskId && result?.cancelled) { + if (taskId && run !== null && result?.cancelled) { setConfirmed((current) => ({ sessionId, - taskIds: new Set([ - ...(current?.sessionId === sessionId ? current.taskIds : NO_STOPPING_TASKS), - taskId + taskIds: new Map([ + ...(current?.sessionId === sessionId ? current.taskIds : NO_CONFIRMED_STOPS), + [taskId, run] ]) })) } diff --git a/src/shared/structured-agent-session-child-work-legacy.ts b/src/shared/structured-agent-session-child-work-legacy.ts index 52e7c1180a4..e1725e480c8 100644 --- a/src/shared/structured-agent-session-child-work-legacy.ts +++ b/src/shared/structured-agent-session-child-work-legacy.ts @@ -33,7 +33,8 @@ function withLegacyIds( ): AgentChildWorkView[] { return views.map((view) => provider === 'codex' && view.kind === 'agent' && view.providerId !== undefined - ? { ...view, providerId: codexAgentBackgroundTaskId(view.providerId) } + ? // A Stop names the row's id, which no host record carries: these rows offer none. + { ...view, providerId: codexAgentBackgroundTaskId(view.providerId), stoppable: false } : view ) } diff --git a/src/shared/structured-session-background-tasks-view.test.ts b/src/shared/structured-session-background-tasks-view.test.ts index 5e76feaf81f..ebe217218e4 100644 --- a/src/shared/structured-session-background-tasks-view.test.ts +++ b/src/shared/structured-session-background-tasks-view.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { AgentChildWorkView } from './agent-status-child-work-view' -import { structuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' +import { + structuredSessionBackgroundTasksView, + structuredSessionConfirmedStopsStillListed, + structuredSessionListedTaskRun +} from './structured-session-background-tasks-view' function view(membership: 'live' | 'settled', kind: AgentChildWorkView['kind'] = 'agent') { return { @@ -125,3 +129,44 @@ describe('structuredSessionBackgroundTasksView', () => { }) }) }) + +describe('a confirmed Stop held per run', () => { + const helper = (invocationId: string, generation: number): AgentChildWorkView => ({ + ...view('live'), + providerId: 'thread-helper', + invocation: { invocationId, generation } + }) + const withChildren = (children: AgentChildWorkView[]) => + structuredSessionBackgroundTasksView({ state: 'monitoring', children }, null) + + it("keys a child's run by its invocation, so the next run under the same id is another", () => { + const first = withChildren([helper('turn-1', 1)]) + const run = structuredSessionListedTaskRun(first, 'thread-helper') + expect(run).not.toBeNull() + const confirmed = new Map([['thread-helper', run!]]) + + expect(structuredSessionConfirmedStopsStillListed(first, confirmed)).toBe(confirmed) + const next = withChildren([helper('turn-2', 2)]) + expect(structuredSessionListedTaskRun(next, 'thread-helper')).not.toBe(run) + expect(structuredSessionConfirmedStopsStillListed(next, confirmed)).toEqual(new Map()) + expect(structuredSessionConfirmedStopsStillListed(withChildren([]), confirmed)).toEqual( + new Map() + ) + }) + + it("keys an older host's roster row by its id, which it has no run marker beside", () => { + const legacy = structuredSessionBackgroundTasksView( + { state: 'monitoring', tasks: [{ id: 'task-1', kind: 'command' }] }, + null + ) + expect(structuredSessionListedTaskRun(legacy, 'task-1')).toBe('task-1') + expect(structuredSessionListedTaskRun(legacy, 'task-2')).toBeNull() + const confirmed = new Map([['task-1', 'task-1']]) + expect(structuredSessionConfirmedStopsStillListed(legacy, confirmed)).toBe(confirmed) + }) + + it('lists nothing while the strip is hidden', () => { + const hidden = structuredSessionBackgroundTasksView(null, null) + expect(structuredSessionListedTaskRun(hidden, 'task-1')).toBeNull() + }) +}) diff --git a/src/shared/structured-session-background-tasks-view.ts b/src/shared/structured-session-background-tasks-view.ts index 66f27129707..859ebf8c1ca 100644 --- a/src/shared/structured-session-background-tasks-view.ts +++ b/src/shared/structured-session-background-tasks-view.ts @@ -77,22 +77,32 @@ export function structuredSessionBackgroundTasksView( } } -/** Whether the strip still lists the task a Stop named, live or settled. */ -function listsTask(view: StructuredSessionBackgroundTasksView, providerId: string): boolean { - return ( - view.show && - (view.children - ? view.children.some((child) => child.providerId === providerId) - : view.tasks.some((task) => task.id === providerId)) - ) +/** 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 whose rows the strip still lists, each holding its row's button; the - * same set when none has left, so a caller can tell when to drop the rest. */ +/** 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: ReadonlySet -): ReadonlySet { - const listed = [...confirmed].filter((providerId) => listsTask(view, providerId)) - return listed.length === confirmed.size ? confirmed : new Set(listed) + confirmed: ReadonlyMap +): ReadonlyMap { + const listed = [...confirmed].filter( + ([providerId, run]) => structuredSessionListedTaskRun(view, providerId) === run + ) + return listed.length === confirmed.size ? confirmed : new Map(listed) }