Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions docs/design/2026-07-22-background-agent-roster-restore.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -4096,6 +4098,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.
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]),
Expand Down
18 changes: 17 additions & 1 deletion packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -2558,6 +2562,18 @@ export class Session implements SessionContext {
this.pendingWorktreeNotice = null;
}

if (
this.pendingRecoveredAgentsNotice &&
!isContinue &&
!isSlashInput
) {
Comment thread
DragonnZhang marked this conversation as resolved.
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();
Expand Down
95 changes: 95 additions & 0 deletions packages/cli/src/acp-integration/session/Session.worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -67,6 +68,7 @@ describe('Session.pendingWorktreeNotice', () => {

beforeEach(() => {
capturedMessages = [];
vi.mocked(handleSlashCommand).mockReset();

mockChat = {
sendMessageStream: vi
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/ca.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export default {
'toolDisplayName.CronDelete': '刪除定時任務',
'toolDisplayName.LoopWakeup': '循環喚醒',
'toolDisplayName.CreateSubSession': '建立子會話',
'toolDisplayName.ListAgents': '列出 Agent',
'toolDisplayName.TaskCreate': '建立任務',
'toolDisplayName.TaskUpdate': '更新任務',
'toolDisplayName.TaskList': '任務列表',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export default {
'toolDisplayName.CronDelete': '删除定时任务',
'toolDisplayName.LoopWakeup': '循环唤醒',
'toolDisplayName.CreateSubSession': '创建子会话',
'toolDisplayName.ListAgents': '列出 Agent',
'toolDisplayName.TaskCreate': '创建任务',
'toolDisplayName.TaskUpdate': '更新任务',
'toolDisplayName.TaskList': '任务列表',
Expand Down
Loading
Loading