feat(core): restore background agent roster (#7459)

* feat(core): restore background agent roster

* fix(web-shell): add list_agents to TOOL_DISPLAY_NAMES

The new list_agents core wire tool was added to core's ToolNames but not
to the web-shell TOOL_DISPLAY_NAMES map, causing toolFormatting.drift.test.ts
to fail (expected ['list_agents'] to deeply equal []). Add the missing
'ListAgents' display-name entry so the browser panel shows a friendly name
instead of the raw wire name and the drift guard passes.

* fix(cli): reload old-session background agents on failed resume rollback

When /resume fails after core has swapped but before the UI swap, the catch
block rolls core back to the old session via startNewSession(oldSessionId).
However the forward path already called resetBackgroundStateForSessionSwitch,
which cleared the old session's in-memory background agents. The rollback did
not reload them, so list_agents returned empty for the old session (whose
sidecars are still on disk) until the next process start or successful resume.

Reload the old session's paused background agents after rolling core back, so
the restored roster matches on-disk state. Placed after startNewSession so the
loadPausedBackgroundAgents current-session guard is satisfied; best-effort via
.catch so it never blocks the rollback path.

* fix(web-shell): add zh translation for list_agents tool name

The toolFormatting test 'has a zh translation for every tool in the
display-name map' failed with expected ['list_agents'] to deeply equal []
because list_agents was added to TOOL_DISPLAY_NAMES without a matching
toolName.list_agents zh-CN entry. Add the translation to restore parity.

* fix(cli): resolve CI failures for background-agent roster restore

- Add toolDisplayName.ListAgents translations (en, zh, zh-TW, ca) so the
  new list_agents tool has a zh entry; fixes i18n/index.test.ts.
- Add loadPausedBackgroundAgents and consumePendingRecoveredAgentsNotice
  to the acpAgent worktree test config mock, which loadSession now calls
  via #restoreBackgroundAgentsOnResume; fixes acpAgent.worktree.test.ts.

* refactor(core): extract incompatible-isolation blocked reason to a const

Move the incompatible-isolation blocked-reason string out of an inline
literal into a module-level INCOMPATIBLE_ISOLATION_BLOCKED_REASON const,
matching its four sibling reasons so the text is discoverable by
constant-name grep and edited alongside the others.

* fix(core): preserve retained activity state on failed agent revive

Address review feedback on the background-agent roster restore:

- On a failed completed-agent revive, restore UI state with a non-empty
  guard instead of `??`. Because `restorePausedEntry` resets the paused
  entry's `recentActivities` to `[]`, the previous `failedEntry?.field ??
  completedEntry.field` kept that empty array and dropped the pre-revive
  snapshot (the UI Progress section rendered empty). Applied consistently
  to pendingMessages, recentActivities, and pendingApprovals.

Add regression coverage for previously untested paths:

- failed revive preserves pre-revive recentActivities
- terminal-agent cap admits only the newest MAX_RETAINED_TERMINAL_AGENTS
  completed sidecars on restore
- /resume rollback reloads the old session's background agents
- headless resume prepends the recovered-agents notice to the prompt

* test(cli): cover interrupted-turn continuation not consuming recovered-agents notice

Add ACP and headless regression tests asserting an interrupted-turn
continuation does not consume the one-shot recovered-agents notice
(the !isContinue / !continueInterrupted guards), so it is delivered on
the user's next ordinary prompt. Mirrors the existing slash-command
coverage.

---------

Co-authored-by: Claude <[email protected]>
This commit is contained in:
DragonandClaude authored and GitHub committed 2026-07-22 12:35:02 +00:00
1 parent ca084dd11f
commit 3a2a74a69b
31 files changed
+1165 -60

No files matched your search

@@ -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.
@@ -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;
@@ -4040,6 +4040,7 @@ class QwenAgent implements Agent {
}
await this.#restoreWorktreeOnResume(config, session);
await this.#restoreBackgroundAgentsOnResume(config, session);
this.#restoreGoalOnResume(config, session);
const modesData = this.buildModesData(config);
@@ -4127,6 +4128,7 @@ class QwenAgent implements Agent {
}
await this.#restoreWorktreeOnResume(config, session);
await this.#restoreBackgroundAgentsOnResume(config, session);
this.#restoreGoalOnResume(config, session);
const modesData = this.buildModesData(config);
@@ -4167,6 +4169,15 @@ class QwenAgent implements Agent {
}
}
async #restoreBackgroundAgentsOnResume(
config: Config,
session: Session,
): Promise<void> {
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.
@@ -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([]),
@@ -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: `<system-reminder>\n${this.pendingRecoveredAgentsNotice}\n</system-reminder>\n\n`,
};
parts = insertAfterFunctionResponses(parts, [noticePart]);
this.pendingRecoveredAgentsNotice = null;
}
let nextMessage: Content | null = { role: 'user', parts };
let turnCount = 0;
const toolLoopState = createDaemonToolLoopState();
@@ -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(
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -193,6 +193,7 @@ export default {
'toolDisplayName.CronDelete': '刪除定時任務',
'toolDisplayName.LoopWakeup': '循環喚醒',
'toolDisplayName.CreateSubSession': '建立子會話',
'toolDisplayName.ListAgents': '列出 Agent',
'toolDisplayName.TaskCreate': '建立任務',
'toolDisplayName.TaskUpdate': '更新任務',
'toolDisplayName.TaskList': '任務列表',
+1
View File
@@ -194,6 +194,7 @@ export default {
'toolDisplayName.CronDelete': '删除定时任务',
'toolDisplayName.LoopWakeup': '循环唤醒',
'toolDisplayName.CreateSubSession': '创建子会话',
'toolDisplayName.ListAgents': '列出 Agent',
'toolDisplayName.TaskCreate': '创建任务',
'toolDisplayName.TaskUpdate': '更新任务',
'toolDisplayName.TaskList': '任务列表',
@@ -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();
+24 -4
View File
@@ -78,6 +78,8 @@ import { cleanupReviewWorktreeLeases } from './services/review-worktree-lease.js
const debugLogger = createDebugLogger('NON_INTERACTIVE_CLI');
const restoredBackgroundAgentSessions = new WeakMap<Config, Set<string>>();
/**
* 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<string>();
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 }];
+46
View File
@@ -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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
capturedUIActions.handleFinalSubmit('continue the review');
capturedUIActions.handleFinalSubmit('one more check');
expect(mockQueueMessage).toHaveBeenNthCalledWith(
1,
'<system-reminder>\nUse list_agents to inspect restored agents.\n' +
'</system-reminder>\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) => {
+19 -10
View File
@@ -2174,6 +2174,25 @@ export const AppContainer = (props: AppContainerProps) => {
// The user's raw text, captured before any `<system-reminder>` 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 =
`<system-reminder>\n${recoveredAgentsNotice}\n</system-reminder>\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 (
@@ -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',
);
});
});
+26 -7
View File
@@ -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()
@@ -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,
);
}
}
@@ -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 {
@@ -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<ChatRecord>(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 = <T>(
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<ResolvedResumeTarget> {
@@ -142,6 +142,7 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet<string> = 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<string> = new Set([
ToolNames.CRON_CREATE,
ToolNames.CRON_LIST,
ToolNames.CRON_DELETE,
ToolNames.LIST_AGENTS,
ToolNames.TASK_STOP,
ToolNames.TEAM_CREATE,
ToolNames.TEAM_DELETE,
+10
View File
@@ -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 });
+36 -3
View File
@@ -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<ReadonlyArray<import('../agents/background-tasks.js').AgentTask>> {
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);
+3 -2
View File
@@ -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',
@@ -4490,7 +4491,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:');
+5 -2
View File
@@ -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.
@@ -3083,6 +3083,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
parentAgentId: getCurrentAgentId(),
createdAt: new Date().toISOString(),
status: 'running',
isBackgrounded: true,
isolation: this.params.isolation,
lastUpdatedAt: new Date().toISOString(),
resolvedApprovalMode,
@@ -3615,7 +3616,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
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` +
@@ -3872,6 +3873,8 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
parentAgentId: getCurrentAgentId(),
createdAt: new Date().toISOString(),
status: 'running',
isBackgrounded: false,
isolation: this.params.isolation,
lastUpdatedAt: new Date().toISOString(),
resolvedApprovalMode,
persistedCliFlags: capturePersistedCliFlags(
@@ -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.',
},
],
});
});
});
+95
View File
@@ -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<string, never>;
class ListAgentsInvocation extends BaseToolInvocation<
ListAgentsParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: ListAgentsParams,
) {
super(params);
}
getDescription(): string {
return 'List background agents';
}
async execute(): Promise<ToolResult> {
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<ListAgentsParams, ToolResult> {
return new ListAgentsInvocation(this.config, params);
}
}
@@ -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',
+13 -2
View File
@@ -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,
{
+2
View File
@@ -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',
@@ -34,6 +34,7 @@ export const TOOL_DISPLAY_NAMES: Record<string, string> = {
loop_wakeup: 'LoopWakeup',
create_sub_session: 'CreateSubSession',
task_stop: 'TaskStop',
list_agents: 'ListAgents',
send_message: 'SendMessage',
structured_output: 'StructuredOutput',
monitor: 'Monitor',
+1
View File
@@ -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',