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:
程序员阿江(Relakkes)
2026-10-08 18:02:15 +08:00
parent 1c78154525
commit b8871ca0e8
6 changed files with 273 additions and 7 deletions
+80
View File
@@ -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([
{
+23 -5
View File
@@ -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({})
+32 -2
View File
@@ -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)
}
+7
View File
@@ -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, {