Skip to content

Commit 63c7884

Browse files
committed
feat: enhance Bash tool for background task cleanup logic
1 parent 63de6fb commit 63c7884

6 files changed

Lines changed: 256 additions & 20 deletions

File tree

src/session.ts

Lines changed: 80 additions & 15 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);
@@ -2667,6 +2663,7 @@ ${skillMd}
26672663

26682664
private addSessionProcess(sessionId: string, processId: string | number, command: string): void {
26692665
const now = new Date().toISOString();
2666+
this.liveProcessKeys.add(this.getProcessControlKey(sessionId, processId));
26702667
this.updateSessionEntry(sessionId, (entry) => {
26712668
const processes = new Map(entry.processes ?? []);
26722669
processes.set(String(processId), { startTime: now, command });
@@ -2699,12 +2696,47 @@ ${skillMd}
26992696
? `signal ${completion.signal}`
27002697
: completion.error || "unknown status";
27012698
const durationMs = Math.max(0, completion.completedAtMs - completion.startedAtMs);
2702-
const content =
2699+
const baseContent =
27032700
`Background command "${completion.command}" ${status} with ${exitText} ` +
27042701
`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;
27052704
this.addSessionSystemMessage(sessionId, content, true);
27062705
}
27072706

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+
27082740
private formatBackgroundDuration(durationMs: number): string {
27092741
if (durationMs < 1000) {
27102742
return `${durationMs}ms`;
@@ -2720,7 +2752,9 @@ ${skillMd}
27202752

27212753
private removeSessionProcess(sessionId: string, processId: string | number): void {
27222754
const now = new Date().toISOString();
2723-
this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, processId));
2755+
const processControlKey = this.getProcessControlKey(sessionId, processId);
2756+
this.processTimeoutControls.delete(processControlKey);
2757+
this.liveProcessKeys.delete(processControlKey);
27242758
this.updateSessionEntry(sessionId, (entry) => {
27252759
const processes = new Map(entry.processes ?? []);
27262760
processes.delete(String(processId));
@@ -2783,6 +2817,37 @@ ${skillMd}
27832817
return `${sessionId}:${String(processId)}`;
27842818
}
27852819

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+
27862851
private getProcessIds(processes: Map<string, SessionProcessEntry> | null): number[] {
27872852
if (!processes) {
27882853
return [];

src/tests/prompt.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ test("getSystemPrompt includes UpdatePlan docs", () => {
4848
test("getSystemPrompt includes Bash background guidance", () => {
4949
const prompt = getSystemPrompt("/tmp/project");
5050
assert.equal(prompt.includes("run_in_background: true"), true);
51-
assert.equal(prompt.includes("do not add `&`"), 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);
5254
});
5355

5456
test("getSystemPrompt does not include runtime context", () => {

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");

src/tests/tool-handlers.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,13 @@ test("Bash can run commands in the background and report completion output", asy
131131
assert.equal(result.metadata?.runInBackground, true);
132132
assert.equal(typeof result.metadata?.backgroundTaskId, "string");
133133
assert.equal(typeof result.metadata?.outputPath, "string");
134+
assert.equal(typeof result.metadata?.processId, "number");
135+
const stopCommand =
136+
process.platform === "win32"
137+
? `cmd.exe /c "taskkill /PID ${result.metadata.processId} /T /F"`
138+
: `kill -- -${result.metadata.processId}`;
139+
assert.equal(result.metadata?.stopCommand, stopCommand);
140+
assert.match(result.output ?? "", /Stop it with:/);
134141
assert.ok(Date.now() - startedAt < 500);
135142
assert.equal(starts.length, 1);
136143

@@ -176,6 +183,38 @@ test("Bash background completion reports failed exit codes", async () => {
176183
assert.match(output, /bad/);
177184
});
178185

186+
test("Bash removes a trailing ampersand when run_in_background is true", async () => {
187+
const workspace = createTempWorkspace();
188+
let startedCommand = "";
189+
let completion: BackgroundProcessCompletion | null = null;
190+
191+
const result = await handleBashTool(
192+
{
193+
command: "printf 'trimmed\\n' &",
194+
run_in_background: true,
195+
},
196+
createContext("bash-background-trailing-ampersand", workspace, {
197+
onProcessStart: (_pid, command) => {
198+
startedCommand = command;
199+
},
200+
onBackgroundProcessComplete: (event) => {
201+
completion = event;
202+
},
203+
})
204+
);
205+
206+
assert.equal(result.ok, true);
207+
assert.equal(startedCommand, "printf 'trimmed\\n'");
208+
209+
await waitFor(() => completion !== null, 2000);
210+
211+
assert.ok(completion);
212+
const done = completion as BackgroundProcessCompletion;
213+
assert.equal(done.command, "printf 'trimmed\\n'");
214+
assert.equal(done.ok, true);
215+
assert.equal(fs.readFileSync(done.outputPath, "utf8"), "trimmed\n");
216+
});
217+
179218
test("UpdatePlan accepts a markdown task list string", async () => {
180219
const workspace = createTempWorkspace();
181220
const plan = ["## Task List", "", "- [>] Inspect current behavior", "- [ ] Implement UpdatePlan"].join("\n");

0 commit comments

Comments
 (0)