diff --git a/docs/design/2026-07-22-background-agent-roster-restore.md b/docs/design/2026-07-22-background-agent-roster-restore.md new file mode 100644 index 00000000000..974ccb9354e --- /dev/null +++ b/docs/design/2026-07-22-background-agent-roster-restore.md @@ -0,0 +1,69 @@ +# Background Agent Roster Restore + +## Context + +Background-agent sidecars and JSONL transcripts persist logical identity and +history, while `BackgroundTaskRegistry` indexes the current session's +addressable tasks. The resume loader currently restores only sidecars left in +`running` state. Completed agents therefore disappear from the registry after +their parent session is restored, even though their transcripts remain +available. The model also has no tool for querying the registry. + +## Goals + +- Restore recent completed background agents with their original task IDs. +- Add a model-callable `list_agents` tool for on-demand discovery. +- Keep `send_message(task_id)` as the continuation operation. +- Give the model one short, one-shot reminder after restoration. +- Apply the same restoration behavior to TUI, headless, and ACP entry points. + +## Non-goals + +- Persisting a live JavaScript runtime across process teardown. +- Replacing the Agent Teams `task_list` tool. +- Restoring failed or cancelled agents. +- Reconstructing temporary worktree isolation. + +## Design + +The session-directory scan accepts both `running` and `completed` sidecars. +Running entries become paused, preserving the existing interrupted-work +behavior. Completed entries remain completed, are marked already notified, and +retain the transcript and metadata paths needed by `send_message` revival. + +New sidecars persist whether the original launch was backgrounded. Completed +entries are restored only when this marker is explicitly true, so foreground +and legacy unmarked completed sidecars are not exposed as reusable background +agents. Legacy running sidecars retain the existing recovery behavior. + +The loader verifies the sidecar filename and parent-session owner before +registration. A retained row with a missing transcript, mismatched transcript +identity, incompatible isolation, or conflicting working directory remains +visible but is marked non-continuable. Worktree-isolated rows are treated the +same way because their temporary ownership context cannot be reconstructed +safely. Only the newest retained completed entries are restored; running +entries are not subject to that limit. + +`list_agents` reads the live registry and returns background agents with a +stable `task_id`, description, type, status, continuation capability, and any +blocking reason. It does not scan disk. The tool is caller-owned and excluded +from subagents and teammates. + +After restoration, the next ordinary top-level user prompt receives a single +system reminder to call `list_agents` and then `send_message`. Slash commands +and interrupted-turn continuations do not consume this reminder. Bare mode +does not receive it. + +Session switches clear the in-memory registry before loading a new roster. +Failed resume rollback clears partially restored entries before restoring the +old session, and branching is blocked while background work is still active. + +## Validation + +- Running and completed sidecars restore with stable IDs and correct states. +- Foreground and wrong-owner sidecars are excluded. +- Unsafe retained state is visible but cannot be continued. +- Restored completed entries do not emit duplicate completion notifications. +- `send_message` can revive a compatible restored completed entry. +- TUI, headless, and ACP restore the roster and deliver the reminder once. +- New, clear, branch, and failed resume paths do not leak a prior roster. diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 508da501257..84fe4cde06b 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -11559,6 +11559,8 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { getSessionRuntimeBaseDir: vi .fn() .mockReturnValue('/tmp/qwen-runtime-test'), + loadPausedBackgroundAgents: vi.fn().mockResolvedValue([]), + consumePendingRecoveredAgentsNotice: vi.fn().mockReturnValue(null), assertCanStartTurn: vi.fn().mockResolvedValue(undefined), getSessionService: vi.fn(), // load path reads back the persisted conversation here and feeds @@ -12078,7 +12080,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { it('loadSession returns LoadSessionResponse and replays history on the session', async () => { const messages = [{ role: 'user', parts: [{ text: 'hi' }] }]; - bindRestoreMocks({ + const innerConfig = bindRestoreMocks({ sessionExists: true, resumedConversation: { messages, @@ -12107,6 +12109,12 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { const recording = lastSessionMock?.getConfig().getChatRecordingService(); expect(recording?.rebuildTurnBoundaries).toHaveBeenCalledWith(messages); + expect(innerConfig.loadPausedBackgroundAgents).toHaveBeenCalledWith( + 'persisted-1', + ); + expect( + innerConfig.consumePendingRecoveredAgentsNotice, + ).toHaveBeenCalledOnce(); mockConnectionState.resolve(); await agentPromise; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 0e5421408e7..6a37e84e509 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -3969,6 +3969,7 @@ class QwenAgent implements Agent { } await this.#restoreWorktreeOnResume(config, session); + await this.#restoreBackgroundAgentsOnResume(config, session); this.#restoreGoalOnResume(config, session); const modesData = this.buildModesData(config); @@ -4056,6 +4057,7 @@ class QwenAgent implements Agent { } await this.#restoreWorktreeOnResume(config, session); + await this.#restoreBackgroundAgentsOnResume(config, session); this.#restoreGoalOnResume(config, session); const modesData = this.buildModesData(config); @@ -4096,6 +4098,15 @@ class QwenAgent implements Agent { } } + async #restoreBackgroundAgentsOnResume( + config: Config, + session: Session, + ): Promise { + await config.loadPausedBackgroundAgents(config.getSessionId()); + session.pendingRecoveredAgentsNotice = + config.consumePendingRecoveredAgentsNotice(); + } + /** * Re-registers the `/goal` Stop hook when a resumed transcript ends on an * unsatisfied goal — the daemon counterpart of the TUI's resume restore. diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index 61b7e0e0d1b..cf90c1e0ec8 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -331,6 +331,8 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => { getDisableAllHooks: vi.fn().mockReturnValue(true), hasHooksForEvent: vi.fn().mockReturnValue(false), getResumedSessionData: vi.fn().mockReturnValue(undefined), + loadPausedBackgroundAgents: vi.fn().mockResolvedValue(undefined), + consumePendingRecoveredAgentsNotice: vi.fn().mockReturnValue(null), getSessionService: vi.fn().mockReturnValue(mockSessionService), getWorkspaceContext: vi.fn().mockReturnValue({ getDirectories: vi.fn().mockReturnValue([]), diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index b6a94b417ff..fa1b0da541f 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -1197,6 +1197,9 @@ export class Session implements SessionContext { */ pendingWorktreeNotice: string | null = null; + /** One-shot model notice for background agents restored with the session. */ + pendingRecoveredAgentsNotice: string | null = null; + // Implement SessionContext interface readonly sessionId: string; @@ -2398,6 +2401,7 @@ export class Session implements SessionContext { (block) => block.type === 'text', ); const inputText = firstTextBlock?.text || ''; + const isSlashInput = !isContinue && isSlashCommand(inputText); let parts: Part[] | null; let fullTurnModelOverride: string | undefined; @@ -2413,7 +2417,7 @@ export class Session implements SessionContext { // Non-null here: the `none` case returned early above, and both // interruption branches assign a concrete part list. parts = continuationParts!; - } else if (isSlashCommand(inputText)) { + } else if (isSlashInput) { // Handle slash command in ACP mode using capability-based filtering const slashCommandResult = await handleSlashCommand( inputText, @@ -2558,6 +2562,18 @@ export class Session implements SessionContext { this.pendingWorktreeNotice = null; } + if ( + this.pendingRecoveredAgentsNotice && + !isContinue && + !isSlashInput + ) { + const noticePart = { + text: `\n${this.pendingRecoveredAgentsNotice}\n\n\n`, + }; + parts = insertAfterFunctionResponses(parts, [noticePart]); + this.pendingRecoveredAgentsNotice = null; + } + let nextMessage: Content | null = { role: 'user', parts }; let turnCount = 0; const toolLoopState = createDaemonToolLoopState(); diff --git a/packages/cli/src/acp-integration/session/Session.worktree.test.ts b/packages/cli/src/acp-integration/session/Session.worktree.test.ts index ad1d39f0c87..8da4005902d 100644 --- a/packages/cli/src/acp-integration/session/Session.worktree.test.ts +++ b/packages/cli/src/acp-integration/session/Session.worktree.test.ts @@ -26,6 +26,7 @@ import type { PromptRequest, } from '@agentclientprotocol/sdk'; import type { LoadedSettings } from '../../config/settings.js'; +import { handleSlashCommand } from '../../nonInteractiveCliCommands.js'; // Stub the non-interactive CLI commands that Session.ts imports transitively. vi.mock('../../nonInteractiveCliCommands.js', () => ({ @@ -67,6 +68,7 @@ describe('Session.pendingWorktreeNotice', () => { beforeEach(() => { capturedMessages = []; + vi.mocked(handleSlashCommand).mockReset(); mockChat = { sendMessageStream: vi @@ -253,6 +255,99 @@ describe('Session.pendingWorktreeNotice', () => { expect(session.pendingWorktreeNotice).toBeNull(); }); + it('injects a recovered-agents notice into the next prompt once', async () => { + const session = new Session( + SESSION_ID, + mockConfig, + mockClient, + mockSettings, + ); + const notice = + '2 background agents were restored. Use list_agents to inspect them.'; + session.pendingRecoveredAgentsNotice = notice; + + await session.prompt(makePromptRequest('first prompt')); + await session.prompt(makePromptRequest('second prompt')); + + const firstParts = capturedMessages[0] as Array<{ text?: string }>; + expect(firstParts.some((part) => part.text?.includes(notice))).toBe(true); + const secondParts = capturedMessages[1] as Array<{ text?: string }>; + expect(secondParts.some((part) => part.text?.includes(notice))).toBe(false); + expect(session.pendingRecoveredAgentsNotice).toBeNull(); + }); + + it('does not consume a recovered-agents notice for a slash command', async () => { + vi.mocked(handleSlashCommand).mockResolvedValueOnce({ + type: 'submit_prompt', + content: [{ text: 'Prompt from command' }], + }); + const session = new Session( + SESSION_ID, + mockConfig, + mockClient, + mockSettings, + ); + const notice = 'Recovered agents are available.'; + session.pendingRecoveredAgentsNotice = notice; + + await session.prompt(makePromptRequest('/testcommand')); + await session.prompt(makePromptRequest('ordinary prompt')); + + expect(capturedMessages[0]).toEqual([{ text: 'Prompt from command' }]); + expect(capturedMessages[1]).toEqual( + expect.arrayContaining([ + { text: expect.stringContaining(notice) as string }, + { text: 'ordinary prompt' }, + ]), + ); + expect(session.pendingRecoveredAgentsNotice).toBeNull(); + }); + + it('does not consume a recovered-agents notice on an interrupted-turn continuation', async () => { + const session = new Session( + SESSION_ID, + mockConfig, + mockClient, + mockSettings, + ); + const notice = 'Recovered agents are available.'; + session.pendingRecoveredAgentsNotice = notice; + + // A daemon continuation (`qwen.daemon.continueLastTurn`) closing a dangling + // tool call re-sends synthesized functionResponse parts. The one-shot + // recovered-agents notice must survive it (the `!isContinue` guard) so it + // is delivered on the user's next ordinary prompt instead. + vi.mocked(mockChat.getHistory).mockReturnValue([ + { + role: 'model', + parts: [ + { functionCall: { id: 'call-1', name: 'read_file', args: {} } }, + ], + }, + ] as never); + await session.prompt({ + ...makePromptRequest(''), + _meta: { 'qwen.daemon.continueLastTurn': true }, + } as PromptRequest); + + // The continuation send leads with the synthesized functionResponse and + // carries no recovered-agents notice; the notice is still pending. + const continuationParts = capturedMessages[0] as Array<{ text?: string }>; + expect(continuationParts.some((part) => part.text?.includes(notice))).toBe( + false, + ); + expect(session.pendingRecoveredAgentsNotice).toBe(notice); + + // The next ordinary prompt consumes it exactly once. + vi.mocked(mockChat.getHistory).mockReturnValue([]); + await session.prompt(makePromptRequest('ordinary prompt')); + const ordinaryParts = capturedMessages[1] as Array<{ text?: string }>; + expect(ordinaryParts.some((part) => part.text?.includes(notice))).toBe( + true, + ); + expect(session.pendingRecoveredAgentsNotice).toBeNull(); + }); + // VP4b: sanity — no notice set, prompt works normally, no worktree reminder injected it('VP4b: no notice set — prompt proceeds normally without worktree system-reminder', async () => { const session = new Session( diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 9ca3bd1dd55..d20408653cf 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2381,6 +2381,7 @@ export default { 'toolDisplayName.CronDelete': 'Suprimeix tasca programada', 'toolDisplayName.LoopWakeup': 'Desperta el bucle', 'toolDisplayName.CreateSubSession': 'Crea subsessió', + 'toolDisplayName.ListAgents': "Llista d'agents", 'toolDisplayName.TaskCreate': 'Crea tasca', 'toolDisplayName.TaskUpdate': 'Actualitza tasca', 'toolDisplayName.TaskList': 'Llista tasques', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 14675a5571d..b8de4211b25 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -202,6 +202,7 @@ export default { 'toolDisplayName.CronDelete': 'toolDisplayName.CronDelete', 'toolDisplayName.LoopWakeup': 'toolDisplayName.LoopWakeup', 'toolDisplayName.CreateSubSession': 'toolDisplayName.CreateSubSession', + 'toolDisplayName.ListAgents': 'toolDisplayName.ListAgents', 'toolDisplayName.TaskCreate': 'toolDisplayName.TaskCreate', 'toolDisplayName.TaskUpdate': 'toolDisplayName.TaskUpdate', 'toolDisplayName.TaskList': 'toolDisplayName.TaskList', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index d306d30fc5b..833ba86d9d0 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -193,6 +193,7 @@ export default { 'toolDisplayName.CronDelete': '刪除定時任務', 'toolDisplayName.LoopWakeup': '循環喚醒', 'toolDisplayName.CreateSubSession': '建立子會話', + 'toolDisplayName.ListAgents': '列出 Agent', 'toolDisplayName.TaskCreate': '建立任務', 'toolDisplayName.TaskUpdate': '更新任務', 'toolDisplayName.TaskList': '任務列表', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 7753bbd85e2..eda2de7c9cd 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -194,6 +194,7 @@ export default { 'toolDisplayName.CronDelete': '删除定时任务', 'toolDisplayName.LoopWakeup': '循环唤醒', 'toolDisplayName.CreateSubSession': '创建子会话', + 'toolDisplayName.ListAgents': '列出 Agent', 'toolDisplayName.TaskCreate': '创建任务', 'toolDisplayName.TaskUpdate': '更新任务', 'toolDisplayName.TaskList': '任务列表', diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index bc0635f85c3..01b68e1d656 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -334,6 +334,8 @@ describe('runNonInteractive', () => { // --worktree flag, so return null to short-circuit injection // and let the resume-restore branch run. consumePendingStartupWorktreeNotice: vi.fn().mockReturnValue(null), + loadPausedBackgroundAgents: vi.fn().mockResolvedValue([]), + consumePendingRecoveredAgentsNotice: vi.fn().mockReturnValue(null), } as unknown as Config; mockSettings = { @@ -456,6 +458,93 @@ describe('runNonInteractive', () => { expect(mockShutdownTelemetry).toHaveBeenCalled(); }); + it('prepends the recovered background agents notice on a resumed headless prompt', async () => { + setupMetricsMock(); + // A resumed session with a one-shot recovered-agents notice pending. + vi.mocked(mockConfig.getResumedSessionData).mockReturnValue({} as never); + vi.mocked(mockConfig.consumePendingRecoveredAgentsNotice).mockReturnValue( + 'Restored 2 background agents from the previous session.', + ); + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents([ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Continue the work', + 'prompt-resume-notice', + ); + + expect(mockConfig.consumePendingRecoveredAgentsNotice).toHaveBeenCalled(); + const [request] = mockGeminiClient.sendMessageStream.mock.calls[0]!; + // The notice is prepended as a system-reminder ahead of the user prompt. + expect(request).toEqual([ + { + text: expect.stringContaining( + 'Restored 2 background agents from the previous session.', + ), + }, + { text: 'Continue the work' }, + ]); + expect((request as Array<{ text: string }>)[0].text).toContain( + SYSTEM_REMINDER_OPEN, + ); + }); + + it('does not consume the recovered-agents notice on an interrupted-turn continuation', async () => { + setupMetricsMock(); + // A resumed session with a one-shot recovered-agents notice pending, but + // this run is an interrupted-turn continuation. The `!continueInterrupted` + // guard must leave the notice for the user's next ordinary prompt. + vi.mocked(mockConfig.getResumedSessionData).mockReturnValue({} as never); + vi.mocked(mockConfig.consumePendingRecoveredAgentsNotice).mockReturnValue( + 'Restored 2 background agents from the previous session.', + ); + mockGeminiClient.getChat = vi.fn(() => ({ + getDebugResponses: mockGetDebugResponses, + getHistory: vi.fn().mockReturnValue([ + { + role: 'model', + parts: [{ functionCall: { id: 'call-1', name: 'shell' } }], + }, + ]), + })); + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents([ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]), + ); + + await runNonInteractive(mockConfig, mockSettings, '', 'prompt-c-notice', { + continueInterrupted: true, + }); + + // The notice is not consumed, and the continuation send carries only the + // synthesized functionResponse — no recovered-agents system-reminder. + expect( + mockConfig.consumePendingRecoveredAgentsNotice, + ).not.toHaveBeenCalled(); + const [request] = mockGeminiClient.sendMessageStream.mock.calls[0]!; + expect(request).toEqual([ + { + functionResponse: { + id: 'call-1', + name: 'shell', + response: { error: expect.stringContaining('not recorded') }, + }, + }, + ]); + }); + it('does not let headless YOLO bypass explicit teammate approval', async () => { setupMetricsMock(); const teamEvents = new EventEmitter(); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index acf56dfa201..3dc9c320227 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -78,6 +78,8 @@ import { cleanupReviewWorktreeLeases } from './services/review-worktree-lease.js const debugLogger = createDebugLogger('NON_INTERACTIVE_CLI'); +const restoredBackgroundAgentSessions = new WeakMap>(); + /** * Maximum wait, in milliseconds, for in-flight background tasks to emit * their terminal `task_notification` after `abortAll()` on the @@ -650,6 +652,17 @@ export async function runNonInteractive( ); adapter.emitMessage(systemMessage); + const resumedSessionData = config.getResumedSessionData(); + if (resumedSessionData) { + const restoredSessions = + restoredBackgroundAgentSessions.get(config) ?? new Set(); + if (!restoredSessions.has(sessionId)) { + await config.loadPausedBackgroundAgents(sessionId); + restoredSessions.add(sessionId); + restoredBackgroundAgentSessions.set(config, restoredSessions); + } + } + let initialPartList: PartListUnion | null = extractPartsFromUserMessage( options.userMessage, ); @@ -832,10 +845,7 @@ export async function runNonInteractive( adapter.emitSystemMessage('worktree_started', { notice: startupNotice, }); - } else if ( - !options.continueInterrupted && - config.getResumedSessionData() - ) { + } else if (!options.continueInterrupted && resumedSessionData) { try { const sessionPath = config .getSessionService() @@ -859,6 +869,16 @@ export async function runNonInteractive( } } + const recoveredAgentsNotice = + resumedSessionData && + !options.continueInterrupted && + !isSlashCommand(input) + ? config.consumePendingRecoveredAgentsNotice() + : null; + if (recoveredAgentsNotice) { + initialPartList = withReminder(initialPartList, recoveredAgentsNotice); + } + const initialParts = normalizePartList(initialPartList); let currentMessages: Content[] = [{ role: 'user', parts: initialParts }]; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index b1076ca504e..59bb01b2306 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -1145,6 +1145,52 @@ describe('AppContainer State Management', () => { expect(mockQueueMessage).not.toHaveBeenCalled(); }); + it('injects a recovered-agent reminder into the next ordinary prompt once', () => { + const mockQueueMessage = vi.fn(); + vi.spyOn(mockConfig, 'consumePendingRecoveredAgentsNotice') + .mockReturnValueOnce('Use list_agents to inspect restored agents.') + .mockReturnValue(null); + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'idle', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), + streamingResponseLengthRef: { current: 0 }, + isReceivingContent: false, + }); + mockedUseMessageQueue.mockReturnValue({ + messageQueue: [], + addMessage: mockQueueMessage, + clearQueue: vi.fn(), + getQueuedMessagesText: vi.fn().mockReturnValue(''), + popAllMessages: vi.fn().mockReturnValue(null), + drainQueue: vi.fn().mockReturnValue([]), + popNextSegment: vi.fn().mockReturnValue(null), + }); + + render( + , + ); + + capturedUIActions.handleFinalSubmit('continue the review'); + capturedUIActions.handleFinalSubmit('one more check'); + + expect(mockQueueMessage).toHaveBeenNthCalledWith( + 1, + '\nUse list_agents to inspect restored agents.\n' + + '\n\ncontinue the review', + ); + expect(mockQueueMessage).toHaveBeenNthCalledWith(2, 'one more check'); + }); + it.each(['exit', 'quit', ':q', ':q!', ':wq', ':wq!'])( 'routes bare "%s" to /quit instead of sending as a message', (command) => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 2c8be76ca15..8cf9e4fe0d0 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2174,6 +2174,25 @@ export const AppContainer = (props: AppContainerProps) => { // The user's raw text, captured before any `` prefix is // prepended below (so keyword detection sees only what the user typed). const userPromptText = submittedValue; + // Quit must bypass reminders and the message queue so it can stop an + // active stream without consuming one-shot session state. + if ( + ['/quit', '/exit', 'exit', 'quit', ':q', ':q!', ':wq', ':wq!'].includes( + userPromptText.trim(), + ) + ) { + void handleSlashCommand('/quit'); + return; + } + const recoveredAgentsNotice = + !isSlashCommand(userPromptText) && !isBtwCommand(userPromptText) + ? config.consumePendingRecoveredAgentsNotice() + : null; + if (recoveredAgentsNotice) { + submittedValue = + `\n${recoveredAgentsNotice}\n\n\n` + + submittedValue; + } // Phase C: one-shot worktree restore reminder. Set during --resume // when the persisted sidecar names a live worktree. We only inject // on top-level user prompts (not btw-during-response, not slash @@ -2222,16 +2241,6 @@ export const AppContainer = (props: AppContainerProps) => { return; } - // Quit must bypass the message queue so it can stop an active stream. - if ( - ['/quit', '/exit', 'exit', 'quit', ':q', ':q!', ':wq', ':wq!'].includes( - submittedValue.trim(), - ) - ) { - void handleSlashCommand('/quit'); - return; - } - // Check if speculation has results for this submission const spec = speculationRef.current; if ( diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index ec40d8bf73f..2e2483acc2f 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -592,6 +592,9 @@ describe('useResumeCommand', () => { }), expect.any(Number), ); + expect(historyManager.loadHistory.mock.invocationCallOrder[0]).toBeLessThan( + historyManager.addItem.mock.invocationCallOrder[0]!, + ); }); it('blocks resume when the current session still has running background work', async () => { @@ -812,5 +815,13 @@ describe('useResumeCommand', () => { }), expect.any(Number), ); + // The rollback reloads the old session's still-on-disk background agents + // so `list_agents` is not left empty after core is restored. The forward + // path never reached its own load (initialize threw first), so this call + // is the rollback reload, scoped to the old session. + expect(config.loadPausedBackgroundAgents).toHaveBeenCalledTimes(1); + expect(config.loadPausedBackgroundAgents).toHaveBeenCalledWith( + 'old-session-id', + ); }); }); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 9482f8776d0..208cf0a8d53 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -107,6 +107,7 @@ export function useResumeCommand( const oldSessionId = config.getSessionId(); let coreSwapped = false; let uiSwapped = false; + let recoveredBackgroundAgentsNotice: string | null = null; try { const cwd = config.getTargetDir(); @@ -179,13 +180,9 @@ export function useResumeCommand( const recovered = await config.loadPausedBackgroundAgents(sessionId); if (recovered.length > 0) { - const recoveredMessage: HistoryItemWithoutId = { - type: MessageType.INFO, - text: config - .getBackgroundAgentResumeService() - .buildRecoveredBackgroundAgentsNotice(recovered.length), - }; - addItem(recoveredMessage, Date.now()); + recoveredBackgroundAgentsNotice = config + .getBackgroundAgentResumeService() + .buildRecoveredBackgroundAgentsNotice(recovered.length); } // 2. Swap UI. Once this commits, rolling core back is unsafe — @@ -195,6 +192,15 @@ export function useResumeCommand( setSessionName?.(customTitle ?? null); clearItems(); loadHistory(uiHistoryItems); + if (recoveredBackgroundAgentsNotice) { + addItem( + { + type: MessageType.INFO, + text: recoveredBackgroundAgentsNotice, + }, + Date.now(), + ); + } uiSwapped = true; // SessionStart hook is handled during chat initialization so its @@ -209,7 +215,20 @@ export function useResumeCommand( // recorder would keep writing new user messages into the // orphaned session JSONL while UI still shows the old session. try { + resetBackgroundStateForSessionSwitch(config); config.startNewSession(oldSessionId, undefined); + // The forward path cleared the old session's in-memory + // background agents (resetBackgroundStateForSessionSwitch above, + // ~L158) before swapping core. After rolling core back to the old + // session, reload them so `list_agents` reflects the old session's + // still-on-disk sidecars again; otherwise the user lands back on + // the old session with an empty roster until the next process + // start or successful /resume. Best-effort — the guard inside + // loadPausedBackgroundAgents requires the session to already be + // current, which the startNewSession above satisfies. + await config + .loadPausedBackgroundAgents(oldSessionId) + .catch(() => {}); } catch (rollbackErr) { config .getDebugLogger() diff --git a/packages/core/src/agents/agent-transcript.ts b/packages/core/src/agents/agent-transcript.ts index c25e41d4597..77a7a5d2c44 100644 --- a/packages/core/src/agents/agent-transcript.ts +++ b/packages/core/src/agents/agent-transcript.ts @@ -116,6 +116,12 @@ export interface AgentMeta { * `running` as resumable work that was interrupted by process exit. */ status?: 'running' | 'completed' | 'failed' | 'cancelled' | 'paused'; + /** + * Whether the original launch ran asynchronously. Completed entries are + * restored only when this is explicitly true so legacy foreground sidecars + * are never exposed as reusable background agents. + */ + isBackgrounded?: boolean; /** Whether the original launch used temporary worktree isolation. */ isolation?: 'worktree'; /** ISO 8601 timestamp of the latest lifecycle transition. */ @@ -201,6 +207,16 @@ export function writeAgentMeta(metaPath: string, meta: AgentMeta): void { fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8'); } catch (error) { debugLogger.warn(`Failed to write agent meta sidecar ${metaPath}:`, error); + return; + } + try { + const now = new Date(); + fs.utimesSync(path.dirname(metaPath), now, now); + } catch (error) { + debugLogger.warn( + `Failed to refresh agent session directory for ${metaPath}:`, + error, + ); } } diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index 47ef2129a29..ace34288200 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -9,7 +9,10 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import type { Config } from '../config/config.js'; -import { BackgroundTaskRegistry } from './background-tasks.js'; +import { + BackgroundTaskRegistry, + MAX_RETAINED_TERMINAL_AGENTS, +} from './background-tasks.js'; import { BackgroundAgentResumeService } from './background-agent-resume.js'; import { getAgentJsonlPath, @@ -138,7 +141,7 @@ describe('BackgroundAgentResumeService', () => { }; } - it('loads only interrupted running background agents as paused entries', async () => { + it('restores interrupted and completed background agents without notifying again', async () => { const sessionId = 'session-1'; const runningAgentId = 'agent-running'; const completedAgentId = 'agent-completed'; @@ -162,6 +165,7 @@ describe('BackgroundAgentResumeService', () => { parentAgentId: null, createdAt: '2026-04-20T00:00:00.000Z', status: 'running', + isBackgrounded: true, subagentName: 'researcher', resolvedApprovalMode: 'auto-edit', }); @@ -173,6 +177,8 @@ describe('BackgroundAgentResumeService', () => { parentAgentId: null, createdAt: '2026-04-20T00:00:00.000Z', status: 'completed', + isBackgrounded: true, + lastUpdatedAt: '2026-04-20T00:00:02.000Z', subagentName: 'researcher', resolvedApprovalMode: 'auto-edit', }); @@ -204,14 +210,24 @@ describe('BackgroundAgentResumeService', () => { ); fs.writeFileSync( getAgentJsonlPath(tempDir, sessionId, completedAgentId), - '', + JSON.stringify({ + uuid: 'c1', + parentUuid: null, + sessionId, + agentId: completedAgentId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Already done' }] }, + }) + '\n', 'utf8', ); const { service, subagentManager } = createService(); + const onNotification = vi.fn(); + registry.setNotificationCallback(onNotification); const recovered = await service.loadPausedBackgroundAgents(sessionId); - expect(recovered).toHaveLength(1); + expect(recovered).toHaveLength(2); expect(recovered[0]).toMatchObject({ agentId: runningAgentId, status: 'paused', @@ -221,12 +237,127 @@ describe('BackgroundAgentResumeService', () => { metaPath: runningMetaPath, outputFile: getAgentJsonlPath(tempDir, sessionId, runningAgentId), }); + expect(recovered[1]).toMatchObject({ + agentId: completedAgentId, + status: 'completed', + notified: true, + description: 'Already done', + outputFile: getAgentJsonlPath(tempDir, sessionId, completedAgentId), + }); expect(registry.get(runningAgentId)?.status).toBe('paused'); - expect(registry.get(completedAgentId)).toBeUndefined(); - expect(subagentManager.loadSubagent).toHaveBeenCalledTimes(1); + expect(registry.get(completedAgentId)?.status).toBe('completed'); + expect(onNotification).not.toHaveBeenCalled(); + expect(subagentManager.loadSubagent).toHaveBeenCalledTimes(2); expect(subagentManager.loadSubagent).toHaveBeenCalledWith('researcher'); }); + it('excludes foreground, legacy completed, and wrong-owner sidecars', async () => { + const sessionId = 'session-owned'; + const cases = [ + { + agentId: 'foreground', + isBackgrounded: false, + parentSessionId: sessionId, + }, + { agentId: 'legacy-completed', parentSessionId: sessionId }, + { + agentId: 'wrong-owner', + isBackgrounded: true, + parentSessionId: 'other', + }, + ]; + + for (const item of cases) { + writeAgentMeta(getAgentMetaPath(tempDir, sessionId, item.agentId), { + agentId: item.agentId, + agentType: 'researcher', + description: item.agentId, + parentSessionId: item.parentSessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'completed', + isBackgrounded: item.isBackgrounded, + subagentName: 'researcher', + }); + } + + const { service, subagentManager } = createService(); + expect(await service.loadPausedBackgroundAgents(sessionId)).toEqual([]); + expect(subagentManager.loadSubagent).not.toHaveBeenCalled(); + }); + + it('keeps damaged and unsafe retained entries visible but non-continuable', async () => { + const sessionId = 'session-unsafe'; + const missingId = 'missing-transcript'; + const wrongCwdId = 'wrong-cwd'; + const worktreeId = 'worktree-agent'; + for (const agentId of [missingId, wrongCwdId, worktreeId]) { + writeAgentMeta(getAgentMetaPath(tempDir, sessionId, agentId), { + agentId, + agentType: 'researcher', + description: agentId, + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'completed', + isBackgrounded: true, + ...(agentId === worktreeId ? { isolation: 'worktree' as const } : {}), + subagentName: 'researcher', + }); + } + fs.writeFileSync( + getAgentJsonlPath(tempDir, sessionId, wrongCwdId), + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + agentId: wrongCwdId, + cwd: path.join(tempDir, 'another-workspace'), + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Unsafe cwd' }] }, + }) + '\n', + 'utf8', + ); + fs.writeFileSync( + getAgentJsonlPath(tempDir, sessionId, worktreeId), + JSON.stringify({ + uuid: 'w1', + parentUuid: null, + sessionId, + agentId: worktreeId, + cwd: tempDir, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Worktree task' }] }, + }) + '\n', + 'utf8', + ); + + const { service } = createService(); + const recovered = await service.loadPausedBackgroundAgents(sessionId); + + expect(recovered).toHaveLength(3); + expect(registry.get(missingId)).toMatchObject({ + status: 'completed', + resumeBlockedReason: + 'Background task transcript is missing or unreadable.', + }); + expect(registry.get(wrongCwdId)).toMatchObject({ + status: 'completed', + resumeBlockedReason: + 'Background task working directory does not match the restored session.', + }); + expect(registry.get(worktreeId)).toMatchObject({ + status: 'completed', + resumeBlockedReason: + 'Background task worktree isolation cannot be reconstructed after session restore.', + }); + expect( + await service.reviveCompletedBackgroundAgent(missingId, 'continue'), + ).toBeUndefined(); + }); + it('preserves model on recovered paused agents for per-model caps', async () => { const sessionId = 'session-model'; const agentId = 'agent-model'; @@ -3035,6 +3166,127 @@ describe('BackgroundAgentResumeService', () => { expect(registry.get(agentId)?.status).toBe('completed'); expect(subagentManager.createAgentHeadless).not.toHaveBeenCalled(); }); + + it('preserves pre-revive activity state when a completed revive fails', async () => { + const sessionId = 'session-revive-rollback-state'; + const agentId = 'agent-revive-rollback-state'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Finished research', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'completed', + subagentName: 'researcher', + resolvedApprovalMode: 'default', + }); + fs.writeFileSync( + outputFile, + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Finished research' }] }, + }) + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: 'Finished research', + subagentType: 'researcher', + isBackgrounded: true, + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + outputFile, + metaPath, + }); + registry.complete(agentId, 'All done'); + // Populate the pre-revive UI state that must survive a failed revive. + const activities = [ + { name: 'Read', description: 'read src/index.ts', at: 1 }, + { name: 'Bash', description: 'npm test', at: 2 }, + ]; + registry.get(agentId)!.recentActivities = activities; + + const { service, subagentManager } = createService(); + // Force the revive to fail after the entry has been transitioned to paused + // (which resets `recentActivities` to []), exercising the rollback path. + subagentManager.createAgentHeadless.mockRejectedValue( + new Error('setup failed'), + ); + + await expect( + service.reviveCompletedBackgroundAgent(agentId, 'keep going'), + ).resolves.toBeUndefined(); + + const restored = registry.get(agentId); + expect(restored?.status).toBe('completed'); + // Regression guard: a `??` fallback would keep the paused entry's empty + // `recentActivities`, silently dropping the retained activities. The + // completed snapshot must be restored instead. + expect(restored?.recentActivities).toEqual(activities); + }); + + it('does not restore more completed agents than the terminal-agent cap', async () => { + const sessionId = 'session-terminal-cap'; + const extra = 3; + const total = MAX_RETAINED_TERMINAL_AGENTS + extra; + const ids: string[] = []; + for (let i = 0; i < total; i++) { + const agentId = `cap-agent-${String(i).padStart(3, '0')}`; + ids.push(agentId); + // Higher index → newer recovery timestamp, so the newest `cap` entries + // (by `lastUpdatedAt`) are the ones that must survive the cap. + const lastUpdatedAt = `2026-04-20T00:00:${String(i).padStart(2, '0')}.000Z`; + writeAgentMeta(getAgentMetaPath(tempDir, sessionId, agentId), { + agentId, + agentType: 'researcher', + description: agentId, + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'completed', + isBackgrounded: true, + lastUpdatedAt, + subagentName: 'researcher', + }); + fs.writeFileSync( + getAgentJsonlPath(tempDir, sessionId, agentId), + JSON.stringify({ + uuid: `u-${agentId}`, + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: agentId }] }, + }) + '\n', + 'utf8', + ); + } + + const { service } = createService(); + const recovered = await service.loadPausedBackgroundAgents(sessionId); + + // Only the cap's worth of completed agents are admitted... + expect(recovered).toHaveLength(MAX_RETAINED_TERMINAL_AGENTS); + expect(registry.getAll()).toHaveLength(MAX_RETAINED_TERMINAL_AGENTS); + // ...and they are the most recent by recovery timestamp; the oldest + // `extra` sidecars are dropped rather than admitted over the cap. + for (const id of ids.slice(extra)) { + expect(registry.get(id)?.status).toBe('completed'); + } + for (const id of ids.slice(0, extra)) { + expect(registry.get(id)).toBeUndefined(); + } + }); }); function readMetaStatus(metaPath: string): string | undefined { diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index f50207dac6a..92a5c2f34c4 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -18,11 +18,14 @@ import { import { AgentTerminateMode } from './runtime/agent-types.js'; import { AgentHeadless, ContextState } from './runtime/agent-headless.js'; import { + getAgentJsonlPath, + getAgentMetaPath, getSubagentSessionDir, normalizeResumedAgentDepth, readAgentMeta, patchAgentMeta, attachJsonlTranscriptWriter, + type AgentMeta, } from './agent-transcript.js'; import type { ChatRecord } from '../services/chatRecordingService.js'; import { buildOrderedUuidChain } from '../utils/conversation-chain.js'; @@ -43,11 +46,12 @@ import { FORK_SUBAGENT_TYPE, runInForkContext, } from '../tools/agent/fork-subagent.js'; -import type { - AgentCompletionStats, - AgentTask, - AgentTaskRegistration, - ResidentBackgroundAgent, +import { + MAX_RETAINED_TERMINAL_AGENTS, + type AgentCompletionStats, + type AgentTask, + type AgentTaskRegistration, + type ResidentBackgroundAgent, } from './background-tasks.js'; import type { SubagentConfig } from '../subagents/types.js'; import { BUBBLE_APPROVAL_MODE } from '../subagents/types.js'; @@ -74,6 +78,16 @@ const LEGACY_FORK_RESUME_BLOCKED_REASON = 'Fork background task cannot be safely resumed because its bootstrap transcript is missing.'; const LEGACY_FORK_CAPABILITIES_BLOCKED_REASON = 'Fork background task cannot be safely resumed because its launch-time runtime constraints are missing.'; +const MISSING_TRANSCRIPT_BLOCKED_REASON = + 'Background task transcript is missing or unreadable.'; +const TRANSCRIPT_IDENTITY_BLOCKED_REASON = + 'Background task transcript does not match its retained identity.'; +const WORKING_DIRECTORY_BLOCKED_REASON = + 'Background task working directory does not match the restored session.'; +const WORKTREE_ISOLATION_BLOCKED_REASON = + 'Background task worktree isolation cannot be reconstructed after session restore.'; +const INCOMPATIBLE_ISOLATION_BLOCKED_REASON = + 'Background task isolation metadata is incompatible.'; type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo'; @@ -370,8 +384,27 @@ function getCompletionStats( function buildRecoveredNotice(count: number): string { return count === 1 - ? 'Recovered 1 interrupted background agent. Open Background tasks and press r to resume.' - : `Recovered ${count} interrupted background agents. Open Background tasks and press r to resume.`; + ? 'Restored 1 background agent from this session. Open Background tasks to inspect it.' + : `Restored ${count} background agents from this session. Open Background tasks to inspect them.`; +} + +function buildRecoveredModelNotice(count: number): string { + return ( + `${count} background agent${count === 1 ? ' was' : 's were'} restored ` + + `from this session. Use list_agents to inspect ${count === 1 ? 'it' : 'them'} ` + + 'and send_message with a task_id to continue one.' + ); +} + +interface RecoverableAgentSidecar { + fileName: string; + metaPath: string; + meta: AgentMeta; +} + +function recoveryTimestamp(meta: AgentMeta): number { + const parsed = Date.parse(meta.lastUpdatedAt ?? meta.createdAt); + return Number.isFinite(parsed) ? parsed : 0; } export class BackgroundAgentResumeService { @@ -394,29 +427,106 @@ export class BackgroundAgentResumeService { throw error; } - const registry = this.config.getBackgroundTaskRegistry(); - const recovered: AgentTask[] = []; - + const sidecars: RecoverableAgentSidecar[] = []; for (const fileName of files) { if (!fileName.endsWith(META_FILE_SUFFIX)) continue; const metaPath = path.join(dir, fileName); try { const meta = readAgentMeta(metaPath); - if (!meta || meta.status !== 'running') continue; + if ( + !meta || + typeof meta.agentId !== 'string' || + meta.agentId.length === 0 || + meta.parentSessionId !== sessionId || + path.basename( + getAgentMetaPath(projectDir, sessionId, meta.agentId), + ) !== fileName || + (meta.status !== 'running' && meta.status !== 'completed') || + (meta.isBackgrounded !== undefined && + typeof meta.isBackgrounded !== 'boolean') || + meta.isBackgrounded === false || + (meta.status === 'completed' && meta.isBackgrounded !== true) + ) { + continue; + } + sidecars.push({ fileName, metaPath, meta }); + } catch (error) { + debugLogger.warn( + `[BackgroundAgentResume] Failed to read background agent metadata from ${metaPath}:`, + error, + ); + } + } + + sidecars.sort((a, b) => { + if (a.meta.status !== b.meta.status) { + return a.meta.status === 'running' ? -1 : 1; + } + if (a.meta.status === 'completed') { + return recoveryTimestamp(b.meta) - recoveryTimestamp(a.meta); + } + return a.fileName.localeCompare(b.fileName); + }); + + const registry = this.config.getBackgroundTaskRegistry(); + const recovered: AgentTask[] = []; + let completedCount = registry + .getAll() + .filter((entry) => entry.notified === true).length; + + for (const { metaPath, meta } of sidecars) { + if ( + meta.status === 'completed' && + completedCount >= MAX_RETAINED_TERMINAL_AGENTS + ) { + continue; + } + try { if (registry.get(meta.agentId)) continue; const subagentName = meta.subagentName ?? meta.agentType; - if (!subagentName) continue; + if (typeof subagentName !== 'string' || !subagentName) continue; const target = await this.resolveResumeTarget(subagentName); - const outputFile = path.join( - dir, - fileName.slice(0, -META_FILE_SUFFIX.length) + '.jsonl', + const outputFile = getAgentJsonlPath( + projectDir, + sessionId, + meta.agentId, ); const records = await jsonl.read(outputFile); const recovery = recoverTranscript(records); const parsedStartTime = Date.parse(meta.createdAt); + const parsedEndTime = Date.parse(meta.lastUpdatedAt ?? meta.createdAt); + const projectRoot = path.resolve(this.config.getProjectRoot()); + let retainedStateBlockedReason: string | undefined; + if (records.length === 0) { + retainedStateBlockedReason = MISSING_TRANSCRIPT_BLOCKED_REASON; + } else if ( + records.some( + (record) => + record.sessionId !== sessionId || + (record.agentId !== undefined && record.agentId !== meta.agentId), + ) + ) { + retainedStateBlockedReason = TRANSCRIPT_IDENTITY_BLOCKED_REASON; + } else if ( + meta.isolation !== undefined && + meta.isolation !== 'worktree' + ) { + retainedStateBlockedReason = INCOMPATIBLE_ISOLATION_BLOCKED_REASON; + } else if (meta.isolation === 'worktree') { + retainedStateBlockedReason = WORKTREE_ISOLATION_BLOCKED_REASON; + } else if ( + records.some( + (record) => + typeof record.cwd === 'string' && + path.resolve(record.cwd) !== projectRoot, + ) + ) { + retainedStateBlockedReason = WORKING_DIRECTORY_BLOCKED_REASON; + } const resumeBlockedReason = + retainedStateBlockedReason || target.unavailableReason || (target.isFork && !recovery.forkBootstrap ? LEGACY_FORK_RESUME_BLOCKED_REASON @@ -431,10 +541,17 @@ export class BackgroundAgentResumeService { description: meta.description, subagentType: target.agentName, isBackgrounded: true, - status: 'paused', + status: meta.status === 'running' ? 'paused' : 'completed', startTime: Number.isFinite(parsedStartTime) ? parsedStartTime : Date.now(), + ...(meta.status === 'completed' + ? { + endTime: Number.isFinite(parsedEndTime) + ? parsedEndTime + : Date.now(), + } + : {}), abortController: new AbortController(), prompt: recovery.initialPrompt, toolUseId: meta.toolUseId, @@ -449,9 +566,19 @@ export class BackgroundAgentResumeService { // UI falls back to its generic orphan annotation. parentAgentId: meta.parentAgentId, depth: meta.depth, - model: meta.model, + model: meta.model ?? meta.persistedCliFlags?.model, }; - const entry = registry.register(registration); + if (meta.status === 'completed') { + (registration as AgentTask).notified = true; + (registration as AgentTask).outputOffset = 0; + } + const entry = registry.register(registration, { + suppressRegisterCallback: meta.status === 'completed', + preserveNotificationState: meta.status === 'completed', + }); + if (meta.status === 'completed') { + completedCount += 1; + } recovered.push(entry); } catch (error) { debugLogger.warn( @@ -503,8 +630,8 @@ export class BackgroundAgentResumeService { * * Returns `undefined` (and logs why) when the agent can't be revived: not an * in-registry, finished background agent with a persisted transcript, or the - * background-agent concurrency cap is full. Cross-session / evicted completed - * agents are out of scope (see QwenLM/qwen-code#5540). + * background-agent concurrency cap is full. Completed agents restored from + * the same parent session use this path after process restart. */ async reviveCompletedBackgroundAgent( agentId: string, @@ -523,7 +650,8 @@ export class BackgroundAgentResumeService { !entry.isBackgrounded || entry.status !== 'completed' || !entry.metaPath || - !entry.outputFile + !entry.outputFile || + entry.resumeBlockedReason ) { debugLogger.warn( `[BackgroundAgentResume] Cannot revive "${agentId}": not a completed ` + @@ -602,7 +730,34 @@ export class BackgroundAgentResumeService { this.restorePausedEntry(agentId, { suppressRegisterCallback: true }); const revived = await this.resumeBackgroundAgent(agentId, initialMessage); if (!revived) { - this.restoreCompletedEntry(completedEntry); + const failedEntry = registry.get(agentId); + // `??` only falls back on null/undefined, so a failed revive that left + // the entry with an *empty* array would clobber the pre-revive snapshot + // (e.g. the UI Progress section would render empty instead of the + // retained activities). Prefer the failed entry's state only when it + // actually carries items; otherwise fall back to the completed snapshot. + const preferNonEmpty = ( + next: readonly T[] | undefined, + fallback: readonly T[], + ): T[] => (next && next.length > 0 ? [...next] : [...fallback]); + this.restoreCompletedEntry({ + ...completedEntry, + resumeBlockedReason: + failedEntry?.resumeBlockedReason ?? + completedEntry.resumeBlockedReason, + pendingMessages: preferNonEmpty( + failedEntry?.pendingMessages, + completedEntry.pendingMessages, + ), + recentActivities: preferNonEmpty( + failedEntry?.recentActivities, + completedEntry.recentActivities, + ), + pendingApprovals: preferNonEmpty( + failedEntry?.pendingApprovals, + completedEntry.pendingApprovals, + ), + }); } return revived; } @@ -616,6 +771,9 @@ export class BackgroundAgentResumeService { if (!existing || existing.status !== 'paused') { return existing; } + if (existing.resumeBlockedReason) { + return undefined; + } const metaPath = existing.metaPath; const outputFile = existing.outputFile; @@ -1309,6 +1467,10 @@ export class BackgroundAgentResumeService { return buildRecoveredNotice(count); } + buildRecoveredBackgroundAgentsModelNotice(count: number): string { + return buildRecoveredModelNotice(count); + } + private async resolveResumeTarget( subagentName: string, ): Promise { diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index fdbc251e20d..89e245ef893 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -142,6 +142,7 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet = new Set([ ToolNames.CRON_CREATE, ToolNames.CRON_LIST, ToolNames.CRON_DELETE, + ToolNames.LIST_AGENTS, ToolNames.TASK_STOP, ToolNames.SEND_MESSAGE, ToolNames.TEAM_CREATE, @@ -175,6 +176,7 @@ const EXCLUDED_TOOLS_FOR_TEAMMATES: ReadonlySet = new Set([ ToolNames.CRON_CREATE, ToolNames.CRON_LIST, ToolNames.CRON_DELETE, + ToolNames.LIST_AGENTS, ToolNames.TASK_STOP, ToolNames.TEAM_CREATE, ToolNames.TEAM_DELETE, diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index dcfebe6aad7..be9d521e12f 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -6755,6 +6755,16 @@ describe('setApprovalMode with folder trust', () => { vi.clearAllMocks(); }); + it('registers the background-agent roster tool', async () => { + const config = new Config(baseParams); + await config.initialize(); + + const calls = (ToolRegistry.prototype.registerFactory as Mock).mock.calls; + expect(calls.some((call) => call[0] === ToolNames.LIST_AGENTS)).toBe( + true, + ); + }); + it('should register grep tool when useRipgrep is true and it is available', async () => { (canUseRipgrep as Mock).mockResolvedValue(true); const config = new Config({ ...baseParams, useRipgrep: true }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index fadc24bd152..896f58fa4ef 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1603,6 +1603,7 @@ export class Config { * process exit (which dies with the process — no leak). */ private pendingStartupWorktreeNotice: string | null = null; + private pendingRecoveredAgentsNotice: string | null = null; private debugLogger: DebugLogger; private toolRegistry!: ToolRegistry; /** @@ -3430,6 +3431,7 @@ export class Config { process.env['QWEN_CODE_SESSION_ID'] = this.sessionId; } this.sessionData = sessionData; + this.pendingRecoveredAgentsNotice = null; setDebugLogSession(this); this.debugLogger = createDebugLogger(); this.chatRecordingService = this.chatRecordingEnabled @@ -6506,9 +6508,36 @@ export class Config { async loadPausedBackgroundAgents( sessionId: string = this.getSessionId(), ): Promise> { - return this.getBackgroundAgentResumeService().loadPausedBackgroundAgents( - sessionId, - ); + if (sessionId !== this.getSessionId()) { + this.debugLogger.warn( + `Refusing to restore background agents for non-current session ${sessionId}.`, + ); + return []; + } + const service = this.getBackgroundAgentResumeService(); + let recovered: ReadonlyArray< + import('../agents/background-tasks.js').AgentTask + >; + try { + recovered = await service.loadPausedBackgroundAgents(sessionId); + } catch (error) { + this.debugLogger.warn( + `Background agent restore failed for session ${sessionId}; continuing without restored agents.`, + error, + ); + return []; + } + if (recovered.length > 0 && !this.getBareMode()) { + this.pendingRecoveredAgentsNotice = + service.buildRecoveredBackgroundAgentsModelNotice(recovered.length); + } + return recovered; + } + + consumePendingRecoveredAgentsNotice(): string | null { + const notice = this.pendingRecoveredAgentsNotice; + this.pendingRecoveredAgentsNotice = null; + return notice; } async resumeBackgroundAgent( @@ -6797,6 +6826,10 @@ export class Config { const { AgentTool } = await import('../tools/agent/agent.js'); return new AgentTool(this); }); + await registerLazy(ToolNames.LIST_AGENTS, async () => { + const { ListAgentsTool } = await import('../tools/list-agents.js'); + return new ListAgentsTool(this); + }); await registerLazy(ToolNames.TASK_STOP, async () => { const { TaskStopTool } = await import('../tools/task-stop.js'); return new TaskStopTool(this); diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 42ce9932b9d..e040e74a05d 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -374,8 +374,9 @@ describe('AgentTool', () => { 'Reuse an existing background agent for related follow-up work', ); expect(tool.description).toContain( - 'send_message with the `agentId` from its launch result as its `task_id`', + 'list_agents to inspect the current roster', ); + expect(tool.description).toContain('send_message with its `task_id`'); expect(tool.description).toContain('next tool-round boundary'); expect(tool.description).toContain( 'paused agents resume with it as their first continuation instruction', @@ -4466,7 +4467,7 @@ describe('AgentTool', () => { expect(llmText).toContain( `Use ${ToolNames.SEND_MESSAGE} to continue this agent`, ); - expect(llmText).toContain('agentId: monitor-'); + expect(llmText).toContain('task_id: monitor-'); expect(llmText).toContain(`or ${ToolNames.TASK_STOP} to cancel.`); expect(llmText).not.toContain('with to:'); expect(llmText).not.toContain('Use send_message with task_id:'); diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 22072289f84..e74796b0971 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -903,7 +903,7 @@ Usage notes: - Run agents concurrently only when their tasks are independent. For code changes, give concurrent agents disjoint write scopes; launch them in a single message with multiple tool uses. - A background agent reports its result through a completion notification in a later turn. A foreground agent returns its result inline. Agent results are not visible to the user, so relay the relevant outcome in your response. - While background agents run, continue meaningful non-overlapping work. Wait for an agent only when its result blocks the next required step. -- Reuse an existing background agent for related follow-up work instead of launching a duplicate: call ${ToolNames.SEND_MESSAGE} with the \`agentId\` from its launch result as its \`task_id\`. Running agents receive the message at the next tool-round boundary; paused agents resume with it as their first continuation instruction; completed agents continue on their resident runtime when available and otherwise revive from their retained transcript. If the task is no longer retained or cannot be resumed or revived, launch a new agent. +- Reuse an existing background agent for related follow-up work instead of launching a duplicate: call ${ToolNames.LIST_AGENTS} to inspect the current roster, then call ${ToolNames.SEND_MESSAGE} with its \`task_id\`. Running agents receive the message at the next tool-round boundary; paused agents resume with it as their first continuation instruction; completed agents continue on their resident runtime when available and otherwise revive from their retained transcript. If the task is no longer retained or cannot be resumed or revived, launch a new agent. - Provide clear, detailed prompts so the agent can work autonomously and return exactly the information you need. - Regular subagents and named teammates start without parent conversation history. Only fork agents accept \`fork_turns\`; omit it for the full conversation or use a positive integer string such as \`"3"\` for a bounded recent window. - Treat the agent's output as evidence, not as automatically correct. Verify factual claims, review code changes, and run relevant checks before integrating or relaying the result. @@ -3074,6 +3074,7 @@ class AgentToolInvocation extends BaseToolInvocation { parentAgentId: getCurrentAgentId(), createdAt: new Date().toISOString(), status: 'running', + isBackgrounded: true, isolation: this.params.isolation, lastUpdatedAt: new Date().toISOString(), resolvedApprovalMode, @@ -3606,7 +3607,7 @@ class AgentToolInvocation extends BaseToolInvocation { return { llmContent: `Background agent launched successfully.\n` + - `agentId: ${hookOpts.agentId} (internal ID — do not mention to the user. Use ${ToolNames.SEND_MESSAGE} to continue this agent, or ${ToolNames.TASK_STOP} to cancel.)\n` + + `task_id: ${hookOpts.agentId} (internal ID — do not mention to the user. Use ${ToolNames.SEND_MESSAGE} to continue this agent, or ${ToolNames.TASK_STOP} to cancel.)\n` + `The agent is working in the background. You will be notified automatically when it completes.\n` + `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\n` + `output_file: ${jsonlPath}\n` + @@ -3863,6 +3864,8 @@ class AgentToolInvocation extends BaseToolInvocation { parentAgentId: getCurrentAgentId(), createdAt: new Date().toISOString(), status: 'running', + isBackgrounded: false, + isolation: this.params.isolation, lastUpdatedAt: new Date().toISOString(), resolvedApprovalMode, persistedCliFlags: capturePersistedCliFlags( diff --git a/packages/core/src/tools/list-agents.test.ts b/packages/core/src/tools/list-agents.test.ts new file mode 100644 index 00000000000..7ca39d0e21c --- /dev/null +++ b/packages/core/src/tools/list-agents.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Config } from '../config/config.js'; +import { BackgroundTaskRegistry } from '../agents/background-tasks.js'; +import { ListAgentsTool } from './list-agents.js'; + +describe('ListAgentsTool', () => { + let registry: BackgroundTaskRegistry; + let tool: ListAgentsTool; + + beforeEach(() => { + registry = new BackgroundTaskRegistry(); + tool = new ListAgentsTool({ + getBackgroundTaskRegistry: () => registry, + } as unknown as Config); + }); + + it('reports an empty roster', async () => { + const result = await tool.validateBuildAndExecute( + {}, + new AbortController().signal, + ); + + expect(tool.name).toBe('list_agents'); + expect(result.llmContent).toBe( + 'No background agents are available in this session.', + ); + }); + + it('lists only background agents with stable continuation fields', async () => { + registry.register({ + agentId: 'agent-running', + subagentType: 'explore', + description: 'Inspect runtime', + isBackgrounded: true, + status: 'running', + startTime: 1, + abortController: new AbortController(), + outputFile: '/tmp/agent-running.jsonl', + }); + registry.register({ + agentId: 'agent-foreground', + description: 'Inline work', + isBackgrounded: false, + status: 'running', + startTime: 2, + abortController: new AbortController(), + outputFile: '/tmp/agent-foreground.jsonl', + }); + registry.register({ + agentId: 'agent-blocked', + description: 'Unsafe restore', + isBackgrounded: true, + status: 'completed', + startTime: 3, + endTime: 4, + abortController: new AbortController(), + outputFile: '/tmp/agent-blocked.jsonl', + resumeBlockedReason: 'Transcript does not match.', + }); + + const result = await tool.validateBuildAndExecute( + {}, + new AbortController().signal, + ); + + expect(JSON.parse(String(result.llmContent))).toEqual({ + agents: [ + { + task_id: 'agent-running', + subagent_type: 'explore', + description: 'Inspect runtime', + status: 'running', + can_message: true, + }, + { + task_id: 'agent-blocked', + description: 'Unsafe restore', + status: 'completed', + can_message: false, + resume_blocked_reason: 'Transcript does not match.', + }, + ], + }); + }); +}); diff --git a/packages/core/src/tools/list-agents.ts b/packages/core/src/tools/list-agents.ts new file mode 100644 index 00000000000..0613a2a456d --- /dev/null +++ b/packages/core/src/tools/list-agents.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + type ToolInvocation, + type ToolResult, +} from './tools.js'; + +export type ListAgentsParams = Record; + +class ListAgentsInvocation extends BaseToolInvocation< + ListAgentsParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: ListAgentsParams, + ) { + super(params); + } + + getDescription(): string { + return 'List background agents'; + } + + async execute(): Promise { + const agents = this.config + .getBackgroundTaskRegistry() + .getAll() + .filter((entry) => entry.isBackgrounded) + .map((entry) => ({ + task_id: entry.agentId, + ...(entry.subagentType ? { subagent_type: entry.subagentType } : {}), + description: entry.description, + status: entry.status, + can_message: + !entry.resumeBlockedReason && + (entry.status === 'running' || + entry.status === 'paused' || + entry.status === 'completed'), + ...(entry.resumeBlockedReason + ? { resume_blocked_reason: entry.resumeBlockedReason } + : {}), + })); + + if (agents.length === 0) { + const message = 'No background agents are available in this session.'; + return { llmContent: message, returnDisplay: message }; + } + + return { + llmContent: JSON.stringify({ agents }), + returnDisplay: `Listed ${agents.length} background agent${ + agents.length === 1 ? '' : 's' + }.`, + }; + } +} + +export class ListAgentsTool extends BaseDeclarativeTool< + ListAgentsParams, + ToolResult +> { + static readonly Name = ToolNames.LIST_AGENTS; + + constructor(private readonly config: Config) { + super( + ListAgentsTool.Name, + ToolDisplayNames.LIST_AGENTS, + 'List addressable background agents in the current session, including ' + + 'agents restored from a prior session run. Use the returned task_id ' + + 'with send_message to continue a running, paused, or completed agent.', + Kind.Read, + { + type: 'object', + properties: {}, + additionalProperties: false, + }, + ); + } + + protected createInvocation( + params: ListAgentsParams, + ): ToolInvocation { + return new ListAgentsInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/send-message.test.ts b/packages/core/src/tools/send-message.test.ts index ea1526e20b9..06b835e8010 100644 --- a/packages/core/src/tools/send-message.test.ts +++ b/packages/core/src/tools/send-message.test.ts @@ -453,6 +453,32 @@ describe('SendMessageTool — background-task mode', () => { expect(result.llmContent).toContain('could not be revived'); }); + it('reports the retained-state reason without attempting continuation', async () => { + registry.register({ + agentId: 'agent-1', + description: 'unsafe restored agent', + status: 'completed', + startTime: Date.now(), + abortController: new AbortController(), + isBackgrounded: true, + outputFile: '/tmp/test.jsonl', + metaPath: '/tmp/test.meta.json', + resumeBlockedReason: 'Background task transcript is missing.', + }); + + const result = await tool.validateBuildAndExecute( + { task_id: 'agent-1', message: 'try again' }, + new AbortController().signal, + ); + + expect(result.error?.type).toBe(ToolErrorType.SEND_MESSAGE_NOT_RUNNING); + expect(result.llmContent).toContain( + 'Background task transcript is missing.', + ); + expect(reviveCompletedBackgroundAgent).not.toHaveBeenCalled(); + expect(resumeBackgroundAgent).not.toHaveBeenCalled(); + }); + it('includes task description in success display', async () => { registry.register({ agentId: 'agent-1', diff --git a/packages/core/src/tools/send-message.ts b/packages/core/src/tools/send-message.ts index b378134e628..6c863783f3c 100644 --- a/packages/core/src/tools/send-message.ts +++ b/packages/core/src/tools/send-message.ts @@ -108,6 +108,17 @@ class SendMessageInvocation extends BaseToolInvocation< }; } + if (entry.resumeBlockedReason) { + return { + llmContent: `Error: Background task "${this.params.task_id}" cannot be continued: ${entry.resumeBlockedReason}`, + returnDisplay: 'Task cannot be continued.', + error: { + message: `Task cannot be continued: ${this.params.task_id}`, + type: ToolErrorType.SEND_MESSAGE_NOT_RUNNING, + }, + }; + } + if (entry.status === 'paused') { const resumed = await this.config.resumeBackgroundAgent( this.params.task_id, @@ -288,8 +299,8 @@ export class SendMessageTool extends BaseDeclarativeTool< ToolDisplayNames.SEND_MESSAGE, 'Send a message to a teammate (use "to") or to a running, paused, or completed background task (use "task_id"); completed tasks are revived. ' + 'For teams, set "to" to a bare teammate name (no @) or "*" to broadcast. ' + - 'For background tasks, set "task_id" to the id from the launch response, a recovered paused task, or a completed task. ' + - 'Running tasks receive it at the next tool-round boundary; paused recovered tasks resume with the message as their first continuation instruction; completed tasks continue on their resident runtime when available and otherwise revive from their transcript. ' + + 'For background tasks, set "task_id" to the id from the launch response or list_agents. ' + + 'Running tasks receive it at the next tool-round boundary; paused recovered tasks resume with the message as their first continuation instruction; completed tasks continue on their resident runtime when available and otherwise revive from their transcript and continue with your message. ' + 'Your text output is NOT visible to other agents — use this tool to communicate.', Kind.Other, { diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 0329560aec1..21668ed38aa 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -40,6 +40,7 @@ export const ToolNames = { CRON_DELETE: 'cron_delete', LOOP_WAKEUP: 'loop_wakeup', CREATE_SUB_SESSION: 'create_sub_session', + LIST_AGENTS: 'list_agents', TASK_STOP: 'task_stop', TASK_CREATE: 'task_create', TASK_UPDATE: 'task_update', @@ -94,6 +95,7 @@ export const ToolDisplayNames = { CRON_DELETE: 'CronDelete', LOOP_WAKEUP: 'LoopWakeup', CREATE_SUB_SESSION: 'CreateSubSession', + LIST_AGENTS: 'ListAgents', TASK_STOP: 'TaskStop', TASK_CREATE: 'TaskCreate', TASK_UPDATE: 'TaskUpdate', diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts index 49b1fb2ade4..c38cc6408f1 100644 --- a/packages/web-shell/client/components/messages/toolFormatting.ts +++ b/packages/web-shell/client/components/messages/toolFormatting.ts @@ -34,6 +34,7 @@ export const TOOL_DISPLAY_NAMES: Record = { loop_wakeup: 'LoopWakeup', create_sub_session: 'CreateSubSession', task_stop: 'TaskStop', + list_agents: 'ListAgents', send_message: 'SendMessage', structured_output: 'StructuredOutput', monitor: 'Monitor', diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 7f224768af5..58ff5bb4dc1 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2273,6 +2273,7 @@ const ZH: Messages = { 'toolName.team_create': '创建团队', 'toolName.team_delete': '删除团队', 'toolName.send_message': '发送消息', + 'toolName.list_agents': '列出 Agent', 'toolName.structured_output': '结构化输出', 'toolName.monitor': '监控', 'toolName.notebook_edit': '编辑 Notebook',