mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(desktop): settle background tasks from sessions the desktop never ran
A session finished by another CLI run (official CLI, or one that has exited) kept a background shell "running" forever: the sidebar spun, the header said the session was active, and Stop failed with "CLI session is not running". The CLI records a task's end in several shapes depending on when the notification was delivered, and history restore only read the user-turn one: - notifications drained mid-turn are queued_command attachments; - notifications queued but never delivered survive only as a queue-operation; - TaskStop/KillShell leave no notification, only their own tool result. Read all of them, and when no CLI process exists, converge a Stop of a history-only task to stopped instead of failing.
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
hasVisibleSessionActivity,
|
||||
} from '../components/activity/sessionActivityModel'
|
||||
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
||||
import { hasRunningBackgroundTasks } from '../lib/backgroundTasks'
|
||||
import { registerSideChatSession, unregisterSideChatSession } from '../lib/sideChatSessions'
|
||||
|
||||
const {
|
||||
@@ -4244,6 +4245,85 @@ describe('chatStore history mapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('restores a background shell stopped by TaskStop when no notification was recorded', () => {
|
||||
const shellStart = (toolUseId: string, taskId: string, minute: number): MessageEntry[] => [
|
||||
{
|
||||
id: `${toolUseId}-use`,
|
||||
type: 'assistant',
|
||||
timestamp: `2026-04-06T00:0${minute}:00.000Z`,
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
id: toolUseId,
|
||||
name: 'Bash',
|
||||
input: { command: 'bun run dev', description: `Start ${taskId}`, run_in_background: true },
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: `${toolUseId}-result`,
|
||||
type: 'tool_result',
|
||||
timestamp: `2026-04-06T00:0${minute}:01.000Z`,
|
||||
content: [{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
content: `Command running in background with ID: ${taskId}. Output is being written to: /tmp/${taskId}.output`,
|
||||
}],
|
||||
},
|
||||
]
|
||||
const taskStop = (
|
||||
toolUseId: string,
|
||||
taskId: string,
|
||||
minute: number,
|
||||
isError = false,
|
||||
): MessageEntry[] => [
|
||||
{
|
||||
id: `${toolUseId}-use`,
|
||||
type: 'assistant',
|
||||
timestamp: `2026-04-06T00:0${minute}:00.000Z`,
|
||||
content: [{ type: 'tool_use', id: toolUseId, name: 'TaskStop', input: { task_id: taskId } }],
|
||||
},
|
||||
{
|
||||
id: `${toolUseId}-result`,
|
||||
type: 'tool_result',
|
||||
timestamp: `2026-04-06T00:0${minute}:01.000Z`,
|
||||
content: [{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
// The CLI records the stop as a JSON string and leaves no task-notification.
|
||||
content: isError
|
||||
? 'No task found with ID: ' + taskId
|
||||
: JSON.stringify({
|
||||
message: `Successfully stopped task: ${taskId} (bun run dev)`,
|
||||
task_id: taskId,
|
||||
task_type: 'local_bash',
|
||||
command: 'bun run dev',
|
||||
}),
|
||||
...(isError ? { is_error: true } : {}),
|
||||
}],
|
||||
},
|
||||
]
|
||||
|
||||
const restored = reconstructRunActivityFromTranscript([
|
||||
...shellStart('shell-tool-stopped', 'stopped-task', 0),
|
||||
...shellStart('shell-tool-live', 'live-task', 1),
|
||||
...taskStop('stop-tool-1', 'stopped-task', 2),
|
||||
...taskStop('stop-tool-2', 'live-task', 3, true),
|
||||
...taskStop('stop-tool-3', 'unknown-task', 4),
|
||||
])
|
||||
|
||||
expect(restored.backgroundAgentTasks['stopped-task']).toMatchObject({
|
||||
taskId: 'stopped-task',
|
||||
toolUseId: 'shell-tool-stopped',
|
||||
status: 'stopped',
|
||||
description: 'Start stopped-task',
|
||||
taskType: 'local_bash',
|
||||
})
|
||||
expect(restored.backgroundAgentTasks['live-task']?.status).toBe('running')
|
||||
expect(restored.backgroundAgentTasks['unknown-task']).toBeUndefined()
|
||||
expect(hasRunningBackgroundTasks({
|
||||
'stopped-task': restored.backgroundAgentTasks['stopped-task']!,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes manually backgrounded PowerShell from structured tool output', () => {
|
||||
const restored = reconstructRunActivityFromTranscript([
|
||||
{
|
||||
|
||||
@@ -6990,6 +6990,7 @@ function reconstructBackgroundShellTasks(
|
||||
messages: MessageEntry[],
|
||||
): Record<string, BackgroundAgentTask> {
|
||||
const shellToolUses = new Map<string, TranscriptShellToolUse>()
|
||||
const taskStopInputs = new Map<string, Record<string, unknown>>()
|
||||
let tasks: Record<string, BackgroundAgentTask> = {}
|
||||
|
||||
for (const message of messages) {
|
||||
@@ -6999,11 +7000,12 @@ function reconstructBackgroundShellTasks(
|
||||
) continue
|
||||
|
||||
for (const block of message.content as AssistantHistoryBlock[]) {
|
||||
if (
|
||||
block.type !== 'tool_use' ||
|
||||
!block.id ||
|
||||
!BACKGROUND_SHELL_TOOL_NAMES.has(block.name ?? '')
|
||||
) continue
|
||||
if (block.type !== 'tool_use' || !block.id) continue
|
||||
if (TASK_STOP_TOOL_NAMES.has(block.name ?? '')) {
|
||||
taskStopInputs.set(block.id, readRecord(block.input) ?? {})
|
||||
continue
|
||||
}
|
||||
if (!BACKGROUND_SHELL_TOOL_NAMES.has(block.name ?? '')) continue
|
||||
const input = readRecord(block.input) ?? {}
|
||||
const description = readNonEmptyString(input, 'description', 'command')
|
||||
if (!description) continue
|
||||
@@ -7024,6 +7026,22 @@ function reconstructBackgroundShellTasks(
|
||||
|
||||
for (const block of message.content as UserHistoryBlock[]) {
|
||||
if (block.type !== 'tool_result' || !block.tool_use_id) continue
|
||||
const taskStopInput = taskStopInputs.get(block.tool_use_id)
|
||||
if (taskStopInput) {
|
||||
// A TaskStop leaves no task-notification behind, so its own result is
|
||||
// the only record that a restored shell stopped running.
|
||||
if (block.is_error) continue
|
||||
const output = parseJsonRecord(block.content) ?? readRecord(message.toolUseResult) ?? {}
|
||||
const taskId = readNonEmptyString(output, 'task_id', 'taskId') ??
|
||||
readNonEmptyString(taskStopInput, 'task_id', 'taskId', 'shell_id', 'shellId')
|
||||
if (!taskId || !tasks[taskId]) continue
|
||||
tasks = upsertBackgroundAgentTask(tasks, {
|
||||
taskId,
|
||||
status: 'stopped',
|
||||
summary: readNonEmptyString(output, 'message'),
|
||||
}, transcriptTimestamp(message.timestamp))
|
||||
continue
|
||||
}
|
||||
const shellToolUse = shellToolUses.get(block.tool_use_id)
|
||||
if (!shellToolUse) continue
|
||||
const taskId = shellBackgroundTaskIdFromResult(
|
||||
|
||||
@@ -231,6 +231,101 @@ describe('background task notification persistence', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it('restores terminal notifications the CLI recorded as queued attachments or queue operations', async () => {
|
||||
const sessionId = crypto.randomUUID()
|
||||
const projectDir = path.join(configDir, 'projects', '-tmp-attachment-notification')
|
||||
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`)
|
||||
await fs.mkdir(projectDir, { recursive: true })
|
||||
const notificationXml = (taskId: string, toolUseId: string, status: string) =>
|
||||
`<task-notification>\n<task-id>${taskId}</task-id>\n<tool-use-id>${toolUseId}</tool-use-id>\n<output-file>/tmp/tasks/${taskId}.output</output-file>\n<status>${status}</status>\n<summary>Background command "Install desktop dependencies" ${status} (exit code 0)</summary>\n</task-notification>`
|
||||
// A notification drained mid-turn is written as an attachment, not a user turn
|
||||
// (official CLI 2.1.288 shape); its prompt may also be a content-block array.
|
||||
const entries = [
|
||||
{
|
||||
type: 'queue-operation',
|
||||
operation: 'enqueue',
|
||||
timestamp: '2026-10-05T10:52:39.100Z',
|
||||
sessionId,
|
||||
content: notificationXml('bdlrow0cg', 'toolu_bash_1', 'completed'),
|
||||
},
|
||||
{
|
||||
type: 'attachment',
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-10-05T10:52:39.100Z',
|
||||
attachment: {
|
||||
type: 'queued_command',
|
||||
prompt: notificationXml('bdlrow0cg', 'toolu_bash_1', 'completed'),
|
||||
commandMode: 'task-notification',
|
||||
origin: { kind: 'task-notification', producer: 'session-task' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'attachment',
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-10-05T11:00:00.000Z',
|
||||
attachment: {
|
||||
type: 'queued_command',
|
||||
prompt: [{ type: 'text', text: notificationXml('b2', 'toolu_bash_2', 'killed') }],
|
||||
commandMode: 'task-notification',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Queued but never delivered: the session exited before draining it.
|
||||
type: 'queue-operation',
|
||||
operation: 'enqueue',
|
||||
timestamp: '2026-10-05T11:02:00.000Z',
|
||||
sessionId,
|
||||
content: notificationXml('b3', 'toolu_bash_3', 'failed'),
|
||||
},
|
||||
{
|
||||
type: 'queue-operation',
|
||||
operation: 'remove',
|
||||
timestamp: '2026-10-05T11:03:00.000Z',
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
// A prompt the user typed while a turn was running stays a prompt.
|
||||
type: 'attachment',
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-10-05T11:01:00.000Z',
|
||||
attachment: {
|
||||
type: 'queued_command',
|
||||
prompt: notificationXml('typed', 'toolu_typed', 'completed'),
|
||||
commandMode: 'prompt',
|
||||
},
|
||||
},
|
||||
]
|
||||
await fs.writeFile(transcriptPath, `${entries.map(entry => JSON.stringify(entry)).join('\n')}\n`, 'utf8')
|
||||
|
||||
const service = new SessionService()
|
||||
expect(await service.getSessionTaskNotifications(sessionId)).toEqual([
|
||||
{
|
||||
taskId: 'bdlrow0cg',
|
||||
toolUseId: 'toolu_bash_1',
|
||||
status: 'completed',
|
||||
summary: 'Background command "Install desktop dependencies" completed (exit code 0)',
|
||||
outputFile: '/tmp/tasks/bdlrow0cg.output',
|
||||
timestamp: '2026-10-05T10:52:39.100Z',
|
||||
},
|
||||
{
|
||||
taskId: 'b2',
|
||||
toolUseId: 'toolu_bash_2',
|
||||
status: 'stopped',
|
||||
summary: 'Background command "Install desktop dependencies" killed (exit code 0)',
|
||||
outputFile: '/tmp/tasks/b2.output',
|
||||
timestamp: '2026-10-05T11:00:00.000Z',
|
||||
},
|
||||
{
|
||||
taskId: 'b3',
|
||||
toolUseId: 'toolu_bash_3',
|
||||
status: 'failed',
|
||||
summary: 'Background command "Install desktop dependencies" failed (exit code 0)',
|
||||
outputFile: '/tmp/tasks/b3.output',
|
||||
timestamp: '2026-10-05T11:02:00.000Z',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('restores workflow run identity from task-notification transcript turns', async () => {
|
||||
const sessionId = crypto.randomUUID()
|
||||
const projectDir = path.join(configDir, 'projects', '-tmp-workflow-notification')
|
||||
|
||||
@@ -3927,6 +3927,7 @@ describe('WebSocket handler session isolation', () => {
|
||||
const sessionId = `stop-background-${crypto.randomUUID()}`
|
||||
const ws = makeClientSocket(sessionId)
|
||||
const requestControl = spyOn(conversationService, 'requestControl').mockResolvedValue({})
|
||||
spyOn(conversationService, 'hasSession').mockReturnValue(true)
|
||||
handleWebSocket.open(ws)
|
||||
|
||||
handleWebSocket.message(ws, JSON.stringify({
|
||||
@@ -3945,6 +3946,7 @@ describe('WebSocket handler session isolation', () => {
|
||||
const sessionId = `stop-background-failed-${crypto.randomUUID()}`
|
||||
const ws = makeClientSocket(sessionId)
|
||||
spyOn(conversationService, 'requestControl').mockRejectedValue(new Error('Task is not running'))
|
||||
spyOn(conversationService, 'hasSession').mockReturnValue(true)
|
||||
handleWebSocket.open(ws)
|
||||
|
||||
handleWebSocket.message(ws, JSON.stringify({
|
||||
@@ -3969,6 +3971,7 @@ describe('WebSocket handler session isolation', () => {
|
||||
const ws = makeClientSocket(sessionId)
|
||||
spyOn(conversationService, 'requestControl')
|
||||
.mockRejectedValue(new Error('No task found with ID: bash-task-1'))
|
||||
spyOn(conversationService, 'hasSession').mockReturnValue(true)
|
||||
handleWebSocket.open(ws)
|
||||
|
||||
handleWebSocket.message(ws, JSON.stringify({
|
||||
@@ -4032,6 +4035,39 @@ describe('WebSocket handler session isolation', () => {
|
||||
expect(activeBackgroundTaskIds.get(sessionId)?.has('bash-evicted-1') ?? false).toBe(false)
|
||||
})
|
||||
|
||||
it('converges a history-only running task to stopped when no CLI process exists', async () => {
|
||||
// A session run by an exited (or external) CLI has no process to ask, so a
|
||||
// task its history still shows as running is stopped locally, not reported
|
||||
// as "CLI session is not running".
|
||||
const sessionId = `stop-background-no-cli-${crypto.randomUUID()}`
|
||||
const ws = makeClientSocket(sessionId)
|
||||
spyOn(conversationService, 'hasSession').mockReturnValue(false)
|
||||
const requestControl = spyOn(conversationService, 'requestControl')
|
||||
.mockRejectedValue(new Error('CLI session is not running'))
|
||||
const append = spyOn(sessionService, 'appendSessionTaskNotification').mockResolvedValue()
|
||||
handleWebSocket.open(ws)
|
||||
ws.sent.length = 0
|
||||
|
||||
handleWebSocket.message(ws, JSON.stringify({
|
||||
type: 'stop_background_task',
|
||||
taskId: 'bdlrow0cg',
|
||||
}))
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(requestControl).not.toHaveBeenCalled()
|
||||
expect(append).toHaveBeenCalledWith(sessionId, expect.objectContaining({
|
||||
taskId: 'bdlrow0cg',
|
||||
status: 'stopped',
|
||||
}))
|
||||
const sent = ws.sent.map((payload) => JSON.parse(payload))
|
||||
expect(sent.some((payload) => payload.type === 'background_task_stop_failed')).toBe(false)
|
||||
expect(sent).toContainEqual(expect.objectContaining({
|
||||
type: 'system_notification',
|
||||
subtype: 'task_notification',
|
||||
data: expect.objectContaining({ task_id: 'bdlrow0cg', status: 'stopped' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects malformed background task ids without throwing from the async handler', async () => {
|
||||
const ws = makeClientSocket(`stop-background-invalid-${crypto.randomUUID()}`)
|
||||
const requestControl = spyOn(conversationService, 'requestControl').mockResolvedValue({})
|
||||
|
||||
@@ -2153,6 +2153,36 @@ export class SessionService {
|
||||
}
|
||||
}
|
||||
|
||||
// A notification the CLI drains while a turn is running is recorded as a
|
||||
// queued_command attachment instead of a user turn, and one that was queued
|
||||
// but never delivered (e.g. the session exited first) only as a
|
||||
// queue-operation. For a session the desktop never ran, these are the only
|
||||
// records that the task finished.
|
||||
private parseQueuedTaskNotification(
|
||||
entry: RawEntry,
|
||||
): SessionTaskNotification | null {
|
||||
if (entry.type === 'queue-operation') {
|
||||
return this.parseTaskNotificationContent(entry.content, entry.timestamp)
|
||||
}
|
||||
if (entry.type !== 'attachment') return null
|
||||
const attachment = entry.attachment
|
||||
if (
|
||||
!attachment ||
|
||||
typeof attachment !== 'object' ||
|
||||
Array.isArray(attachment)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const record = attachment as Record<string, unknown>
|
||||
if (
|
||||
record.type !== 'queued_command' ||
|
||||
record.commandMode === 'prompt'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return this.parseTaskNotificationContent(record.prompt, entry.timestamp)
|
||||
}
|
||||
|
||||
private parsePersistedTaskNotification(
|
||||
value: unknown,
|
||||
timestamp?: string,
|
||||
@@ -5363,7 +5393,7 @@ export class SessionService {
|
||||
|
||||
const entries = await this.readTargetedJsonlEntries(
|
||||
found,
|
||||
['user', PERSISTED_TASK_NOTIFICATION_ENTRY_TYPE],
|
||||
['user', 'attachment', 'queue-operation', PERSISTED_TASK_NOTIFICATION_ENTRY_TYPE],
|
||||
) ?? await this.readJsonlFile(found.filePath)
|
||||
return this.taskNotificationsFromEntries(entries)
|
||||
}
|
||||
@@ -5377,7 +5407,7 @@ export class SessionService {
|
||||
? this.parsePersistedTaskNotification(entry.taskNotification, entry.timestamp)
|
||||
: entry.message?.role === 'user'
|
||||
? this.parseTaskNotificationContent(entry.message.content, entry.timestamp)
|
||||
: null
|
||||
: this.parseQueuedTaskNotification(entry)
|
||||
if (notification) {
|
||||
notifications.set(sessionTaskNotificationIdentity(notification), notification)
|
||||
}
|
||||
|
||||
@@ -2496,6 +2496,13 @@ async function requestStopBackgroundTask(
|
||||
await emitAuthoritativeNonAgentStopped(sessionId, tracked)
|
||||
return
|
||||
}
|
||||
// With no CLI process, nothing is running the task any more (it only survives
|
||||
// in the history of an exited or external CLI run), so Stop converges it to a
|
||||
// terminal record instead of failing with "CLI session is not running".
|
||||
if (!conversationService.hasSession(sessionId)) {
|
||||
await convergeEvictedBackgroundTaskStop(sessionId, taskId)
|
||||
return
|
||||
}
|
||||
if (tracked) tracked.stopRequested = true
|
||||
try {
|
||||
const response = await conversationService.requestControl(sessionId, {
|
||||
|
||||
Reference in New Issue
Block a user