Skip to content

Commit 9044b6c

Browse files
committed
chore: resolve merge conflict with origin/main
- Keep HEAD import (supportsMultimodal moved to OpenAIMessageConverter) - Add readTextFileWithMetadata (needed by origin/main background task tail)
2 parents cd433cd + 2e9dfbc commit 9044b6c

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 } from "openai/resources/chat/completio
88
import { launchNotifyScript } from "./common/notify";
99
import { buildThinkingRequestOptions } from "./common/openai-thinking";
1010
import { DEEPSEEK_V4_MODELS } from "./common/model-capabilities";
11+
import { readTextFileWithMetadata } from "./common/file-utils";
1112
import {
1213
getCompactPrompt,
1314
getDefaultSkillPrompt,
@@ -61,6 +62,7 @@ export type {
6162
const MAX_SESSION_ENTRIES = 50;
6263
const MAX_PROJECT_CODE_LENGTH = 64;
6364
const PROJECT_CODE_HASH_LENGTH = 16;
65+
const BACKGROUND_FAILURE_LOG_TAIL_CHARS = 4000;
6466
const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024;
6567
const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024;
6668

@@ -331,6 +333,7 @@ export class SessionManager {
331333
private activePromptController: AbortController | null = null;
332334
private readonly sessionControllers = new Map<string, AbortController>();
333335
private readonly processTimeoutControls = new Map<string, ProcessTimeoutControl>();
336+
private readonly liveProcessKeys = new Set<string>();
334337
private readonly toolExecutor: ToolExecutor;
335338
private readonly mcpManager = new McpManager();
336339
private mcpToolDefinitions: ToolDefinition[] = [];
@@ -396,6 +399,7 @@ export class SessionManager {
396399
sessionController.abort();
397400
}
398401
}
402+
this.killLiveProcesses();
399403
this.sessionControllers.clear();
400404
this.processTimeoutControls.clear();
401405
this.mcpManager.disconnect();
@@ -1548,7 +1552,9 @@ ${skillMd}
15481552
const killedPids: number[] = [];
15491553
const failedPids: number[] = [];
15501554
for (const pid of processIds) {
1551-
this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, pid));
1555+
const processControlKey = this.getProcessControlKey(sessionId, pid);
1556+
this.processTimeoutControls.delete(processControlKey);
1557+
this.liveProcessKeys.delete(processControlKey);
15521558
if (killProcessTree(pid, "SIGKILL")) {
15531559
killedPids.push(pid);
15541560
continue;
@@ -1915,21 +1921,11 @@ ${skillMd}
19151921
const processIds = options.processIds ?? [];
19161922
for (const pid of processIds) {
19171923
const processControlKey = this.getProcessControlKey(sessionId, pid);
1918-
if (!this.processTimeoutControls.has(processControlKey)) {
1924+
if (!this.processTimeoutControls.has(processControlKey) && !this.liveProcessKeys.has(processControlKey)) {
19191925
continue;
19201926
}
19211927

1922-
const killedGroup = killProcessTree(pid, "SIGKILL");
1923-
if (killedGroup) {
1924-
this.processTimeoutControls.delete(processControlKey);
1925-
continue;
1926-
}
1927-
try {
1928-
process.kill(pid, "SIGKILL");
1929-
} catch {
1930-
// ignore process-kill failures during cleanup
1931-
}
1932-
this.processTimeoutControls.delete(processControlKey);
1928+
this.killTrackedProcess(processControlKey, pid);
19331929
}
19341930

19351931
clearSessionState(sessionId);
@@ -2195,6 +2191,7 @@ ${skillMd}
21952191
onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid),
21962192
onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk),
21972193
onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control),
2194+
onBackgroundProcessComplete: (completion) => this.addBackgroundProcessCompletionMessage(sessionId, completion),
21982195
onBeforeFileMutation: (filePath) => this.prepareFileMutationCheckpoint(sessionId, filePath),
21992196
onAfterFileMutation: (filePath) => this.recordFileMutationCheckpoint(sessionId, filePath),
22002197
shouldStop: () => this.isInterrupted(sessionId),
@@ -2456,6 +2453,7 @@ ${skillMd}
24562453

24572454
private addSessionProcess(sessionId: string, processId: string | number, command: string): void {
24582455
const now = new Date().toISOString();
2456+
this.liveProcessKeys.add(this.getProcessControlKey(sessionId, processId));
24592457
this.updateSessionEntry(sessionId, (entry) => {
24602458
const processes = new Map(entry.processes ?? []);
24612459
processes.set(String(processId), { startTime: now, command });
@@ -2467,9 +2465,86 @@ ${skillMd}
24672465
});
24682466
}
24692467

2468+
private addBackgroundProcessCompletionMessage(
2469+
sessionId: string,
2470+
completion: {
2471+
command: string;
2472+
outputPath: string;
2473+
ok: boolean;
2474+
exitCode: number | null;
2475+
signal: string | null;
2476+
error?: string;
2477+
completedAtMs: number;
2478+
startedAtMs: number;
2479+
}
2480+
): void {
2481+
const status = completion.ok ? "completed" : "failed";
2482+
const exitText =
2483+
completion.exitCode !== null
2484+
? `exit code ${completion.exitCode}`
2485+
: completion.signal
2486+
? `signal ${completion.signal}`
2487+
: completion.error || "unknown status";
2488+
const durationMs = Math.max(0, completion.completedAtMs - completion.startedAtMs);
2489+
const baseContent =
2490+
`Background command "${completion.command}" ${status} with ${exitText} ` +
2491+
`after ${this.formatBackgroundDuration(durationMs)}. Output: ${completion.outputPath}`;
2492+
const logTail = completion.ok ? null : this.buildBackgroundFailureLogTailSlice(completion.outputPath);
2493+
const content = logTail ? `${baseContent}\n${logTail}` : baseContent;
2494+
this.addSessionSystemMessage(sessionId, content, true);
2495+
}
2496+
2497+
private buildBackgroundFailureLogTailSlice(outputPath: string): string | null {
2498+
const tail = this.readTextFileTail(outputPath, BACKGROUND_FAILURE_LOG_TAIL_CHARS);
2499+
if (!tail || !tail.content) {
2500+
return null;
2501+
}
2502+
const prefix = tail.truncated ? `(${tail.totalBytes} bytes)...\n` : "";
2503+
return [
2504+
`<background_task_failure_log path="${outputPath}">`,
2505+
`${prefix}${tail.content}`,
2506+
"</background_task_failure_log>",
2507+
].join("\n");
2508+
}
2509+
2510+
private readTextFileTail(
2511+
filePath: string,
2512+
maxChars: number
2513+
): { content: string; totalBytes: number; truncated: boolean } | null {
2514+
try {
2515+
const stat = fs.statSync(filePath);
2516+
if (!stat.isFile() || stat.size <= 0) {
2517+
return null;
2518+
}
2519+
const content = readTextFileWithMetadata(filePath).content;
2520+
return {
2521+
content: content.slice(-maxChars).trimEnd(),
2522+
totalBytes: stat.size,
2523+
truncated: content.length > maxChars,
2524+
};
2525+
} catch {
2526+
return null;
2527+
}
2528+
}
2529+
2530+
private formatBackgroundDuration(durationMs: number): string {
2531+
if (durationMs < 1000) {
2532+
return `${durationMs}ms`;
2533+
}
2534+
const seconds = Math.round(durationMs / 1000);
2535+
if (seconds < 60) {
2536+
return `${seconds}s`;
2537+
}
2538+
const minutes = Math.floor(seconds / 60);
2539+
const remainingSeconds = seconds % 60;
2540+
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
2541+
}
2542+
24702543
private removeSessionProcess(sessionId: string, processId: string | number): void {
24712544
const now = new Date().toISOString();
2472-
this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, processId));
2545+
const processControlKey = this.getProcessControlKey(sessionId, processId);
2546+
this.processTimeoutControls.delete(processControlKey);
2547+
this.liveProcessKeys.delete(processControlKey);
24732548
this.updateSessionEntry(sessionId, (entry) => {
24742549
const processes = new Map(entry.processes ?? []);
24752550
processes.delete(String(processId));
@@ -2532,6 +2607,37 @@ ${skillMd}
25322607
return `${sessionId}:${String(processId)}`;
25332608
}
25342609

2610+
private killLiveProcesses(): void {
2611+
for (const processControlKey of Array.from(this.liveProcessKeys)) {
2612+
const processId = this.getProcessIdFromControlKey(processControlKey);
2613+
if (processId === null) {
2614+
this.liveProcessKeys.delete(processControlKey);
2615+
continue;
2616+
}
2617+
this.killTrackedProcess(processControlKey, processId);
2618+
}
2619+
}
2620+
2621+
private killTrackedProcess(processControlKey: string, processId: number): void {
2622+
const killedGroup = killProcessTree(processId, "SIGKILL");
2623+
if (!killedGroup) {
2624+
try {
2625+
process.kill(processId, "SIGKILL");
2626+
} catch {
2627+
// Ignore process-kill failures during cleanup.
2628+
}
2629+
}
2630+
this.processTimeoutControls.delete(processControlKey);
2631+
this.liveProcessKeys.delete(processControlKey);
2632+
}
2633+
2634+
private getProcessIdFromControlKey(processControlKey: string): number | null {
2635+
const separatorIndex = processControlKey.lastIndexOf(":");
2636+
const rawProcessId = separatorIndex >= 0 ? processControlKey.slice(separatorIndex + 1) : processControlKey;
2637+
const processId = Number(rawProcessId);
2638+
return Number.isInteger(processId) && processId > 0 ? processId : null;
2639+
}
2640+
25352641
private getProcessIds(processes: Map<string, SessionProcessEntry> | null): number[] {
25362642
if (!processes) {
25372643
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)