mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-26 10:41:41 +00:00
feat(core): managed background shell pool with /bashes command
Replace shell.ts's `&` fork-and-detach background path with a managed process registry. Background shells now have observable lifecycle, captured output, and explicit cancellation — matching the pattern used by background subagents (#3076). Phase B from #3634 (background task management roadmap). What changes - New `BackgroundShellRegistry` (services/backgroundShellRegistry.ts): per-process entry with status (running / completed / failed / cancelled), AbortController, output file path. State transitions are one-shot (terminal status sticks; late callbacks no-op). Mirrors the lifecycle shape of #3471's BackgroundTaskRegistry so the two can be unified later. - `shell.ts` is_background path rewritten as `executeBackground`: - Spawns the unwrapped command (no '&', no pgrep envelope) - Streams stdout to `<projectDir>/tasks/<sessionId>/shell-<id>.output` (path layout aligns with the direction sketched in #3471 review) - Bridges the external abort signal into the entry's AbortController so a single source of truth governs cancellation - Returns immediately with id + output path; agent's turn isn't blocked - Settles the registry entry asynchronously when ShellExecutionService resolves: complete (clean exit) / fail (error) / cancel (aborted) - Removes ~120 lines of dead bg-specific code from shell.ts: pgrep wrapping, '&' appending, Windows ampersand cleanup, Windows early-return path, bg PID parsing, tempFile cleanup - New `/bashes` slash command: lists registered shells with id, status, runtime, command, output path. Empty state prints a friendly message. What this PR doesn't do - Footer pill / dialog integration — gated on #3488 landing - task_stop / send_message integration — gated on #3471 landing - Auto-backgrounding heuristics for long foreground bash — Phase D Test plan - 11 registry unit tests (state machine + idempotent terminal transitions) - 4 background-path tests in shell.test.ts (spawn no-wrap + complete / fail / cancel settle paths) - 2 /bashes command tests (empty + populated) - Full core suite: 247 files / 6075 passed (existing tests unaffected)
This commit is contained in:
parent
569cfe10fa
commit
c8d7d151a1
9 changed files with 791 additions and 406 deletions
|
|
@ -8,6 +8,7 @@ import type { ICommandLoader } from './types.js';
|
|||
import type { SlashCommand } from '../ui/commands/types.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { bashesCommand } from '../ui/commands/bashesCommand.js';
|
||||
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||
import { arenaCommand } from '../ui/commands/arenaCommand.js';
|
||||
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
|
||||
|
|
@ -91,6 +92,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
const allDefinitions: Array<SlashCommand | null> = [
|
||||
aboutCommand,
|
||||
agentsCommand,
|
||||
bashesCommand,
|
||||
arenaCommand,
|
||||
approvalModeCommand,
|
||||
authCommand,
|
||||
|
|
|
|||
94
packages/cli/src/ui/commands/bashesCommand.test.ts
Normal file
94
packages/cli/src/ui/commands/bashesCommand.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { bashesCommand } from './bashesCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import type { BackgroundShellEntry } from '@qwen-code/qwen-code-core';
|
||||
|
||||
function entry(
|
||||
overrides: Partial<BackgroundShellEntry> = {},
|
||||
): BackgroundShellEntry {
|
||||
return {
|
||||
shellId: 'bg_aaaaaaaa',
|
||||
command: 'sleep 60',
|
||||
cwd: '/tmp',
|
||||
status: 'running',
|
||||
startTime: Date.now() - 5_000,
|
||||
outputPath: '/tmp/tasks/sess/shell-bg_aaaaaaaa.output',
|
||||
abortController: new AbortController(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('bashesCommand', () => {
|
||||
let context: CommandContext;
|
||||
let getAll: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
getAll = vi.fn().mockReturnValue([]);
|
||||
context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getBackgroundShellRegistry: () => ({ getAll }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createMockCommandContext>[0]);
|
||||
});
|
||||
|
||||
it('reports an empty registry', async () => {
|
||||
const result = await bashesCommand.action!(context, '');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No background shells.',
|
||||
});
|
||||
});
|
||||
|
||||
it('lists running and terminal entries with status / runtime / output path', async () => {
|
||||
getAll.mockReturnValue([
|
||||
entry({
|
||||
shellId: 'bg_run',
|
||||
command: 'npm run dev',
|
||||
status: 'running',
|
||||
startTime: Date.now() - 12_000,
|
||||
pid: 1111,
|
||||
}),
|
||||
entry({
|
||||
shellId: 'bg_done',
|
||||
command: 'npm test',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
startTime: Date.now() - 70_000,
|
||||
endTime: Date.now() - 5_000,
|
||||
outputPath: '/tmp/tasks/sess/shell-bg_done.output',
|
||||
}),
|
||||
entry({
|
||||
shellId: 'bg_fail',
|
||||
command: 'flaky.sh',
|
||||
status: 'failed',
|
||||
error: 'spawn ENOENT',
|
||||
startTime: Date.now() - 3_000,
|
||||
endTime: Date.now() - 2_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await bashesCommand.action!(context, '');
|
||||
if (!result || result.type !== 'message') {
|
||||
throw new Error('expected message result');
|
||||
}
|
||||
expect(result.content).toContain('Background shells (3 total)');
|
||||
expect(result.content).toContain('[bg_run] running');
|
||||
expect(result.content).toContain('pid=1111');
|
||||
expect(result.content).toContain('npm run dev');
|
||||
expect(result.content).toContain('[bg_done] completed (exit 0)');
|
||||
expect(result.content).toContain('[bg_fail] failed: spawn ENOENT');
|
||||
expect(result.content).toContain(
|
||||
'output: /tmp/tasks/sess/shell-bg_done.output',
|
||||
);
|
||||
});
|
||||
});
|
||||
85
packages/cli/src/ui/commands/bashesCommand.ts
Normal file
85
packages/cli/src/ui/commands/bashesCommand.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { BackgroundShellEntry } from '@qwen-code/qwen-code-core';
|
||||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
function formatRuntime(ms: number): string {
|
||||
if (ms < 0) ms = 0;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m${remainingSeconds.toString().padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
function statusLabel(entry: BackgroundShellEntry): string {
|
||||
switch (entry.status) {
|
||||
case 'completed':
|
||||
return `completed (exit ${entry.exitCode ?? '?'})`;
|
||||
case 'failed':
|
||||
return `failed: ${entry.error ?? 'unknown error'}`;
|
||||
case 'cancelled':
|
||||
return 'cancelled';
|
||||
case 'running':
|
||||
return 'running';
|
||||
default:
|
||||
return entry.status;
|
||||
}
|
||||
}
|
||||
|
||||
export const bashesCommand: SlashCommand = {
|
||||
name: 'bashes',
|
||||
altNames: ['shells'],
|
||||
get description() {
|
||||
return t('List background shells started via the shell tool');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context) => {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'error' as const,
|
||||
content: 'Config not available.',
|
||||
};
|
||||
}
|
||||
|
||||
const entries = config.getBackgroundShellRegistry().getAll();
|
||||
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'info' as const,
|
||||
content: 'No background shells.',
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const lines: string[] = [
|
||||
`Background shells (${entries.length} total)`,
|
||||
'',
|
||||
];
|
||||
for (const entry of entries) {
|
||||
const endTime = entry.endTime ?? now;
|
||||
const runtime = formatRuntime(endTime - entry.startTime);
|
||||
const pidPart = entry.pid !== undefined ? ` pid=${entry.pid}` : '';
|
||||
lines.push(
|
||||
`[${entry.shellId}] ${statusLabel(entry)} ${runtime}${pidPart} ${entry.command}`,
|
||||
);
|
||||
lines.push(` output: ${entry.outputPath}`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'info' as const,
|
||||
content: lines.join('\n'),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -61,6 +61,7 @@ import { PermissionManager } from '../permissions/permission-manager.js';
|
|||
import { SubagentManager } from '../subagents/subagent-manager.js';
|
||||
import type { SubagentConfig } from '../subagents/types.js';
|
||||
import { BackgroundTaskRegistry } from '../agents/background-tasks.js';
|
||||
import { BackgroundShellRegistry } from '../services/backgroundShellRegistry.js';
|
||||
import {
|
||||
DEFAULT_OTLP_ENDPOINT,
|
||||
DEFAULT_TELEMETRY_TARGET,
|
||||
|
|
@ -544,6 +545,7 @@ export class Config {
|
|||
private promptRegistry!: PromptRegistry;
|
||||
private subagentManager!: SubagentManager;
|
||||
private readonly backgroundTaskRegistry = new BackgroundTaskRegistry();
|
||||
private readonly backgroundShellRegistry = new BackgroundShellRegistry();
|
||||
private extensionManager!: ExtensionManager;
|
||||
private skillManager: SkillManager | null = null;
|
||||
private permissionManager: PermissionManager | null = null;
|
||||
|
|
@ -2467,6 +2469,10 @@ export class Config {
|
|||
return this.backgroundTaskRegistry;
|
||||
}
|
||||
|
||||
getBackgroundShellRegistry(): BackgroundShellRegistry {
|
||||
return this.backgroundShellRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether interactive permission prompts should be auto-denied.
|
||||
* True for background agents that have no UI to show prompts.
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export * from './services/sessionService.js';
|
|||
export * from './services/sessionTitle.js';
|
||||
export { stripTerminalControlSequences } from './utils/terminalSafe.js';
|
||||
export * from './services/shellExecutionService.js';
|
||||
export * from './services/backgroundShellRegistry.js';
|
||||
export * from './utils/bareMode.js';
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
132
packages/core/src/services/backgroundShellRegistry.test.ts
Normal file
132
packages/core/src/services/backgroundShellRegistry.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BackgroundShellRegistry,
|
||||
type BackgroundShellEntry,
|
||||
} from './backgroundShellRegistry.js';
|
||||
|
||||
function makeEntry(
|
||||
overrides: Partial<BackgroundShellEntry> = {},
|
||||
): BackgroundShellEntry {
|
||||
return {
|
||||
shellId: 's1',
|
||||
command: 'sleep 60',
|
||||
cwd: '/tmp',
|
||||
status: 'running',
|
||||
startTime: 1000,
|
||||
outputPath: '/tmp/s1.output',
|
||||
abortController: new AbortController(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BackgroundShellRegistry', () => {
|
||||
describe('register / get / getAll', () => {
|
||||
it('round-trips a registered entry by id', () => {
|
||||
const reg = new BackgroundShellRegistry();
|
||||
const e = makeEntry({ shellId: 'a' });
|
||||
reg.register(e);
|
||||
expect(reg.get('a')).toBe(e);
|
||||
});
|
||||
|
||||
it('returns undefined for unknown id', () => {
|
||||
const reg = new BackgroundShellRegistry();
|
||||
expect(reg.get('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists all entries via getAll', () => {
|
||||
const reg = new BackgroundShellRegistry();
|
||||
const a = makeEntry({ shellId: 'a' });
|
||||
const b = makeEntry({ shellId: 'b' });
|
||||
reg.register(a);
|
||||
reg.register(b);
|
||||
const all = reg.getAll();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all).toContain(a);
|
||||
expect(all).toContain(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete', () => {
|
||||
it('transitions running → completed with exitCode and endTime', () => {
|
||||
const reg = new BackgroundShellRegistry();
|
||||
reg.register(makeEntry({ shellId: 'a' }));
|
||||
reg.complete('a', 0, 2000);
|
||||
const e = reg.get('a')!;
|
||||
expect(e.status).toBe('completed');
|
||||
expect(e.exitCode).toBe(0);
|
||||
expect(e.endTime).toBe(2000);
|
||||
});
|
||||
|
||||
it('is a no-op when entry is not running', () => {
|
||||
const reg = new BackgroundShellRegistry();
|
||||
reg.register(makeEntry({ shellId: 'a' }));
|
||||
reg.cancel('a', 1500);
|
||||
reg.complete('a', 0, 2000);
|
||||
const e = reg.get('a')!;
|
||||
expect(e.status).toBe('cancelled');
|
||||
expect(e.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is a no-op for unknown id', () => {
|
||||
const reg = new BackgroundShellRegistry();
|
||||
expect(() => reg.complete('missing', 0, 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail', () => {
|
||||
it('transitions running → failed with error and endTime', () => {
|
||||
const reg = new BackgroundShellRegistry();
|
||||
reg.register(makeEntry({ shellId: 'a' }));
|
||||
reg.fail('a', 'spawn error', 2000);
|
||||
const e = reg.get('a')!;
|
||||
expect(e.status).toBe('failed');
|
||||
expect(e.error).toBe('spawn error');
|
||||
expect(e.endTime).toBe(2000);
|
||||
});
|
||||
|
||||
it('is a no-op when entry is not running', () => {
|
||||
const reg = new BackgroundShellRegistry();
|
||||
reg.register(makeEntry({ shellId: 'a' }));
|
||||
reg.complete('a', 0, 1500);
|
||||
reg.fail('a', 'late error', 2000);
|
||||
const e = reg.get('a')!;
|
||||
expect(e.status).toBe('completed');
|
||||
expect(e.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('transitions running → cancelled and aborts the signal', () => {
|
||||
const reg = new BackgroundShellRegistry();
|
||||
const ac = new AbortController();
|
||||
reg.register(makeEntry({ shellId: 'a', abortController: ac }));
|
||||
reg.cancel('a', 2000);
|
||||
const e = reg.get('a')!;
|
||||
expect(e.status).toBe('cancelled');
|
||||
expect(e.endTime).toBe(2000);
|
||||
expect(ac.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op when entry is already terminal', () => {
|
||||
const reg = new BackgroundShellRegistry();
|
||||
const ac = new AbortController();
|
||||
reg.register(makeEntry({ shellId: 'a', abortController: ac }));
|
||||
reg.complete('a', 0, 1500);
|
||||
reg.cancel('a', 2000);
|
||||
const e = reg.get('a')!;
|
||||
expect(e.status).toBe('completed');
|
||||
expect(ac.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no-op for unknown id', () => {
|
||||
const reg = new BackgroundShellRegistry();
|
||||
expect(() => reg.cancel('missing', 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
88
packages/core/src/services/backgroundShellRegistry.ts
Normal file
88
packages/core/src/services/backgroundShellRegistry.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tracks background shell processes spawned via the `shell` tool with
|
||||
* `is_background: true`. Each entry holds the metadata the agent and the
|
||||
* `/bashes` slash command need to query, observe, or terminate a running
|
||||
* background shell.
|
||||
*
|
||||
* State machine: register → running → { completed | failed | cancelled }.
|
||||
* Transitions out of running are one-shot: complete/fail/cancel become
|
||||
* no-ops once the entry has settled. This prevents late callbacks (e.g. a
|
||||
* process that exits during cancellation) from clobbering the terminal
|
||||
* status.
|
||||
*/
|
||||
|
||||
export type BackgroundShellStatus =
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export interface BackgroundShellEntry {
|
||||
/** Stable id used by the model and the `/bashes` UI. */
|
||||
shellId: string;
|
||||
/** The user-supplied command, after any pre-processing the tool applies. */
|
||||
command: string;
|
||||
/** Working directory the process was spawned in. */
|
||||
cwd: string;
|
||||
/** OS pid once spawned; absent if registration happens before spawn. */
|
||||
pid?: number;
|
||||
status: BackgroundShellStatus;
|
||||
/** Exit code on `completed`. */
|
||||
exitCode?: number;
|
||||
/** Error message on `failed`. */
|
||||
error?: string;
|
||||
/** ms epoch when the entry was registered. */
|
||||
startTime: number;
|
||||
/** ms epoch when the entry transitioned out of running. */
|
||||
endTime?: number;
|
||||
/** Absolute path of the captured stdout/stderr file. */
|
||||
outputPath: string;
|
||||
/** Aborted by `cancel()`; callers should wire it into the spawn. */
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
export class BackgroundShellRegistry {
|
||||
private readonly entries = new Map<string, BackgroundShellEntry>();
|
||||
|
||||
register(entry: BackgroundShellEntry): void {
|
||||
this.entries.set(entry.shellId, entry);
|
||||
}
|
||||
|
||||
get(shellId: string): BackgroundShellEntry | undefined {
|
||||
return this.entries.get(shellId);
|
||||
}
|
||||
|
||||
getAll(): readonly BackgroundShellEntry[] {
|
||||
return [...this.entries.values()];
|
||||
}
|
||||
|
||||
complete(shellId: string, exitCode: number, endTime: number): void {
|
||||
const entry = this.entries.get(shellId);
|
||||
if (!entry || entry.status !== 'running') return;
|
||||
entry.status = 'completed';
|
||||
entry.exitCode = exitCode;
|
||||
entry.endTime = endTime;
|
||||
}
|
||||
|
||||
fail(shellId: string, error: string, endTime: number): void {
|
||||
const entry = this.entries.get(shellId);
|
||||
if (!entry || entry.status !== 'running') return;
|
||||
entry.status = 'failed';
|
||||
entry.error = error;
|
||||
entry.endTime = endTime;
|
||||
}
|
||||
|
||||
cancel(shellId: string, endTime: number): void {
|
||||
const entry = this.entries.get(shellId);
|
||||
if (!entry || entry.status !== 'running') return;
|
||||
entry.status = 'cancelled';
|
||||
entry.endTime = endTime;
|
||||
entry.abortController.abort();
|
||||
}
|
||||
}
|
||||
|
|
@ -31,8 +31,6 @@ import {
|
|||
} from '../services/shellExecutionService.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { EOL } from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js';
|
||||
|
|
@ -55,12 +53,14 @@ describe('ShellTool', () => {
|
|||
getPermissionsDeny: vi.fn().mockReturnValue([]),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session'),
|
||||
getWorkspaceContext: vi
|
||||
.fn()
|
||||
.mockReturnValue(createMockWorkspaceContext('/test/dir')),
|
||||
storage: {
|
||||
getUserSkillsDirs: vi.fn().mockReturnValue(['/test/dir/.qwen/skills']),
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/qwen-temp'),
|
||||
getProjectDir: vi.fn().mockReturnValue('/test/proj'),
|
||||
},
|
||||
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(0),
|
||||
getTruncateToolOutputLines: vi.fn().mockReturnValue(0),
|
||||
|
|
@ -72,8 +72,23 @@ describe('ShellTool', () => {
|
|||
email: 'qwen-coder@alibabacloud.com',
|
||||
}),
|
||||
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
|
||||
getBackgroundShellRegistry: vi.fn().mockReturnValue({
|
||||
register: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getAll: vi.fn().mockReturnValue([]),
|
||||
cancel: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
// executeBackground writes to disk; stub mkdirSync + createWriteStream.
|
||||
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
|
||||
vi.mocked(fs.createWriteStream).mockReturnValue({
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
} as unknown as fs.WriteStream);
|
||||
|
||||
shellTool = new ShellTool(mockConfig);
|
||||
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
|
|
@ -271,81 +286,129 @@ describe('ShellTool', () => {
|
|||
resolveExecutionPromise(fullResult);
|
||||
};
|
||||
|
||||
it('should wrap background command on linux and parse pgrep output', async () => {
|
||||
it('runs background commands as managed pool entries (no & / pgrep wrap)', async () => {
|
||||
const registry = mockConfig.getBackgroundShellRegistry();
|
||||
const invocation = shellTool.build({
|
||||
command: 'my-command',
|
||||
command: 'npm start',
|
||||
is_background: true,
|
||||
});
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
resolveShellExecution({ pid: 54321 });
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(`54321${EOL}54322${EOL}`); // Service PID and background PID
|
||||
const result = await invocation.execute(mockAbortSignal);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||
const wrappedCommand = `{ my-command & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
||||
// Spawn happens with the unwrapped command — no '&', no pgrep envelope.
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
wrappedCommand,
|
||||
'npm start',
|
||||
'/test/dir',
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
expect(result.llmContent).toContain('PIDs: 54322');
|
||||
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
||||
// Entry registered with the spawn pid.
|
||||
expect(registry.register).toHaveBeenCalledTimes(1);
|
||||
const entry = (registry.register as Mock).mock.calls[0][0];
|
||||
expect(entry.command).toBe('npm start');
|
||||
expect(entry.cwd).toBe('/test/dir');
|
||||
expect(entry.status).toBe('running');
|
||||
expect(entry.pid).toBe(12345);
|
||||
expect(typeof entry.shellId).toBe('string');
|
||||
expect(entry.outputPath).toContain('shell-');
|
||||
// Returns immediately with id + output path; agent's turn isn't blocked.
|
||||
expect(result.llmContent).toContain(entry.shellId);
|
||||
expect(result.llmContent).toContain(entry.outputPath);
|
||||
});
|
||||
|
||||
it('should add ampersand to command when is_background is true and command does not end with &', async () => {
|
||||
it('settles a background entry as completed when the process exits cleanly', async () => {
|
||||
const registry = mockConfig.getBackgroundShellRegistry();
|
||||
const invocation = shellTool.build({
|
||||
command: 'npm start',
|
||||
command: 'true',
|
||||
is_background: true,
|
||||
});
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
resolveShellExecution({ pid: 54321 });
|
||||
await invocation.execute(mockAbortSignal);
|
||||
const entry = (registry.register as Mock).mock.calls[0][0];
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n');
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
// Flush the .then() microtask attached to resultPromise.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
await promise;
|
||||
|
||||
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||
const wrappedCommand = `{ npm start & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
wrappedCommand,
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
expect(registry.complete).toHaveBeenCalledWith(
|
||||
entry.shellId,
|
||||
0,
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(registry.fail).not.toHaveBeenCalled();
|
||||
expect(registry.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add extra ampersand when is_background is true and command already ends with &', async () => {
|
||||
it('settles a background entry as failed when ShellExecutionService reports error', async () => {
|
||||
const registry = mockConfig.getBackgroundShellRegistry();
|
||||
const invocation = shellTool.build({
|
||||
command: 'npm start &',
|
||||
command: 'no-such-command',
|
||||
is_background: true,
|
||||
});
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
resolveShellExecution({ pid: 54321 });
|
||||
await invocation.execute(mockAbortSignal);
|
||||
const entry = (registry.register as Mock).mock.calls[0][0];
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n');
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: new Error('spawn ENOENT'),
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
await promise;
|
||||
|
||||
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||
const wrappedCommand = `{ npm start & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
wrappedCommand,
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
expect(registry.fail).toHaveBeenCalledWith(
|
||||
entry.shellId,
|
||||
'spawn ENOENT',
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(registry.complete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('settles a background entry as cancelled when the abort signal fires', async () => {
|
||||
const registry = mockConfig.getBackgroundShellRegistry();
|
||||
const ac = new AbortController();
|
||||
const invocation = shellTool.build({
|
||||
command: 'sleep 99',
|
||||
is_background: true,
|
||||
});
|
||||
await invocation.execute(ac.signal);
|
||||
const entry = (registry.register as Mock).mock.calls[0][0];
|
||||
// Simulate registry already setting status to 'running' before cancel hits.
|
||||
(registry.get as Mock).mockReturnValue({ status: 'running' });
|
||||
|
||||
ac.abort();
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: null,
|
||||
signal: 'SIGTERM',
|
||||
error: null,
|
||||
aborted: true,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(registry.cancel).toHaveBeenCalledWith(
|
||||
entry.shellId,
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(registry.complete).not.toHaveBeenCalled();
|
||||
expect(registry.fail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add ampersand when is_background is false', async () => {
|
||||
|
|
@ -479,23 +542,6 @@ describe('ShellTool', () => {
|
|||
).toThrow('Directory must be an absolute path.');
|
||||
});
|
||||
|
||||
it('should clean up the temp file on synchronous execution error', async () => {
|
||||
const error = new Error('sync spawn error');
|
||||
mockShellExecutionService.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true); // Pretend the file exists
|
||||
|
||||
const invocation = shellTool.build({
|
||||
command: 'a-command',
|
||||
is_background: false,
|
||||
});
|
||||
await expect(invocation.execute(mockAbortSignal)).rejects.toThrow(error);
|
||||
|
||||
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
||||
});
|
||||
|
||||
describe('Streaming to `updateOutput`', () => {
|
||||
let updateOutputMock: Mock;
|
||||
beforeEach(() => {
|
||||
|
|
@ -1016,43 +1062,6 @@ describe('ShellTool', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Windows background execution', () => {
|
||||
it('should clean up trailing ampersand on Windows for background tasks', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
const mockAbortSignal = new AbortController().signal;
|
||||
|
||||
const invocation = shellTool.build({
|
||||
command: 'npm start &',
|
||||
is_background: true,
|
||||
});
|
||||
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
// Simulate immediate success (process started)
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
'npm start',
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeout parameter', () => {
|
||||
it('should validate timeout parameter correctly', async () => {
|
||||
// Valid timeout
|
||||
|
|
@ -1177,40 +1186,6 @@ describe('ShellTool', () => {
|
|||
expect(calledSignal).not.toBe(mockAbortSignal);
|
||||
});
|
||||
|
||||
it('should not create timeout signal for background execution', async () => {
|
||||
const mockAbortSignal = new AbortController().signal;
|
||||
const invocation = shellTool.build({
|
||||
command: 'npm start',
|
||||
is_background: true,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: 'Background command started. PID: 12345',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
// For background execution, the original signal should be used
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle timeout vs user cancellation correctly', async () => {
|
||||
const userAbortController = new AbortController();
|
||||
const invocation = shellTool.build({
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os, { EOL } from 'node:os';
|
||||
import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { ToolNames, ToolDisplayNames } from './tool-names.js';
|
||||
|
|
@ -29,6 +29,7 @@ import type {
|
|||
ShellOutputEvent,
|
||||
} from '../services/shellExecutionService.js';
|
||||
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
||||
import type { BackgroundShellEntry } from '../services/backgroundShellRegistry.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import { isSubpaths } from '../utils/paths.js';
|
||||
|
|
@ -208,9 +209,12 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
|||
};
|
||||
}
|
||||
|
||||
const effectiveTimeout = this.params.is_background
|
||||
? undefined
|
||||
: (this.params.timeout ?? DEFAULT_FOREGROUND_TIMEOUT_MS);
|
||||
if (this.params.is_background) {
|
||||
return this.executeBackground(signal, shellExecutionConfig);
|
||||
}
|
||||
|
||||
const effectiveTimeout =
|
||||
this.params.timeout ?? DEFAULT_FOREGROUND_TIMEOUT_MS;
|
||||
|
||||
// Create combined signal with timeout for foreground execution
|
||||
let combinedSignal = signal;
|
||||
|
|
@ -219,294 +223,292 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
|||
combinedSignal = AbortSignal.any([signal, timeoutSignal]);
|
||||
}
|
||||
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const tempFileName = `shell_pgrep_${crypto
|
||||
.randomBytes(6)
|
||||
.toString('hex')}.tmp`;
|
||||
const tempFilePath = path.join(os.tmpdir(), tempFileName);
|
||||
// Add co-author to git commit commands
|
||||
const processedCommand = this.addCoAuthorToGitCommit(strippedCommand);
|
||||
const commandToExecute = processedCommand;
|
||||
const cwd = this.params.directory || this.config.getTargetDir();
|
||||
|
||||
try {
|
||||
// Add co-author to git commit commands
|
||||
const processedCommand = this.addCoAuthorToGitCommit(strippedCommand);
|
||||
let cumulativeOutput: string | AnsiOutput = '';
|
||||
let lastUpdateTime = Date.now();
|
||||
let isBinaryStream = false;
|
||||
let totalLines = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
const shouldRunInBackground = this.params.is_background;
|
||||
let finalCommand = processedCommand;
|
||||
const { result: resultPromise, pid } = await ShellExecutionService.execute(
|
||||
commandToExecute,
|
||||
cwd,
|
||||
(event: ShellOutputEvent) => {
|
||||
let shouldUpdate = false;
|
||||
|
||||
// On non-Windows, use & to run in background.
|
||||
// On Windows, we don't use start /B because it creates a detached process that
|
||||
// doesn't die when the parent dies. Instead, we rely on the race logic below
|
||||
// to return early while keeping the process attached (detached: false).
|
||||
if (
|
||||
!isWindows &&
|
||||
shouldRunInBackground &&
|
||||
!finalCommand.trim().endsWith('&')
|
||||
) {
|
||||
finalCommand = finalCommand.trim() + ' &';
|
||||
}
|
||||
|
||||
// On Windows, we rely on the race logic below to handle background tasks.
|
||||
// We just ensure the command string is clean.
|
||||
if (isWindows && shouldRunInBackground) {
|
||||
finalCommand = finalCommand.trim().replace(/&+$/, '').trim();
|
||||
}
|
||||
|
||||
// On non-Windows background commands, wrap with pgrep to capture
|
||||
// subprocess PIDs so we can report them to the user.
|
||||
const commandToExecute =
|
||||
!isWindows && shouldRunInBackground
|
||||
? (() => {
|
||||
let command = finalCommand.trim();
|
||||
if (!command.endsWith('&')) command += ';';
|
||||
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
|
||||
})()
|
||||
: finalCommand;
|
||||
|
||||
const cwd = this.params.directory || this.config.getTargetDir();
|
||||
|
||||
let cumulativeOutput: string | AnsiOutput = '';
|
||||
let lastUpdateTime = Date.now();
|
||||
let isBinaryStream = false;
|
||||
let totalLines = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
const { result: resultPromise, pid } =
|
||||
await ShellExecutionService.execute(
|
||||
commandToExecute,
|
||||
cwd,
|
||||
(event: ShellOutputEvent) => {
|
||||
let shouldUpdate = false;
|
||||
|
||||
switch (event.type) {
|
||||
case 'data':
|
||||
if (isBinaryStream) break;
|
||||
cumulativeOutput = event.chunk;
|
||||
// Stats are only consumed by the ANSI-output branch below,
|
||||
// so skip the per-chunk accounting for plain string chunks.
|
||||
if (Array.isArray(event.chunk)) {
|
||||
totalLines = event.chunk.length;
|
||||
totalBytes = event.chunk.reduce(
|
||||
(sum, line) =>
|
||||
sum +
|
||||
line.reduce(
|
||||
(ls, token) =>
|
||||
ls + Buffer.byteLength(token.text, 'utf-8'),
|
||||
0,
|
||||
),
|
||||
switch (event.type) {
|
||||
case 'data':
|
||||
if (isBinaryStream) break;
|
||||
cumulativeOutput = event.chunk;
|
||||
// Stats are only consumed by the ANSI-output branch below,
|
||||
// so skip the per-chunk accounting for plain string chunks.
|
||||
if (Array.isArray(event.chunk)) {
|
||||
totalLines = event.chunk.length;
|
||||
totalBytes = event.chunk.reduce(
|
||||
(sum, line) =>
|
||||
sum +
|
||||
line.reduce(
|
||||
(ls, token) => ls + Buffer.byteLength(token.text, 'utf-8'),
|
||||
0,
|
||||
);
|
||||
}
|
||||
shouldUpdate = true;
|
||||
break;
|
||||
case 'binary_detected':
|
||||
isBinaryStream = true;
|
||||
cumulativeOutput =
|
||||
'[Binary output detected. Halting stream...]';
|
||||
shouldUpdate = true;
|
||||
break;
|
||||
case 'binary_progress':
|
||||
isBinaryStream = true;
|
||||
cumulativeOutput = `[Receiving binary output... ${formatMemoryUsage(
|
||||
event.bytesReceived,
|
||||
)} received]`;
|
||||
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
|
||||
shouldUpdate = true;
|
||||
}
|
||||
break;
|
||||
default: {
|
||||
throw new Error('An unhandled ShellOutputEvent was found.');
|
||||
}
|
||||
),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldUpdate && updateOutput) {
|
||||
if (typeof cumulativeOutput === 'string') {
|
||||
updateOutput(cumulativeOutput);
|
||||
} else {
|
||||
updateOutput({
|
||||
ansiOutput: cumulativeOutput,
|
||||
totalLines,
|
||||
totalBytes,
|
||||
// Only include timeout when user explicitly set it
|
||||
...(this.params.timeout != null && {
|
||||
timeoutMs: this.params.timeout,
|
||||
}),
|
||||
});
|
||||
}
|
||||
lastUpdateTime = Date.now();
|
||||
shouldUpdate = true;
|
||||
break;
|
||||
case 'binary_detected':
|
||||
isBinaryStream = true;
|
||||
cumulativeOutput = '[Binary output detected. Halting stream...]';
|
||||
shouldUpdate = true;
|
||||
break;
|
||||
case 'binary_progress':
|
||||
isBinaryStream = true;
|
||||
cumulativeOutput = `[Receiving binary output... ${formatMemoryUsage(
|
||||
event.bytesReceived,
|
||||
)} received]`;
|
||||
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
|
||||
shouldUpdate = true;
|
||||
}
|
||||
},
|
||||
combinedSignal,
|
||||
shouldRunInBackground
|
||||
? false
|
||||
: this.config.getShouldUseNodePtyShell(),
|
||||
shellExecutionConfig ?? {},
|
||||
);
|
||||
|
||||
if (pid && setPidCallback) {
|
||||
setPidCallback(pid);
|
||||
}
|
||||
|
||||
// On Windows, background commands rely on early return since there's
|
||||
// no & backgrounding or pgrep. Awaiting would block until completion.
|
||||
if (shouldRunInBackground && isWindows) {
|
||||
const pidMsg = pid ? ` PID: ${pid}` : '';
|
||||
const killHint = ' (Use taskkill /F /T /PID <pid> to stop)';
|
||||
|
||||
return {
|
||||
llmContent: `Background command started.${pidMsg}${killHint}`,
|
||||
returnDisplay: `Background command started.${pidMsg}${killHint}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
if (shouldRunInBackground) {
|
||||
// Read subprocess PIDs captured by the pgrep wrapper (non-Windows only)
|
||||
const backgroundPIDs: number[] = [];
|
||||
if (!isWindows) {
|
||||
if (fs.existsSync(tempFilePath)) {
|
||||
const pgrepLines = fs
|
||||
.readFileSync(tempFilePath, 'utf8')
|
||||
.split(EOL)
|
||||
.filter(Boolean);
|
||||
for (const line of pgrepLines) {
|
||||
if (!/^\d+$/.test(line)) {
|
||||
debugLogger.warn(`pgrep: ${line}`);
|
||||
continue;
|
||||
}
|
||||
const bgPid = Number(line);
|
||||
if (bgPid !== result.pid) {
|
||||
backgroundPIDs.push(bgPid);
|
||||
}
|
||||
}
|
||||
} else if (!signal.aborted) {
|
||||
debugLogger.warn('missing pgrep output');
|
||||
break;
|
||||
default: {
|
||||
throw new Error('An unhandled ShellOutputEvent was found.');
|
||||
}
|
||||
}
|
||||
|
||||
const bgPidMsg =
|
||||
backgroundPIDs.length > 0
|
||||
? ` PIDs: ${backgroundPIDs.join(', ')}`
|
||||
: pid
|
||||
? ` PID: ${pid}`
|
||||
: '';
|
||||
const killHint = ' (Use kill <pid> to stop)';
|
||||
|
||||
return {
|
||||
llmContent: `Background command started.${bgPidMsg}${killHint}`,
|
||||
returnDisplay: `Background command started.${bgPidMsg}${killHint}`,
|
||||
};
|
||||
}
|
||||
|
||||
let llmContent = '';
|
||||
if (result.aborted) {
|
||||
// Check if it was a timeout or user cancellation
|
||||
const wasTimeout =
|
||||
!this.params.is_background &&
|
||||
effectiveTimeout &&
|
||||
combinedSignal.aborted &&
|
||||
!signal.aborted;
|
||||
|
||||
if (wasTimeout) {
|
||||
llmContent = `Command timed out after ${effectiveTimeout}ms before it could complete.`;
|
||||
if (result.output.trim()) {
|
||||
llmContent += ` Below is the output before it timed out:\n${result.output}`;
|
||||
if (shouldUpdate && updateOutput) {
|
||||
if (typeof cumulativeOutput === 'string') {
|
||||
updateOutput(cumulativeOutput);
|
||||
} else {
|
||||
llmContent += ' There was no output before it timed out.';
|
||||
}
|
||||
} else {
|
||||
llmContent =
|
||||
'Command was cancelled by user before it could complete.';
|
||||
if (result.output.trim()) {
|
||||
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
|
||||
} else {
|
||||
llmContent += ' There was no output before it was cancelled.';
|
||||
updateOutput({
|
||||
ansiOutput: cumulativeOutput,
|
||||
totalLines,
|
||||
totalBytes,
|
||||
// Only include timeout when user explicitly set it
|
||||
...(this.params.timeout != null && {
|
||||
timeoutMs: this.params.timeout,
|
||||
}),
|
||||
});
|
||||
}
|
||||
lastUpdateTime = Date.now();
|
||||
}
|
||||
} else {
|
||||
// Create a formatted error string for display, replacing the wrapper command
|
||||
// with the user-facing command.
|
||||
const finalError = result.error
|
||||
? result.error.message.replace(commandToExecute, this.params.command)
|
||||
: '(none)';
|
||||
},
|
||||
combinedSignal,
|
||||
this.config.getShouldUseNodePtyShell(),
|
||||
shellExecutionConfig ?? {},
|
||||
);
|
||||
|
||||
llmContent = [
|
||||
`Command: ${this.params.command}`,
|
||||
`Directory: ${this.params.directory || '(root)'}`,
|
||||
`Output: ${result.output || '(empty)'}`,
|
||||
`Error: ${finalError}`, // Use the cleaned error string.
|
||||
`Exit Code: ${result.exitCode ?? '(none)'}`,
|
||||
`Signal: ${result.signal ?? '(none)'}`,
|
||||
`Process Group PGID: ${result.pid ?? '(none)'}`,
|
||||
].join('\n');
|
||||
}
|
||||
if (pid && setPidCallback) {
|
||||
setPidCallback(pid);
|
||||
}
|
||||
|
||||
let returnDisplayMessage = '';
|
||||
if (this.config.getDebugMode()) {
|
||||
returnDisplayMessage = llmContent;
|
||||
} else {
|
||||
const result = await resultPromise;
|
||||
|
||||
let llmContent = '';
|
||||
if (result.aborted) {
|
||||
// Check if it was a timeout or user cancellation
|
||||
const wasTimeout =
|
||||
effectiveTimeout && combinedSignal.aborted && !signal.aborted;
|
||||
|
||||
if (wasTimeout) {
|
||||
llmContent = `Command timed out after ${effectiveTimeout}ms before it could complete.`;
|
||||
if (result.output.trim()) {
|
||||
returnDisplayMessage = result.output;
|
||||
llmContent += ` Below is the output before it timed out:\n${result.output}`;
|
||||
} else {
|
||||
if (result.aborted) {
|
||||
// Check if it was a timeout or user cancellation
|
||||
const wasTimeout =
|
||||
!this.params.is_background &&
|
||||
effectiveTimeout &&
|
||||
combinedSignal.aborted &&
|
||||
!signal.aborted;
|
||||
|
||||
returnDisplayMessage = wasTimeout
|
||||
? `Command timed out after ${effectiveTimeout}ms.`
|
||||
: 'Command cancelled by user.';
|
||||
} else if (result.signal) {
|
||||
returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
|
||||
} else if (result.error) {
|
||||
returnDisplayMessage = `Command failed: ${getErrorMessage(
|
||||
result.error,
|
||||
)}`;
|
||||
} else if (result.exitCode !== null && result.exitCode !== 0) {
|
||||
returnDisplayMessage = `Command exited with code: ${result.exitCode}`;
|
||||
}
|
||||
// If output is empty and command succeeded (code 0, no error/signal/abort),
|
||||
// returnDisplayMessage will remain empty, which is fine.
|
||||
llmContent += ' There was no output before it timed out.';
|
||||
}
|
||||
} else {
|
||||
llmContent = 'Command was cancelled by user before it could complete.';
|
||||
if (result.output.trim()) {
|
||||
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
|
||||
} else {
|
||||
llmContent += ' There was no output before it was cancelled.';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create a formatted error string for display, replacing the wrapper command
|
||||
// with the user-facing command.
|
||||
const finalError = result.error
|
||||
? result.error.message.replace(commandToExecute, this.params.command)
|
||||
: '(none)';
|
||||
|
||||
// Truncate large output and save full content to a temp file.
|
||||
if (typeof llmContent === 'string') {
|
||||
const truncatedResult = await truncateToolOutput(
|
||||
this.config,
|
||||
ShellTool.Name,
|
||||
llmContent,
|
||||
);
|
||||
llmContent = [
|
||||
`Command: ${this.params.command}`,
|
||||
`Directory: ${this.params.directory || '(root)'}`,
|
||||
`Output: ${result.output || '(empty)'}`,
|
||||
`Error: ${finalError}`, // Use the cleaned error string.
|
||||
`Exit Code: ${result.exitCode ?? '(none)'}`,
|
||||
`Signal: ${result.signal ?? '(none)'}`,
|
||||
`Process Group PGID: ${result.pid ?? '(none)'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (truncatedResult.outputFile) {
|
||||
llmContent = truncatedResult.content;
|
||||
returnDisplayMessage +=
|
||||
(returnDisplayMessage ? '\n' : '') +
|
||||
`Output too long and was saved to: ${truncatedResult.outputFile}`;
|
||||
let returnDisplayMessage = '';
|
||||
if (this.config.getDebugMode()) {
|
||||
returnDisplayMessage = llmContent;
|
||||
} else {
|
||||
if (result.output.trim()) {
|
||||
returnDisplayMessage = result.output;
|
||||
} else {
|
||||
if (result.aborted) {
|
||||
// Check if it was a timeout or user cancellation
|
||||
const wasTimeout =
|
||||
effectiveTimeout && combinedSignal.aborted && !signal.aborted;
|
||||
|
||||
returnDisplayMessage = wasTimeout
|
||||
? `Command timed out after ${effectiveTimeout}ms.`
|
||||
: 'Command cancelled by user.';
|
||||
} else if (result.signal) {
|
||||
returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
|
||||
} else if (result.error) {
|
||||
returnDisplayMessage = `Command failed: ${getErrorMessage(
|
||||
result.error,
|
||||
)}`;
|
||||
} else if (result.exitCode !== null && result.exitCode !== 0) {
|
||||
returnDisplayMessage = `Command exited with code: ${result.exitCode}`;
|
||||
}
|
||||
}
|
||||
|
||||
const executionError = result.error
|
||||
? {
|
||||
error: {
|
||||
message: result.error.message,
|
||||
type: ToolErrorType.SHELL_EXECUTE_ERROR,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return {
|
||||
llmContent,
|
||||
returnDisplay: returnDisplayMessage,
|
||||
...executionError,
|
||||
};
|
||||
} finally {
|
||||
if (fs.existsSync(tempFilePath)) {
|
||||
fs.unlinkSync(tempFilePath);
|
||||
// If output is empty and command succeeded (code 0, no error/signal/abort),
|
||||
// returnDisplayMessage will remain empty, which is fine.
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate large output and save full content to a temp file.
|
||||
if (typeof llmContent === 'string') {
|
||||
const truncatedResult = await truncateToolOutput(
|
||||
this.config,
|
||||
ShellTool.Name,
|
||||
llmContent,
|
||||
);
|
||||
|
||||
if (truncatedResult.outputFile) {
|
||||
llmContent = truncatedResult.content;
|
||||
returnDisplayMessage +=
|
||||
(returnDisplayMessage ? '\n' : '') +
|
||||
`Output too long and was saved to: ${truncatedResult.outputFile}`;
|
||||
}
|
||||
}
|
||||
|
||||
const executionError = result.error
|
||||
? {
|
||||
error: {
|
||||
message: result.error.message,
|
||||
type: ToolErrorType.SHELL_EXECUTE_ERROR,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return {
|
||||
llmContent,
|
||||
returnDisplay: returnDisplayMessage,
|
||||
...executionError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Background-execution path: spawn the command into a managed registry
|
||||
* entry instead of detaching with `&`. Output streams to a per-shell file
|
||||
* the agent can `Read`; cancellation flows through the entry's
|
||||
* AbortController; the registry's terminal status is set when the process
|
||||
* exits. Returns immediately so the agent's turn isn't blocked.
|
||||
*/
|
||||
private async executeBackground(
|
||||
signal: AbortSignal,
|
||||
shellExecutionConfig?: ShellExecutionConfig,
|
||||
): Promise<ToolResult> {
|
||||
const strippedCommand = stripShellWrapper(this.params.command);
|
||||
const processedCommand = this.addCoAuthorToGitCommit(strippedCommand);
|
||||
const cwd = this.params.directory || this.config.getTargetDir();
|
||||
|
||||
// Per-session output dir under the project. Path layout aligns with the
|
||||
// direction sketched in #3471 review (`<projectDir>/tasks/<sessionId>/`)
|
||||
// so future task kinds (subagent transcripts, monitor logs) sit alongside.
|
||||
const outputDir = path.join(
|
||||
this.config.storage.getProjectDir(),
|
||||
'tasks',
|
||||
this.config.getSessionId(),
|
||||
);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const shellId = `bg_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const outputPath = path.join(outputDir, `shell-${shellId}.output`);
|
||||
|
||||
// Bridge external signal (tool-framework abort, e.g. Ctrl+C) into the
|
||||
// entry's AC so a single source of truth governs cancellation.
|
||||
const entryAc = new AbortController();
|
||||
if (signal.aborted) {
|
||||
entryAc.abort();
|
||||
} else {
|
||||
signal.addEventListener('abort', () => entryAc.abort(), { once: true });
|
||||
}
|
||||
|
||||
const outputStream = fs.createWriteStream(outputPath, { flags: 'w' });
|
||||
|
||||
const startTime = Date.now();
|
||||
const entry: BackgroundShellEntry = {
|
||||
shellId,
|
||||
command: processedCommand,
|
||||
cwd,
|
||||
status: 'running',
|
||||
startTime,
|
||||
outputPath,
|
||||
abortController: entryAc,
|
||||
};
|
||||
|
||||
const { result: resultPromise, pid } = await ShellExecutionService.execute(
|
||||
processedCommand,
|
||||
cwd,
|
||||
(event: ShellOutputEvent) => {
|
||||
if (event.type === 'data' && typeof event.chunk === 'string') {
|
||||
outputStream.write(event.chunk);
|
||||
}
|
||||
// ANSI array chunks and binary streams are not written to the output
|
||||
// file: agents read the file as plain text and binary spam would be
|
||||
// unhelpful.
|
||||
},
|
||||
entryAc.signal,
|
||||
false,
|
||||
shellExecutionConfig ?? {},
|
||||
);
|
||||
|
||||
if (pid !== undefined) entry.pid = pid;
|
||||
const registry = this.config.getBackgroundShellRegistry();
|
||||
registry.register(entry);
|
||||
|
||||
// Settle in the background — do NOT await here, the agent should be
|
||||
// unblocked immediately.
|
||||
void resultPromise.then(
|
||||
(result) => {
|
||||
outputStream.end();
|
||||
const endTime = Date.now();
|
||||
if (entryAc.signal.aborted) {
|
||||
if (registry.get(shellId)?.status === 'running') {
|
||||
registry.cancel(shellId, endTime);
|
||||
}
|
||||
} else if (result.error) {
|
||||
registry.fail(shellId, result.error.message, endTime);
|
||||
} else {
|
||||
registry.complete(shellId, result.exitCode ?? 0, endTime);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
outputStream.end();
|
||||
registry.fail(shellId, getErrorMessage(err), Date.now());
|
||||
},
|
||||
);
|
||||
|
||||
const pidLine = pid !== undefined ? `pid: ${pid}\n` : '';
|
||||
return {
|
||||
llmContent:
|
||||
`Background shell started.\n` +
|
||||
`id: ${shellId}\n` +
|
||||
pidLine +
|
||||
`output file: ${outputPath}\n` +
|
||||
`Use the /bashes command to list and inspect background shells, or Read the output file directly.`,
|
||||
returnDisplay: `Background shell ${shellId} started${pid !== undefined ? ` (pid ${pid})` : ''}.`,
|
||||
};
|
||||
}
|
||||
|
||||
private addCoAuthorToGitCommit(command: string): string {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue