mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-02 16:51:13 +08:00
Keep goal sessions alive until background work reports back
The goal evaluator now treats TaskCreate/TaskUpdate transcript state as a hard liveness gate, so a goal cannot complete while task entries remain pending or in_progress. Background agent completion now notifies the parent before classifier or worktree cleanup, which keeps the main session from waiting forever when post-completion cleanup hangs. The desktop store also marks tabs idle on message_complete so completed transcripts do not leave stale running chrome. Constraint: Desktop /goal relies on transcript task notifications to resume after background agents. Rejected: Let optional cleanup run before notification | cleanup can hang and leaves the parent loop stuck. Confidence: high Scope-risk: moderate Directive: Do not gate task-notification delivery on classifier or worktree cleanup without a timeout-backed liveness test. Tested: bun test src/goals/goalEvaluator.test.ts src/tools/AgentTool/agentToolUtils.test.ts Tested: bun run check:server Tested: cd desktop && bun run test src/stores/chatStore.test.ts -t "marks the tab idle when a message completes"
This commit is contained in:
parent
3ebab4366d
commit
e9ac5739bc
@ -445,6 +445,12 @@ describe('chatStore history mapping', () => {
|
|||||||
summary: 'Agent completed',
|
summary: 'Agent completed',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'goal-complete',
|
||||||
|
type: 'goal_event',
|
||||||
|
action: 'completed',
|
||||||
|
message: 'Goal marked complete.',
|
||||||
|
},
|
||||||
])
|
])
|
||||||
expect(session?.activeGoal).toMatchObject({
|
expect(session?.activeGoal).toMatchObject({
|
||||||
action: 'completed',
|
action: 'completed',
|
||||||
@ -1945,6 +1951,22 @@ describe('chatStore history mapping', () => {
|
|||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('marks the tab idle when a message completes', () => {
|
||||||
|
useChatStore.setState({
|
||||||
|
sessions: {
|
||||||
|
[TEST_SESSION_ID]: makeSession({ chatState: 'thinking' }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||||
|
type: 'message_complete',
|
||||||
|
usage: { input_tokens: 1, output_tokens: 2 },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState).toBe('idle')
|
||||||
|
expect(updateTabStatusMock).toHaveBeenCalledWith(TEST_SESSION_ID, 'idle')
|
||||||
|
})
|
||||||
|
|
||||||
it('flushes pending text before appending a thinking block', () => {
|
it('flushes pending text before appending a thinking block', () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
|||||||
@ -254,6 +254,27 @@ function mergeBackgroundTaskMessages(
|
|||||||
return [...merged].sort((a, b) => a.timestamp - b.timestamp)
|
return [...merged].sort((a, b) => a.timestamp - b.timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeRestoredTerminalGoalEvents(
|
||||||
|
messages: UIMessage[],
|
||||||
|
restoredMessages: UIMessage[],
|
||||||
|
): UIMessage[] {
|
||||||
|
const existingKeys = new Set(messages
|
||||||
|
.filter((message): message is Extract<UIMessage, { type: 'goal_event' }> =>
|
||||||
|
message.type === 'goal_event')
|
||||||
|
.map((message) => `${message.action}:${message.message ?? ''}:${message.objective ?? ''}`))
|
||||||
|
|
||||||
|
const missingTerminalEvents = restoredMessages.filter((
|
||||||
|
message,
|
||||||
|
): message is Extract<UIMessage, { type: 'goal_event' }> =>
|
||||||
|
message.type === 'goal_event' &&
|
||||||
|
(message.action === 'completed' || message.action === 'cleared') &&
|
||||||
|
!existingKeys.has(`${message.action}:${message.message ?? ''}:${message.objective ?? ''}`))
|
||||||
|
|
||||||
|
return missingTerminalEvents.length > 0
|
||||||
|
? [...messages, ...missingTerminalEvents]
|
||||||
|
: messages
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMemoryEventFiles(data: unknown): MemoryEventFile[] {
|
function normalizeMemoryEventFiles(data: unknown): MemoryEventFile[] {
|
||||||
if (!data || typeof data !== 'object') return []
|
if (!data || typeof data !== 'object') return []
|
||||||
const writtenPaths = (data as { writtenPaths?: unknown }).writtenPaths
|
const writtenPaths = (data as { writtenPaths?: unknown }).writtenPaths
|
||||||
@ -592,7 +613,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
s.backgroundAgentTasks ?? {},
|
s.backgroundAgentTasks ?? {},
|
||||||
restoredBackgroundTasks,
|
restoredBackgroundTasks,
|
||||||
),
|
),
|
||||||
messages: mergeBackgroundTaskMessages(s.messages, restoredBackgroundTasks),
|
messages: mergeRestoredTerminalGoalEvents(
|
||||||
|
mergeBackgroundTaskMessages(s.messages, restoredBackgroundTasks),
|
||||||
|
uiMessages,
|
||||||
|
),
|
||||||
})) }
|
})) }
|
||||||
}
|
}
|
||||||
return { sessions: updateSessionIn(state.sessions, sessionId, (s) => ({
|
return { sessions: updateSessionIn(state.sessions, sessionId, (s) => ({
|
||||||
@ -908,6 +932,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
pendingComputerUsePermission: null,
|
pendingComputerUsePermission: null,
|
||||||
elapsedTimer: null,
|
elapsedTimer: null,
|
||||||
}))
|
}))
|
||||||
|
useTabStore.getState().updateTabStatus(sessionId, 'idle')
|
||||||
const notification = wasAgentRunning
|
const notification = wasAgentRunning
|
||||||
? buildAgentCompletionNotification(sessionId, completionMessages, text)
|
? buildAgentCompletionNotification(sessionId, completionMessages, text)
|
||||||
: null
|
: null
|
||||||
|
|||||||
@ -134,6 +134,61 @@ describe('goalEvaluator', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('continues an active goal before evaluating when tasks are still incomplete', async () => {
|
||||||
|
setThreadGoal('thread-eval-open-task', {
|
||||||
|
objective: 'finish all task-list work',
|
||||||
|
now: 1_000,
|
||||||
|
})
|
||||||
|
let evaluatorCalled = false
|
||||||
|
|
||||||
|
const decision = await evaluateThreadGoalAfterTurn({
|
||||||
|
threadId: 'thread-eval-open-task',
|
||||||
|
messages: [
|
||||||
|
createUserMessage({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'task-create-3',
|
||||||
|
content: 'Task #3 created successfully: Perform code review',
|
||||||
|
} as unknown as BetaContentBlock,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createAssistantMessage({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'task-update-3',
|
||||||
|
name: 'TaskUpdate',
|
||||||
|
input: { taskId: '3', status: 'in_progress' },
|
||||||
|
} as unknown as BetaContentBlock,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
assistantMessages: [
|
||||||
|
createAssistantMessage({
|
||||||
|
content: [{ type: 'text', text: 'The implementation is complete.' }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
now: 3_000,
|
||||||
|
evaluate: async () => {
|
||||||
|
evaluatorCalled = true
|
||||||
|
return {
|
||||||
|
complete: true,
|
||||||
|
reason: 'The final answer claims the work is done.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(decision.action).toBe('continue')
|
||||||
|
expect(evaluatorCalled).toBe(false)
|
||||||
|
if (decision.action === 'continue') {
|
||||||
|
expect(decision.reason).toContain('Task #3 (Perform code review) is in_progress')
|
||||||
|
expect(decision.prompt).toContain('The task list is not complete yet')
|
||||||
|
}
|
||||||
|
expect(getThreadGoal('thread-eval-open-task')?.status).toBe('active')
|
||||||
|
})
|
||||||
|
|
||||||
test('hydrates an active goal from persisted slash command history before continuing', async () => {
|
test('hydrates an active goal from persisted slash command history before continuing', async () => {
|
||||||
const threadId = 'thread-eval-hydrate'
|
const threadId = 'thread-eval-hydrate'
|
||||||
|
|
||||||
|
|||||||
@ -78,6 +78,25 @@ export async function evaluateThreadGoalAfterTurn(input: {
|
|||||||
return { action: 'budget_limited', goal: limited }
|
return { action: 'budget_limited', goal: limited }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const taskState = summarizeTaskState([
|
||||||
|
...input.messages,
|
||||||
|
...input.assistantMessages,
|
||||||
|
])
|
||||||
|
if (taskState.incomplete.length > 0) {
|
||||||
|
const reason = formatIncompleteTaskReason(taskState.incomplete)
|
||||||
|
const continued =
|
||||||
|
incrementThreadGoalContinuation(input.threadId, {
|
||||||
|
reason,
|
||||||
|
now,
|
||||||
|
}) ?? accounted
|
||||||
|
return {
|
||||||
|
action: 'continue',
|
||||||
|
goal: continued,
|
||||||
|
reason,
|
||||||
|
prompt: buildGoalContinuationPrompt(continued, reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const transcript = formatTranscript([
|
const transcript = formatTranscript([
|
||||||
...input.messages,
|
...input.messages,
|
||||||
...input.assistantMessages,
|
...input.assistantMessages,
|
||||||
@ -259,6 +278,88 @@ function assistantVisibleText(content: readonly BetaContentBlock[]): string {
|
|||||||
.join('\n')
|
.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TaskSummary = {
|
||||||
|
id: string
|
||||||
|
subject: string | null
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeTaskState(messages: Message[]): { incomplete: TaskSummary[] } {
|
||||||
|
const tasks = new Map<string, TaskSummary>()
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.type === 'assistant') {
|
||||||
|
for (const block of message.message.content) {
|
||||||
|
if (block.type !== 'tool_use') continue
|
||||||
|
if (block.name !== 'TaskUpdate') continue
|
||||||
|
const input = block.input
|
||||||
|
if (!input || typeof input !== 'object') continue
|
||||||
|
const taskId = (input as { taskId?: unknown }).taskId
|
||||||
|
const status = (input as { status?: unknown }).status
|
||||||
|
if (typeof taskId !== 'string' || typeof status !== 'string') continue
|
||||||
|
const existing = tasks.get(taskId)
|
||||||
|
tasks.set(taskId, {
|
||||||
|
id: taskId,
|
||||||
|
subject: existing?.subject ?? null,
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type !== 'user') continue
|
||||||
|
const content = message.message.content
|
||||||
|
if (!Array.isArray(content)) continue
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type !== 'tool_result') continue
|
||||||
|
const text = toolResultText(block.content)
|
||||||
|
const created = text.match(/Task #(\S+) created successfully:\s*(.+)/)
|
||||||
|
if (!created) continue
|
||||||
|
const [, id, subject] = created
|
||||||
|
const existing = tasks.get(id)
|
||||||
|
tasks.set(id, {
|
||||||
|
id,
|
||||||
|
subject: subject.trim(),
|
||||||
|
status: existing?.status ?? 'pending',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
incomplete: [...tasks.values()].filter(task =>
|
||||||
|
task.status === 'pending' || task.status === 'in_progress',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolResultText(content: unknown): string {
|
||||||
|
if (typeof content === 'string') return content
|
||||||
|
if (!Array.isArray(content)) return ''
|
||||||
|
return content
|
||||||
|
.map(item =>
|
||||||
|
item &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
'text' in item &&
|
||||||
|
typeof item.text === 'string'
|
||||||
|
? item.text
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIncompleteTaskReason(tasks: TaskSummary[]): string {
|
||||||
|
const taskList = tasks
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(task => {
|
||||||
|
const label = task.subject ? `Task #${task.id} (${task.subject})` : `Task #${task.id}`
|
||||||
|
return `${label} is ${task.status}`
|
||||||
|
})
|
||||||
|
.join('; ')
|
||||||
|
const suffix = tasks.length > 3 ? `; ${tasks.length - 3} more task(s) are incomplete` : ''
|
||||||
|
return `The task list is not complete yet: ${taskList}${suffix}.`
|
||||||
|
}
|
||||||
|
|
||||||
function getMaxContinuations(): number {
|
function getMaxContinuations(): number {
|
||||||
const raw = process.env.CLAUDE_CODE_GOAL_MAX_CONTINUES
|
const raw = process.env.CLAUDE_CODE_GOAL_MAX_CONTINUES
|
||||||
if (!raw) return DEFAULT_MAX_CONTINUATIONS
|
if (!raw) return DEFAULT_MAX_CONTINUATIONS
|
||||||
|
|||||||
@ -951,16 +951,30 @@ export const AgentTool = buildTool({
|
|||||||
const agentResult = finalizeAgentTool(agentMessages, backgroundedTaskId, metadata);
|
const agentResult = finalizeAgentTool(agentMessages, backgroundedTaskId, metadata);
|
||||||
|
|
||||||
// Mark task completed FIRST so TaskOutput(block=true)
|
// Mark task completed FIRST so TaskOutput(block=true)
|
||||||
// unblocks immediately. classifyHandoffIfNeeded and
|
// unblocks immediately, then notify the parent before
|
||||||
// cleanupWorktreeIfNeeded can hang — they must not gate
|
// optional classifier/worktree cleanup. The parent loop
|
||||||
// the status transition (gh-20236).
|
// depends on this notification to resume.
|
||||||
completeAsyncAgent(agentResult, rootSetAppState);
|
completeAsyncAgent(agentResult, rootSetAppState);
|
||||||
|
|
||||||
// Extract text from agent result content for the notification
|
enqueueAgentNotification({
|
||||||
let finalMessage = extractTextContent(agentResult.content, '\n');
|
taskId: backgroundedTaskId,
|
||||||
|
description,
|
||||||
|
status: 'completed',
|
||||||
|
setAppState: rootSetAppState,
|
||||||
|
finalMessage: extractTextContent(agentResult.content, '\n'),
|
||||||
|
usage: {
|
||||||
|
totalTokens: getTokenCountFromTracker(tracker),
|
||||||
|
toolUses: agentResult.totalToolUseCount,
|
||||||
|
durationMs: agentResult.totalDurationMs
|
||||||
|
},
|
||||||
|
toolUseId: toolUseContext.toolUseId
|
||||||
|
});
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await cleanupWorktreeIfNeeded();
|
||||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||||
const backgroundedAppState = toolUseContext.getAppState();
|
const backgroundedAppState = toolUseContext.getAppState();
|
||||||
const handoffWarning = await classifyHandoffIfNeeded({
|
await classifyHandoffIfNeeded({
|
||||||
agentMessages,
|
agentMessages,
|
||||||
tools: toolUseContext.options.tools,
|
tools: toolUseContext.options.tools,
|
||||||
toolPermissionContext: backgroundedAppState.toolPermissionContext,
|
toolPermissionContext: backgroundedAppState.toolPermissionContext,
|
||||||
@ -968,27 +982,11 @@ export const AgentTool = buildTool({
|
|||||||
subagentType: selectedAgent.agentType,
|
subagentType: selectedAgent.agentType,
|
||||||
totalToolUseCount: agentResult.totalToolUseCount
|
totalToolUseCount: agentResult.totalToolUseCount
|
||||||
});
|
});
|
||||||
if (handoffWarning) {
|
|
||||||
finalMessage = `${handoffWarning}\n\n${finalMessage}`;
|
|
||||||
}
|
}
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logForDebugging(`Backgrounded sync agent post-completion cleanup failed: ${errorMessage(cleanupError)}`);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
// Clean up worktree before notification so we can include it
|
|
||||||
const worktreeResult = await cleanupWorktreeIfNeeded();
|
|
||||||
enqueueAgentNotification({
|
|
||||||
taskId: backgroundedTaskId,
|
|
||||||
description,
|
|
||||||
status: 'completed',
|
|
||||||
setAppState: rootSetAppState,
|
|
||||||
finalMessage,
|
|
||||||
usage: {
|
|
||||||
totalTokens: getTokenCountFromTracker(tracker),
|
|
||||||
toolUses: agentResult.totalToolUseCount,
|
|
||||||
durationMs: agentResult.totalDurationMs
|
|
||||||
},
|
|
||||||
toolUseId: toolUseContext.toolUseId,
|
|
||||||
...worktreeResult
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AbortError) {
|
if (error instanceof AbortError) {
|
||||||
// Transition status BEFORE worktree cleanup so
|
// Transition status BEFORE worktree cleanup so
|
||||||
@ -1002,7 +1000,6 @@ export const AgentTool = buildTool({
|
|||||||
is_built_in_agent: metadata.isBuiltInAgent,
|
is_built_in_agent: metadata.isBuiltInAgent,
|
||||||
reason: 'user_cancel_background' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
reason: 'user_cancel_background' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||||
});
|
});
|
||||||
const worktreeResult = await cleanupWorktreeIfNeeded();
|
|
||||||
const partialResult = extractPartialResult(agentMessages);
|
const partialResult = extractPartialResult(agentMessages);
|
||||||
enqueueAgentNotification({
|
enqueueAgentNotification({
|
||||||
taskId: backgroundedTaskId,
|
taskId: backgroundedTaskId,
|
||||||
@ -1010,23 +1007,22 @@ export const AgentTool = buildTool({
|
|||||||
status: 'killed',
|
status: 'killed',
|
||||||
setAppState: rootSetAppState,
|
setAppState: rootSetAppState,
|
||||||
toolUseId: toolUseContext.toolUseId,
|
toolUseId: toolUseContext.toolUseId,
|
||||||
finalMessage: partialResult,
|
finalMessage: partialResult
|
||||||
...worktreeResult
|
|
||||||
});
|
});
|
||||||
|
void cleanupWorktreeIfNeeded().catch(cleanupError => logForDebugging(`Backgrounded sync agent post-cancel cleanup failed: ${errorMessage(cleanupError)}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const errMsg = errorMessage(error);
|
const errMsg = errorMessage(error);
|
||||||
failAsyncAgent(backgroundedTaskId, errMsg, rootSetAppState);
|
failAsyncAgent(backgroundedTaskId, errMsg, rootSetAppState);
|
||||||
const worktreeResult = await cleanupWorktreeIfNeeded();
|
|
||||||
enqueueAgentNotification({
|
enqueueAgentNotification({
|
||||||
taskId: backgroundedTaskId,
|
taskId: backgroundedTaskId,
|
||||||
description,
|
description,
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: errMsg,
|
error: errMsg,
|
||||||
setAppState: rootSetAppState,
|
setAppState: rootSetAppState,
|
||||||
toolUseId: toolUseContext.toolUseId,
|
toolUseId: toolUseContext.toolUseId
|
||||||
...worktreeResult
|
|
||||||
});
|
});
|
||||||
|
void cleanupWorktreeIfNeeded().catch(cleanupError => logForDebugging(`Backgrounded sync agent post-failure cleanup failed: ${errorMessage(cleanupError)}`));
|
||||||
} finally {
|
} finally {
|
||||||
stopBackgroundedSummarization?.();
|
stopBackgroundedSummarization?.();
|
||||||
clearInvokedSkillsForAgent(syncAgentId);
|
clearInvokedSkillsForAgent(syncAgentId);
|
||||||
|
|||||||
95
src/tools/AgentTool/agentToolUtils.test.ts
Normal file
95
src/tools/AgentTool/agentToolUtils.test.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { afterEach, describe, expect, test } from 'bun:test'
|
||||||
|
import type { AppState } from '../../state/AppState.js'
|
||||||
|
import { IDLE_SPECULATION_STATE } from '../../state/AppStateStore.js'
|
||||||
|
import { createTaskStateBase } from '../../Task.js'
|
||||||
|
import type { ToolUseContext } from '../../Tool.js'
|
||||||
|
import type { LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js'
|
||||||
|
import type { Message } from '../../types/message.js'
|
||||||
|
import { getEmptyToolPermissionContext } from '../../Tool.js'
|
||||||
|
import {
|
||||||
|
getCommandQueue,
|
||||||
|
resetCommandQueue,
|
||||||
|
} from '../../utils/messageQueueManager.js'
|
||||||
|
import { createAssistantMessage } from '../../utils/messages.js'
|
||||||
|
import { runAsyncAgentLifecycle } from './agentToolUtils.js'
|
||||||
|
|
||||||
|
describe('runAsyncAgentLifecycle', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
resetCommandQueue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('notifies the parent before post-completion cleanup finishes', async () => {
|
||||||
|
const taskId = 'agent-notify-first'
|
||||||
|
const abortController = new AbortController()
|
||||||
|
const task: LocalAgentTaskState = {
|
||||||
|
...createTaskStateBase(taskId, 'local_agent', 'Review code', 'toolu_agent'),
|
||||||
|
status: 'running',
|
||||||
|
agentId: taskId,
|
||||||
|
prompt: 'Review code',
|
||||||
|
agentType: 'general-purpose',
|
||||||
|
abortController,
|
||||||
|
retrieved: false,
|
||||||
|
lastReportedToolCount: 0,
|
||||||
|
lastReportedTokenCount: 0,
|
||||||
|
isBackgrounded: true,
|
||||||
|
pendingMessages: [],
|
||||||
|
retain: false,
|
||||||
|
diskLoaded: false,
|
||||||
|
}
|
||||||
|
let appState = {
|
||||||
|
tasks: { [taskId]: task },
|
||||||
|
toolPermissionContext: getEmptyToolPermissionContext(),
|
||||||
|
speculation: IDLE_SPECULATION_STATE,
|
||||||
|
} as unknown as AppState
|
||||||
|
const setAppState = (updater: (prev: AppState) => AppState): void => {
|
||||||
|
appState = updater(appState)
|
||||||
|
}
|
||||||
|
const message = createAssistantMessage({
|
||||||
|
content: [{ type: 'text', text: 'Review complete.' }],
|
||||||
|
}) as Message
|
||||||
|
let cleanupStarted = false
|
||||||
|
|
||||||
|
async function* makeStream(): AsyncGenerator<Message, void> {
|
||||||
|
yield message
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
runAsyncAgentLifecycle({
|
||||||
|
taskId,
|
||||||
|
abortController,
|
||||||
|
makeStream,
|
||||||
|
metadata: {
|
||||||
|
prompt: 'Review code',
|
||||||
|
resolvedAgentModel: 'test-model',
|
||||||
|
isBuiltInAgent: true,
|
||||||
|
startTime: Date.now(),
|
||||||
|
agentType: 'general-purpose',
|
||||||
|
isAsync: true,
|
||||||
|
},
|
||||||
|
description: 'Review code',
|
||||||
|
toolUseContext: {
|
||||||
|
options: { tools: [] },
|
||||||
|
toolUseId: 'toolu_agent',
|
||||||
|
getAppState: () => appState,
|
||||||
|
} as unknown as ToolUseContext,
|
||||||
|
rootSetAppState: setAppState,
|
||||||
|
agentIdForCleanup: taskId,
|
||||||
|
enableSummarization: false,
|
||||||
|
getWorktreeResult: () => {
|
||||||
|
cleanupStarted = true
|
||||||
|
return new Promise(() => {})
|
||||||
|
},
|
||||||
|
}).then(() => 'completed'),
|
||||||
|
new Promise(resolve => setTimeout(() => resolve('timed-out'), 50)),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result).toBe('completed')
|
||||||
|
expect(cleanupStarted).toBe(true)
|
||||||
|
expect(appState.tasks[taskId]?.status).toBe('completed')
|
||||||
|
expect(getCommandQueue()).toHaveLength(1)
|
||||||
|
expect(String(getCommandQueue()[0]?.value)).toContain(
|
||||||
|
'<status>completed</status>',
|
||||||
|
)
|
||||||
|
expect(String(getCommandQueue()[0]?.value)).toContain('Review complete.')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -597,15 +597,29 @@ export async function runAsyncAgentLifecycle({
|
|||||||
const agentResult = finalizeAgentTool(agentMessages, taskId, metadata)
|
const agentResult = finalizeAgentTool(agentMessages, taskId, metadata)
|
||||||
|
|
||||||
// Mark task completed FIRST so TaskOutput(block=true) unblocks
|
// Mark task completed FIRST so TaskOutput(block=true) unblocks
|
||||||
// immediately. classifyHandoffIfNeeded (API call) and getWorktreeResult
|
// immediately, then notify the parent before any optional cleanup. The
|
||||||
// (git exec) are notification embellishments that can hang — they must
|
// parent session depends on this notification to resume its loop.
|
||||||
// not gate the status transition (gh-20236).
|
|
||||||
completeAsyncAgent(agentResult, rootSetAppState)
|
completeAsyncAgent(agentResult, rootSetAppState)
|
||||||
|
|
||||||
let finalMessage = extractTextContent(agentResult.content, '\n')
|
enqueueAgentNotification({
|
||||||
|
taskId,
|
||||||
|
description,
|
||||||
|
status: 'completed',
|
||||||
|
setAppState: rootSetAppState,
|
||||||
|
finalMessage: extractTextContent(agentResult.content, '\n'),
|
||||||
|
usage: {
|
||||||
|
totalTokens: getTokenCountFromTracker(tracker),
|
||||||
|
toolUses: agentResult.totalToolUseCount,
|
||||||
|
durationMs: agentResult.totalDurationMs,
|
||||||
|
},
|
||||||
|
toolUseId: toolUseContext.toolUseId,
|
||||||
|
})
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await getWorktreeResult()
|
||||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||||
const handoffWarning = await classifyHandoffIfNeeded({
|
await classifyHandoffIfNeeded({
|
||||||
agentMessages,
|
agentMessages,
|
||||||
tools: toolUseContext.options.tools,
|
tools: toolUseContext.options.tools,
|
||||||
toolPermissionContext:
|
toolPermissionContext:
|
||||||
@ -614,27 +628,13 @@ export async function runAsyncAgentLifecycle({
|
|||||||
subagentType: metadata.agentType,
|
subagentType: metadata.agentType,
|
||||||
totalToolUseCount: agentResult.totalToolUseCount,
|
totalToolUseCount: agentResult.totalToolUseCount,
|
||||||
})
|
})
|
||||||
if (handoffWarning) {
|
|
||||||
finalMessage = `${handoffWarning}\n\n${finalMessage}`
|
|
||||||
}
|
}
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logForDebugging(
|
||||||
|
`Async agent post-completion cleanup failed: ${errorMessage(cleanupError)}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
})()
|
||||||
const worktreeResult = await getWorktreeResult()
|
|
||||||
|
|
||||||
enqueueAgentNotification({
|
|
||||||
taskId,
|
|
||||||
description,
|
|
||||||
status: 'completed',
|
|
||||||
setAppState: rootSetAppState,
|
|
||||||
finalMessage,
|
|
||||||
usage: {
|
|
||||||
totalTokens: getTokenCountFromTracker(tracker),
|
|
||||||
toolUses: agentResult.totalToolUseCount,
|
|
||||||
durationMs: agentResult.totalDurationMs,
|
|
||||||
},
|
|
||||||
toolUseId: toolUseContext.toolUseId,
|
|
||||||
...worktreeResult,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
stopSummarization?.()
|
stopSummarization?.()
|
||||||
if (error instanceof AbortError) {
|
if (error instanceof AbortError) {
|
||||||
@ -654,7 +654,6 @@ export async function runAsyncAgentLifecycle({
|
|||||||
reason:
|
reason:
|
||||||
'user_kill_async' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
'user_kill_async' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
})
|
})
|
||||||
const worktreeResult = await getWorktreeResult()
|
|
||||||
const partialResult = extractPartialResult(agentMessages)
|
const partialResult = extractPartialResult(agentMessages)
|
||||||
enqueueAgentNotification({
|
enqueueAgentNotification({
|
||||||
taskId,
|
taskId,
|
||||||
@ -663,13 +662,16 @@ export async function runAsyncAgentLifecycle({
|
|||||||
setAppState: rootSetAppState,
|
setAppState: rootSetAppState,
|
||||||
toolUseId: toolUseContext.toolUseId,
|
toolUseId: toolUseContext.toolUseId,
|
||||||
finalMessage: partialResult,
|
finalMessage: partialResult,
|
||||||
...worktreeResult,
|
|
||||||
})
|
})
|
||||||
|
void getWorktreeResult().catch(cleanupError =>
|
||||||
|
logForDebugging(
|
||||||
|
`Async agent post-cancel cleanup failed: ${errorMessage(cleanupError)}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const msg = errorMessage(error)
|
const msg = errorMessage(error)
|
||||||
failAsyncAgent(taskId, msg, rootSetAppState)
|
failAsyncAgent(taskId, msg, rootSetAppState)
|
||||||
const worktreeResult = await getWorktreeResult()
|
|
||||||
enqueueAgentNotification({
|
enqueueAgentNotification({
|
||||||
taskId,
|
taskId,
|
||||||
description,
|
description,
|
||||||
@ -677,8 +679,12 @@ export async function runAsyncAgentLifecycle({
|
|||||||
error: msg,
|
error: msg,
|
||||||
setAppState: rootSetAppState,
|
setAppState: rootSetAppState,
|
||||||
toolUseId: toolUseContext.toolUseId,
|
toolUseId: toolUseContext.toolUseId,
|
||||||
...worktreeResult,
|
|
||||||
})
|
})
|
||||||
|
void getWorktreeResult().catch(cleanupError =>
|
||||||
|
logForDebugging(
|
||||||
|
`Async agent post-failure cleanup failed: ${errorMessage(cleanupError)}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
clearInvokedSkillsForAgent(agentIdForCleanup)
|
clearInvokedSkillsForAgent(agentIdForCleanup)
|
||||||
clearDumpState(agentIdForCleanup)
|
clearDumpState(agentIdForCleanup)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user