fix(native-chat): a stop-all that times out stops asking, and "no children" is sent once

A stop-all kept asking after a request timed out, so a Claude CLI that stopped
answering control requests cost one full deadline per task, while the chat's
sends, Stop and /compact waited behind it. A timeout now ends the loop; other
request failures still let the remaining tasks be stopped.

The chat strip channel never remembered that it had sent "no children", so
every change in a chat with none re-sent that frame to each subscriber. It now
remembers it, and forgets only when the conversation closes.

Also renames agent-child-work-stop.ts to agent-child-work-stop-targets.ts, which
says what it answers: the provider ids a background Stop reaches.
This commit is contained in:
Brennan Benson
2026-09-30 02:57:50 -07:00
parent a020c1ee4b
commit dbd2439cd4
9 changed files with 62 additions and 13 deletions
@@ -3,7 +3,7 @@
// order through the real adapter and a real hook server. // order through the real adapter and a real hook server.
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { agentChildWorkStopTargets } from '../../shared/agent-child-work-stop' import { agentChildWorkStopTargets } from '../../shared/agent-child-work-stop-targets'
import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { conversationCommandBlocked } from '../native-chat/agent-session-wire/structured-conversation-command-admission' import { conversationCommandBlocked } from '../native-chat/agent-session-wire/structured-conversation-command-admission'
import type { CapturedFrame } from './claude-captured-frame-builders.test-fixture' import type { CapturedFrame } from './claude-captured-frame-builders.test-fixture'
@@ -6,6 +6,10 @@ import {
} from './claude-structured-control-actions' } from './claude-structured-control-actions'
import { dispatchClaudeTurn } from './claude-structured-dispatch' import { dispatchClaudeTurn } from './claude-structured-dispatch'
import { ClaudeControlRequestError } from './claude-stream-json-connection' import { ClaudeControlRequestError } from './claude-stream-json-connection'
import {
ClaudeControlRequestTimeoutError,
runClaudeControl
} from './claude-agent-sdk-control-requests'
import { buildClaudePromptReply, ClaudePromptRegistry } from './claude-structured-prompt-replies' import { buildClaudePromptReply, ClaudePromptRegistry } from './claude-structured-prompt-replies'
import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state'
import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder'
@@ -389,4 +393,25 @@ describe('stopClaudeBackgroundTasks', () => {
{ type: 'ended', handle: { id: 'task-2' }, outcome: 'cancelled' } { type: 'ended', handle: { id: 'task-2' }, outcome: 'cancelled' }
]) ])
}) })
it('stops asking after a request times out: a CLI not answering costs one deadline, not one per task', async () => {
const childWork = liveChildWork(['task-0', 'task-1', 'task-2', 'task-3', 'task-4'])
// Never answered, behind the real deadline wrapper, with a short deadline.
const stopTask = vi.fn((_taskId: string, options?: { timeoutMs?: number }) =>
runClaudeControl('stop_task', () => new Promise<void>(() => {}), options?.timeoutMs)
)
const session = stoppingSession(childWork, stopTask)
await expect(
stopClaudeBackgroundTasks(session, 50, () => true, [
'task-0',
'task-1',
'task-2',
'task-3',
'task-4'
])
).rejects.toBeInstanceOf(ClaudeControlRequestTimeoutError)
expect(stopTask).toHaveBeenCalledTimes(1)
expect(childWork.drain(2)).toEqual([])
})
}) })
@@ -1,6 +1,7 @@
import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk'
import type { ClaudePromptClaim } from './claude-structured-prompt-replies' import type { ClaudePromptClaim } from './claude-structured-prompt-replies'
import { ClaudeControlRequestError } from './claude-stream-json-connection' import { ClaudeControlRequestError } from './claude-stream-json-connection'
import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests'
import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch'
import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution'
import type { ClaudeSession } from './claude-structured-session-state' import type { ClaudeSession } from './claude-structured-session-state'
@@ -67,7 +68,9 @@ export async function stopClaudeBackgroundTasks(
taskIds: readonly string[] taskIds: readonly string[]
): Promise<{ cancelled: boolean }> { ): Promise<{ cancelled: boolean }> {
let cancelled = false let cancelled = false
// A failed request for one task still leaves the others to stop; it is reported after them. // A failed request for one task still leaves the others to stop; it is reported after them. A
// timeout ends the loop: a CLI that is not answering would make each id wait out its own
// deadline while the session's other actions queue behind this one.
let failure: { error: unknown } | undefined let failure: { error: unknown } | undefined
for (const taskId of taskIds) { for (const taskId of taskIds) {
if (!isCurrent()) { if (!isCurrent()) {
@@ -78,6 +81,9 @@ export async function stopClaudeBackgroundTasks(
session.childWork.stopAcknowledged(taskId) session.childWork.stopAcknowledged(taskId)
cancelled = true cancelled = true
} catch (error) { } catch (error) {
if (error instanceof ClaudeControlRequestTimeoutError) {
throw error
}
if (!(error instanceof ClaudeControlRequestError)) { if (!(error instanceof ClaudeControlRequestError)) {
failure ??= { error } failure ??= { error }
} }
@@ -23,7 +23,7 @@ const child: AgentChildWorkView = {
invocation: { invocationId: 'spawn-1', generation: 1 } invocation: { invocationId: 'spawn-1', generation: 1 }
} }
function channelOver() { function channelOver(children: () => AgentChildWorkView[] = () => [child]) {
const sessions = new StructuredAgentSessionConversations({ const sessions = new StructuredAgentSessionConversations({
deliver: () => {}, deliver: () => {},
onDeliveryError: () => {}, onDeliveryError: () => {},
@@ -42,7 +42,7 @@ function channelOver() {
async () => { async () => {
throw new Error('not opened here') throw new Error('not opened here')
}, },
() => [child] children
) )
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the map reads only the journal's commit observer and the session's provider. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the map reads only the journal's commit observer and the session's provider.
const session = { const session = {
@@ -67,4 +67,24 @@ describe('the background-task channel', () => {
channel.publish('session-1') channel.publish('session-1')
expect(sent).toHaveBeenCalledTimes(2) expect(sent).toHaveBeenCalledTimes(2)
}) })
it('sends "no children" once, not again on every change that leaves none', () => {
let views: AgentChildWorkView[] = []
const { sessions, sent, channel, session } = channelOver(() => views)
sessions.set('session-1', session)
channel.publish('session-1')
channel.publish('session-1')
expect(sent.mock.calls.map(([, state]) => state)).toEqual([null])
views = [child]
channel.publish('session-1')
views = []
channel.publish('session-1')
channel.publish('session-1')
expect(sent.mock.calls.map(([, state]) => state?.children?.length ?? null)).toEqual([
null,
1,
null
])
})
}) })
@@ -85,7 +85,7 @@ export class StructuredAgentSessionBackgroundTaskChannel {
/** Re-read after the session's child records changed; an unchanged roster sends nothing. */ /** Re-read after the session's child records changed; an unchanged roster sends nothing. */
publish(sessionId: string): void { publish(sessionId: string): void {
const session = this.sessions.get(sessionId) const session = this.sessions.get(sessionId)
// Explicit null once rows were sent, not silence: a reader keeps its last roster on // Explicit null once a roster was sent, not silence: a reader keeps its last roster on
// `undefined`, and a closing provider stops answering before its records are gone. // `undefined`, and a closing provider stops answering before its records are gone.
const read = this.state(sessionId) const read = this.state(sessionId)
const state = read === undefined && this.published.has(sessionId) ? null : read const state = read === undefined && this.published.has(sessionId) ? null : read
@@ -96,11 +96,9 @@ export class StructuredAgentSessionBackgroundTaskChannel {
if (this.published.get(sessionId) === fingerprint) { if (this.published.get(sessionId) === fingerprint) {
return return
} }
if (state === null) { // "None" is remembered too, so a session with no children sends it once, not on every change;
this.published.delete(sessionId) // the entry goes when the conversation closes.
} else { this.published.set(sessionId, fingerprint)
this.published.set(sessionId, fingerprint)
}
this.subscribers.backgroundTasks( this.subscribers.backgroundTasks(
sessionId, sessionId,
state, state,
@@ -1,4 +1,4 @@
import { agentChildWorkStopTargets } from '../../../shared/agent-child-work-stop' import { agentChildWorkStopTargets } from '../../../shared/agent-child-work-stop-targets'
import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
@@ -1,4 +1,4 @@
import { agentChildWorkViewOffersStop } from '../../../shared/agent-child-work-stop' import { agentChildWorkViewOffersStop } from '../../../shared/agent-child-work-stop-targets'
import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness'
+1 -1
View File
@@ -19,7 +19,7 @@ import {
deriveAgentChildDisplayState, deriveAgentChildDisplayState,
type AgentChildDisplayState type AgentChildDisplayState
} from './agent-status-child-work-display' } from './agent-status-child-work-display'
import { agentChildWorkViewOffersStop } from './agent-child-work-stop' import { agentChildWorkViewOffersStop } from './agent-child-work-stop-targets'
import type { AgentChildWorkView } from './agent-status-child-work-view' import type { AgentChildWorkView } from './agent-status-child-work-view'
import { import {
agentStatusAuthorityObservedAt, agentStatusAuthorityObservedAt,