Skip to content

Commit 2e9dfbc

Browse files
committed
Merge branch 'bash-tool-run-in-background'
2 parents 66e34d3 + 63c7884 commit 2e9dfbc

9 files changed

Lines changed: 544 additions & 19 deletions

File tree

src/prompt.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,11 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe
351351
},
352352
uniqueItems: true,
353353
},
354+
run_in_background: {
355+
type: "boolean",
356+
description:
357+
"Set to true to run the command in the background. Use this only when you need to perform a blocking task and do not need the result immediately.",
358+
},
354359
},
355360
required: ["command", "sideEffects"],
356361
additionalProperties: false,

src/session.ts

Lines changed: 120 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "open
88
import { launchNotifyScript } from "./common/notify";
99
import { buildThinkingRequestOptions } from "./common/openai-thinking";
1010
import { DEEPSEEK_V4_MODELS, supportsMultimodal } from "./common/model-capabilities";
11+
import { readTextFileWithMetadata } from "./common/file-utils";
1112
import {
1213
getCompactPrompt,
1314
getDefaultSkillPrompt,
@@ -60,6 +61,7 @@ export type {
6061
const MAX_SESSION_ENTRIES = 50;
6162
const MAX_PROJECT_CODE_LENGTH = 64;
6263
const PROJECT_CODE_HASH_LENGTH = 16;
64+
const BACKGROUND_FAILURE_LOG_TAIL_CHARS = 4000;
6365
const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024;
6466
const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024;
6567

@@ -330,6 +332,7 @@ export class SessionManager {
330332
private activePromptController: AbortController | null = null;
331333
private readonly sessionControllers = new Map<string, AbortController>();
332334
private readonly processTimeoutControls = new Map<string, ProcessTimeoutControl>();
335+
private readonly liveProcessKeys = new Set<string>();
333336
private readonly toolExecutor: ToolExecutor;
334337
private readonly mcpManager = new McpManager();
335338
private mcpToolDefinitions: ToolDefinition[] = [];
@@ -379,6 +382,7 @@ export class SessionManager {
379382
sessionController.abort();
380383
}
381384
}
385+
this.killLiveProcesses();
382386
this.sessionControllers.clear();
383387
this.processTimeoutControls.clear();
384388
this.mcpManager.disconnect();
@@ -1525,7 +1529,9 @@ ${skillMd}
15251529
const killedPids: number[] = [];
15261530
const failedPids: number[] = [];
15271531
for (const pid of processIds) {
1528-
this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, pid));
1532+
const processControlKey = this.getProcessControlKey(sessionId, pid);
1533+
this.processTimeoutControls.delete(processControlKey);
1534+
this.liveProcessKeys.delete(processControlKey);
15291535
if (killProcessTree(pid, "SIGKILL")) {
15301536
killedPids.push(pid);
15311537
continue;
@@ -1892,21 +1898,11 @@ ${skillMd}
18921898
const processIds = options.processIds ?? [];
18931899
for (const pid of processIds) {
18941900
const processControlKey = this.getProcessControlKey(sessionId, pid);
1895-
if (!this.processTimeoutControls.has(processControlKey)) {
1901+
if (!this.processTimeoutControls.has(processControlKey) && !this.liveProcessKeys.has(processControlKey)) {
18961902
continue;
18971903
}
18981904

1899-
const killedGroup = killProcessTree(pid, "SIGKILL");
1900-
if (killedGroup) {
1901-
this.processTimeoutControls.delete(processControlKey);
1902-
continue;
1903-
}
1904-
try {
1905-
process.kill(pid, "SIGKILL");
1906-
} catch {
1907-
// ignore process-kill failures during cleanup
1908-
}
1909-
this.processTimeoutControls.delete(processControlKey);
1905+
this.killTrackedProcess(processControlKey, pid);
19101906
}
19111907

19121908
clearSessionState(sessionId);
@@ -2172,6 +2168,7 @@ ${skillMd}
21722168
onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid),
21732169
onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk),
21742170
onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control),
2171+
onBackgroundProcessComplete: (completion) => this.addBackgroundProcessCompletionMessage(sessionId, completion),
21752172
onBeforeFileMutation: (filePath) => this.prepareFileMutationCheckpoint(sessionId, filePath),
21762173
onAfterFileMutation: (filePath) => this.recordFileMutationCheckpoint(sessionId, filePath),
21772174
shouldStop: () => this.isInterrupted(sessionId),
@@ -2666,6 +2663,7 @@ ${skillMd}
26662663

26672664
private addSessionProcess(sessionId: string, processId: string | number, command: string): void {
26682665
const now = new Date().toISOString();
2666+
this.liveProcessKeys.add(this.getProcessControlKey(sessionId, processId));
26692667
this.updateSessionEntry(sessionId, (entry) => {
26702668
const processes = new Map(entry.processes ?? []);
26712669
processes.set(String(processId), { startTime: now, command });
@@ -2677,9 +2675,86 @@ ${skillMd}
26772675
});
26782676
}
26792677

2678+
private addBackgroundProcessCompletionMessage(
2679+
sessionId: string,
2680+
completion: {
2681+
command: string;
2682+
outputPath: string;
2683+
ok: boolean;
2684+
exitCode: number | null;
2685+
signal: string | null;
2686+
error?: string;
2687+
completedAtMs: number;
2688+
startedAtMs: number;
2689+
}
2690+
): void {
2691+
const status = completion.ok ? "completed" : "failed";
2692+
const exitText =
2693+
completion.exitCode !== null
2694+
? `exit code ${completion.exitCode}`
2695+
: completion.signal
2696+
? `signal ${completion.signal}`
2697+
: completion.error || "unknown status";
2698+
const durationMs = Math.max(0, completion.completedAtMs - completion.startedAtMs);
2699+
const baseContent =
2700+
`Background command "${completion.command}" ${status} with ${exitText} ` +
2701+
`after ${this.formatBackgroundDuration(durationMs)}. Output: ${completion.outputPath}`;
2702+
const logTail = completion.ok ? null : this.buildBackgroundFailureLogTailSlice(completion.outputPath);
2703+
const content = logTail ? `${baseContent}\n${logTail}` : baseContent;
2704+
this.addSessionSystemMessage(sessionId, content, true);
2705+
}
2706+
2707+
private buildBackgroundFailureLogTailSlice(outputPath: string): string | null {
2708+
const tail = this.readTextFileTail(outputPath, BACKGROUND_FAILURE_LOG_TAIL_CHARS);
2709+
if (!tail || !tail.content) {
2710+
return null;
2711+
}
2712+
const prefix = tail.truncated ? `(${tail.totalBytes} bytes)...\n` : "";
2713+
return [
2714+
`<background_task_failure_log path="${outputPath}">`,
2715+
`${prefix}${tail.content}`,
2716+
"</background_task_failure_log>",
2717+
].join("\n");
2718+
}
2719+
2720+
private readTextFileTail(
2721+
filePath: string,
2722+
maxChars: number
2723+
): { content: string; totalBytes: number; truncated: boolean } | null {
2724+
try {
2725+
const stat = fs.statSync(filePath);
2726+
if (!stat.isFile() || stat.size <= 0) {
2727+
return null;
2728+
}
2729+
const content = readTextFileWithMetadata(filePath).content;
2730+
return {
2731+
content: content.slice(-maxChars).trimEnd(),
2732+
totalBytes: stat.size,
2733+
truncated: content.length > maxChars,
2734+
};
2735+
} catch {
2736+
return null;
2737+
}
2738+
}
2739+
2740+
private formatBackgroundDuration(durationMs: number): string {
2741+
if (durationMs < 1000) {
2742+
return `${durationMs}ms`;
2743+
}
2744+
const seconds = Math.round(durationMs / 1000);
2745+
if (seconds < 60) {
2746+
return `${seconds}s`;
2747+
}
2748+
const minutes = Math.floor(seconds / 60);
2749+
const remainingSeconds = seconds % 60;
2750+
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
2751+
}
2752+
26802753
private removeSessionProcess(sessionId: string, processId: string | number): void {
26812754
const now = new Date().toISOString();
2682-
this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, processId));
2755+
const processControlKey = this.getProcessControlKey(sessionId, processId);
2756+
this.processTimeoutControls.delete(processControlKey);
2757+
this.liveProcessKeys.delete(processControlKey);
26832758
this.updateSessionEntry(sessionId, (entry) => {
26842759
const processes = new Map(entry.processes ?? []);
26852760
processes.delete(String(processId));
@@ -2742,6 +2817,37 @@ ${skillMd}
27422817
return `${sessionId}:${String(processId)}`;
27432818
}
27442819

2820+
private killLiveProcesses(): void {
2821+
for (const processControlKey of Array.from(this.liveProcessKeys)) {
2822+
const processId = this.getProcessIdFromControlKey(processControlKey);
2823+
if (processId === null) {
2824+
this.liveProcessKeys.delete(processControlKey);
2825+
continue;
2826+
}
2827+
this.killTrackedProcess(processControlKey, processId);
2828+
}
2829+
}
2830+
2831+
private killTrackedProcess(processControlKey: string, processId: number): void {
2832+
const killedGroup = killProcessTree(processId, "SIGKILL");
2833+
if (!killedGroup) {
2834+
try {
2835+
process.kill(processId, "SIGKILL");
2836+
} catch {
2837+
// Ignore process-kill failures during cleanup.
2838+
}
2839+
}
2840+
this.processTimeoutControls.delete(processControlKey);
2841+
this.liveProcessKeys.delete(processControlKey);
2842+
}
2843+
2844+
private getProcessIdFromControlKey(processControlKey: string): number | null {
2845+
const separatorIndex = processControlKey.lastIndexOf(":");
2846+
const rawProcessId = separatorIndex >= 0 ? processControlKey.slice(separatorIndex + 1) : processControlKey;
2847+
const processId = Number(rawProcessId);
2848+
return Number.isInteger(processId) && processId > 0 ? processId : null;
2849+
}
2850+
27452851
private getProcessIds(processes: Map<string, SessionProcessEntry> | null): number[] {
27462852
if (!processes) {
27472853
return [];

src/tests/prompt.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ test("getTools requires bash sideEffects permission scopes", () => {
3030
assert.equal(sideEffects.type, "array");
3131
assert.equal(sideEffects.items?.enum?.includes("write-out-cwd"), true);
3232
assert.equal(sideEffects.items?.enum?.includes("unknown"), true);
33+
const runInBackground = tool.function.parameters.properties.run_in_background as { type?: unknown };
34+
assert.equal(runInBackground.type, "boolean");
3335
});
3436

3537
test("getSystemPrompt always includes WebSearch docs", () => {
@@ -43,6 +45,14 @@ test("getSystemPrompt includes UpdatePlan docs", () => {
4345
assert.equal(prompt.includes("The `plan` argument is a markdown string, not an array of step objects."), true);
4446
});
4547

48+
test("getSystemPrompt includes Bash background guidance", () => {
49+
const prompt = getSystemPrompt("/tmp/project");
50+
assert.equal(prompt.includes("run_in_background: true"), true);
51+
assert.equal(prompt.includes("do NOT add `&`"), true);
52+
assert.equal(prompt.includes("use the `stopCommand` returned in the tool result metadata"), true);
53+
assert.equal(prompt.includes("stop background tasks that has not reported a completed state"), true);
54+
});
55+
4656
test("getSystemPrompt does not include runtime context", () => {
4757
const prompt = getSystemPrompt("/tmp/project");
4858
assert.equal(prompt.includes("# Local Workspace Environment"), false);

src/tests/session.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,49 @@ test("SessionManager preserves structured system content when building OpenAI me
108108
]);
109109
});
110110

111+
test("SessionManager appends failed background log tail as XML", () => {
112+
const workspace = createTempDir("deepcode-background-log-workspace-");
113+
const home = createTempDir("deepcode-background-log-home-");
114+
setHomeDir(home);
115+
const outputPath = path.join(workspace, "background.log");
116+
fs.writeFileSync(outputPath, ["before", "failure <line> & one", "failure line two"].join("\n"), "utf8");
117+
let systemMessage: SessionMessage | null = null;
118+
const manager = new SessionManager({
119+
projectRoot: workspace,
120+
createOpenAIClient: () => ({
121+
client: null,
122+
model: "test-model",
123+
thinkingEnabled: false,
124+
}),
125+
getResolvedSettings: () => ({ model: "test-model" }),
126+
renderMarkdown: (text) => text,
127+
onAssistantMessage: (message) => {
128+
systemMessage = message;
129+
},
130+
});
131+
132+
(manager as any).addBackgroundProcessCompletionMessage("session-background-fail", {
133+
command: "npm test",
134+
outputPath,
135+
ok: false,
136+
exitCode: 1,
137+
signal: null,
138+
startedAtMs: 0,
139+
completedAtMs: 1200,
140+
});
141+
142+
assert.ok(systemMessage);
143+
const message = systemMessage as SessionMessage;
144+
assert.equal(message.role, "system");
145+
const content = message.content ?? "";
146+
assert.match(content, /Background command "npm test" failed with exit code 1/);
147+
assert.match(content, new RegExp(`<background_task_failure_log path="${escapeRegExp(outputPath)}">`));
148+
assert.match(content, /failure <line> & one[\s\S]*failure line two/);
149+
assert.doesNotMatch(content, /failure &lt;line&gt; &amp; one/);
150+
assert.doesNotMatch(content, /<output_path>/);
151+
assert.doesNotMatch(content, /<tail>/);
152+
});
153+
111154
test("SessionManager filters image content for non-multimodal models", () => {
112155
const manager = new SessionManager({
113156
projectRoot: process.cwd(),
@@ -499,6 +542,67 @@ rl.on("line", (line) => {
499542
assert.deepEqual(manager.getMcpStatus(), []);
500543
});
501544

545+
test("SessionManager dispose kills live processes without timeout controls", (t) => {
546+
if (process.platform === "win32") {
547+
t.skip("process group kill assertion is non-Windows specific");
548+
return;
549+
}
550+
551+
const workspace = createTempDir("deepcode-dispose-process-workspace-");
552+
const home = createTempDir("deepcode-dispose-process-home-");
553+
setHomeDir(home);
554+
const manager = createSessionManager(workspace, "machine-id-dispose-process");
555+
const sessionId = createSessionAndMessages(manager, "session-dispose-process", "Dispose process session");
556+
const originalKill = process.kill;
557+
const killed: Array<{ pid: number; signal?: NodeJS.Signals | number }> = [];
558+
559+
try {
560+
process.kill = ((pid: number, signal?: NodeJS.Signals | number) => {
561+
killed.push({ pid, signal });
562+
return true;
563+
}) as typeof process.kill;
564+
565+
(manager as any).addSessionProcess(sessionId, 1234, "python3 -m http.server 8080");
566+
manager.dispose();
567+
} finally {
568+
process.kill = originalKill;
569+
}
570+
571+
assert.deepEqual(killed, [{ pid: -1234, signal: "SIGKILL" }]);
572+
});
573+
574+
test("SessionManager deleteSession ignores persisted processes that are not live", (t) => {
575+
if (process.platform === "win32") {
576+
t.skip("process group kill assertion is non-Windows specific");
577+
return;
578+
}
579+
580+
const workspace = createTempDir("deepcode-delete-stale-process-workspace-");
581+
const home = createTempDir("deepcode-delete-stale-process-home-");
582+
setHomeDir(home);
583+
const manager = createSessionManager(workspace, "machine-id-delete-stale-process");
584+
const sessionId = createSessionAndMessages(manager, "session-delete-stale-process", "Delete stale process session");
585+
(manager as any).updateSessionEntry(sessionId, (entry: any) => ({
586+
...entry,
587+
processes: new Map([["1234", { startTime: new Date().toISOString(), command: "stale process" }]]),
588+
}));
589+
const originalKill = process.kill;
590+
const killed: Array<{ pid: number; signal?: NodeJS.Signals | number }> = [];
591+
592+
try {
593+
process.kill = ((pid: number, signal?: NodeJS.Signals | number) => {
594+
killed.push({ pid, signal });
595+
return true;
596+
}) as typeof process.kill;
597+
598+
assert.equal(manager.deleteSession(sessionId), true);
599+
} finally {
600+
process.kill = originalKill;
601+
}
602+
603+
assert.deepEqual(killed, []);
604+
});
605+
502606
test("SessionManager refreshes cached MCP tool definitions after server crash", async () => {
503607
const workspace = createTempDir("deepcode-mcp-crash-cache-workspace-");
504608
const serverPath = path.join(workspace, "mcp-server-crash.cjs");

0 commit comments

Comments
 (0)