mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
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:
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-2
@@ -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
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+4
-6
@@ -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
-1
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user