feat(cli): make background task UI agnostic to execution type

Add onBackground event to ExecutionLifecycleService that fires when any
execution is moved to the background. The CLI subscribes to this event
and automatically registers background tasks in the UI — no per-tool
changes needed.

Any tool that calls ExecutionLifecycleService.createExecution() or
attachExecution() now automatically gets Ctrl+B support. Shell-specific
concerns (PTY log files) stay in ShellExecutionService.

Forward setExecutionIdCallback through SubAgentInvocation so agents
can expose their execution ID to the scheduler for backgrounding.

Route registerBackgroundTask and dismissBackgroundTask through
ExecutionLifecycleService instead of ShellExecutionService for
agnostic subscribe/onExit/kill support.
This commit is contained in:
Adam Weidman
2026-03-12 14:59:32 -04:00
parent 6510587725
commit d68cc3a88f
6 changed files with 252 additions and 59 deletions
@@ -189,6 +189,7 @@ describe('SubAgentInvocation', () => {
expect(mockInnerInvocation.execute).toHaveBeenCalledWith(
abortSignal,
updateOutput,
undefined,
);
expect(runInDevTraceSpan).toHaveBeenCalledWith(
+3 -1
View File
@@ -13,6 +13,7 @@ import {
type ToolCallConfirmationDetails,
isTool,
type ToolLiveOutput,
type ExecuteOptions,
} from '../tools/tools.js';
import type { Config } from '../config/config.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
@@ -161,6 +162,7 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
async execute(
signal: AbortSignal,
updateOutput?: (output: ToolLiveOutput) => void,
options?: ExecuteOptions,
): Promise<ToolResult> {
const validationError = SchemaValidator.validate(
this.definition.inputConfig.inputSchema,
@@ -188,7 +190,7 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
},
async ({ metadata }) => {
metadata.input = this.params;
const result = await invocation.execute(signal, updateOutput);
const result = await invocation.execute(signal, updateOutput, options);
metadata.output = result;
return result;
},
@@ -296,6 +296,81 @@ describe('ExecutionLifecycleService', () => {
}).toThrow('Execution 4324 is already attached.');
});
describe('Background Start Listeners', () => {
it('fires onBackground when an execution is backgrounded', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackground(listener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'remote_agent',
undefined,
'My Remote Agent',
);
const executionId = handle.pid!;
ExecutionLifecycleService.appendOutput(executionId, 'some output');
ExecutionLifecycleService.background(executionId);
await handle.result;
expect(listener).toHaveBeenCalledTimes(1);
const info = listener.mock.calls[0][0];
expect(info.executionId).toBe(executionId);
expect(info.executionMethod).toBe('remote_agent');
expect(info.label).toBe('My Remote Agent');
expect(info.output).toBe('some output');
ExecutionLifecycleService.offBackground(listener);
});
it('uses fallback label when none is provided', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackground(listener);
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'none',
);
const executionId = handle.pid!;
ExecutionLifecycleService.background(executionId);
await handle.result;
const info = listener.mock.calls[0][0];
expect(info.label).toContain('none');
expect(info.label).toContain(String(executionId));
ExecutionLifecycleService.offBackground(listener);
});
it('does not fire onBackground for non-backgrounded completions', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackground(listener);
const handle = ExecutionLifecycleService.createExecution();
ExecutionLifecycleService.completeExecution(handle.pid!);
await handle.result;
expect(listener).not.toHaveBeenCalled();
ExecutionLifecycleService.offBackground(listener);
});
it('offBackground removes the listener', async () => {
const listener = vi.fn();
ExecutionLifecycleService.onBackground(listener);
ExecutionLifecycleService.offBackground(listener);
const handle = ExecutionLifecycleService.createExecution();
ExecutionLifecycleService.background(handle.pid!);
await handle.result;
expect(listener).not.toHaveBeenCalled();
});
});
describe('Background Completion Listeners', () => {
it('fires onBackgroundComplete with formatInjection text when backgrounded execution settles', async () => {
const listener = vi.fn();
@@ -59,6 +59,8 @@ export interface ExecutionCompletionOptions {
export interface ExternalExecutionRegistration {
executionMethod: ExecutionMethod;
/** Human-readable label for the background task UI (e.g. the command string). */
label?: string;
initialOutput?: string;
getBackgroundOutput?: () => string;
getSubscriptionSnapshot?: () => string | AnsiOutput | undefined;
@@ -79,6 +81,7 @@ export type FormatInjectionFn = (
interface ManagedExecutionBase {
executionMethod: ExecutionMethod;
label?: string;
output: string;
backgrounded?: boolean;
formatInjection?: FormatInjectionFn;
@@ -86,6 +89,18 @@ interface ManagedExecutionBase {
getSubscriptionSnapshot?: () => string | AnsiOutput | undefined;
}
/**
* Payload emitted when an execution is moved to the background.
*/
export interface BackgroundStartInfo {
executionId: number;
executionMethod: ExecutionMethod;
label: string;
output: string;
}
export type BackgroundStartListener = (info: BackgroundStartInfo) => void;
/**
* Payload emitted when a previously-backgrounded execution settles.
*/
@@ -150,6 +165,23 @@ export class ExecutionLifecycleService {
this.injectionService = service;
}
private static backgroundStartListeners = new Set<BackgroundStartListener>();
/**
* Registers a listener that fires when any execution is moved to the background.
* This is the hook for the UI to automatically discover backgrounded executions.
*/
static onBackground(listener: BackgroundStartListener): void {
this.backgroundStartListeners.add(listener);
}
/**
* Unregisters a background start listener.
*/
static offBackground(listener: BackgroundStartListener): void {
this.backgroundStartListeners.delete(listener);
}
/**
* Registers a listener that fires when a previously-backgrounded
* execution settles (completes or errors).
@@ -222,6 +254,7 @@ export class ExecutionLifecycleService {
this.exitedExecutionInfo.clear();
this.backgroundCompletionListeners.clear();
this.injectionService = null;
this.backgroundStartListeners.clear();
this.nextExecutionId = NON_PROCESS_EXECUTION_ID_START;
}
@@ -239,6 +272,7 @@ export class ExecutionLifecycleService {
this.activeExecutions.set(executionId, {
executionMethod: registration.executionMethod,
label: registration.label,
output: registration.initialOutput ?? '',
kind: 'external',
getBackgroundOutput: registration.getBackgroundOutput,
@@ -259,11 +293,13 @@ export class ExecutionLifecycleService {
onKill?: () => void,
executionMethod: ExecutionMethod = 'none',
formatInjection?: FormatInjectionFn,
label?: string,
): ExecutionHandle {
const executionId = this.allocateExecutionId();
this.activeExecutions.set(executionId, {
executionMethod,
label,
output: initialOutput,
kind: 'virtual',
onKill,
@@ -434,6 +470,18 @@ export class ExecutionLifecycleService {
this.activeResolvers.delete(executionId);
execution.backgrounded = true;
// Notify listeners that an execution was moved to the background.
const info: BackgroundStartInfo = {
executionId,
executionMethod: execution.executionMethod,
label:
execution.label ?? `${execution.executionMethod} (ID: ${executionId})`,
output,
};
for (const listener of this.backgroundStartListeners) {
listener(info);
}
}
static subscribe(