Skip to content

Commit 5d48d41

Browse files
committed
feat(bash): Add Bash timeout control feature and related adjustments
1 parent e4d9b44 commit 5d48d41

9 files changed

Lines changed: 450 additions & 36 deletions

File tree

src/common/bash-timeout.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const DEFAULT_BASH_TIMEOUT_MS = 10 * 60 * 1000;
2+
export const MIN_BASH_TIMEOUT_MS = 60 * 1000;
3+
export const BASH_TIMEOUT_INCREMENT_MS = 5 * 60 * 1000;
4+
export const BASH_TIMEOUT_DECREMENT_MS = 60 * 1000;
5+
6+
export function clampBashTimeoutMs(timeoutMs: number, minTimeoutMs: number = MIN_BASH_TIMEOUT_MS): number {
7+
if (!Number.isFinite(timeoutMs)) {
8+
return DEFAULT_BASH_TIMEOUT_MS;
9+
}
10+
const minimum = Number.isFinite(minTimeoutMs) ? Math.max(1, Math.round(minTimeoutMs)) : MIN_BASH_TIMEOUT_MS;
11+
return Math.max(minimum, Math.round(timeoutMs));
12+
}

src/session.ts

Lines changed: 128 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ import {
1717
getTools,
1818
type ToolDefinition,
1919
} from "./prompt";
20-
import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor";
20+
import {
21+
ToolExecutor,
22+
type CreateOpenAIClient,
23+
type ProcessTimeoutControl,
24+
type ProcessTimeoutInfo,
25+
} from "./tools/executor";
2126
import { McpManager } from "./mcp/mcp-manager";
2227
import type { McpServerConfig } from "./settings";
2328
import { logApiError } from "./common/error-logger";
@@ -134,6 +139,21 @@ export type ModelUsage = {
134139
total_reqs?: number;
135140
};
136141

142+
export type SessionProcessEntry = {
143+
startTime: string;
144+
command: string;
145+
timeoutMs?: number;
146+
deadlineAt?: string;
147+
timedOut?: boolean;
148+
};
149+
150+
export type BashTimeoutAdjustment = {
151+
processId: string;
152+
timeoutMs: number;
153+
deadlineAt: string;
154+
timedOut: boolean;
155+
};
156+
137157
export type SessionEntry = {
138158
id: string;
139159
summary: string | null;
@@ -148,7 +168,7 @@ export type SessionEntry = {
148168
activeTokens: number;
149169
createTime: string;
150170
updateTime: string;
151-
processes: Map<string, { startTime: string; command: string }> | null; // {pid: {startTime, command}}
171+
processes: Map<string, SessionProcessEntry> | null; // {pid: process info}
152172
};
153173

154174
export type SessionsIndex = {
@@ -234,6 +254,7 @@ export class SessionManager {
234254
private activeSessionId: string | null = null;
235255
private activePromptController: AbortController | null = null;
236256
private readonly sessionControllers = new Map<string, AbortController>();
257+
private readonly processTimeoutControls = new Map<string, ProcessTimeoutControl>();
237258
private readonly toolExecutor: ToolExecutor;
238259
private readonly mcpManager = new McpManager();
239260
private mcpToolDefinitions: ToolDefinition[] = [];
@@ -1360,6 +1381,7 @@ ${skillMd}
13601381
const killedPids: number[] = [];
13611382
const failedPids: number[] = [];
13621383
for (const pid of processIds) {
1384+
this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, pid));
13631385
if (killProcessTree(pid, "SIGKILL")) {
13641386
killedPids.push(pid);
13651387
continue;
@@ -1397,6 +1419,37 @@ ${skillMd}
13971419
return !this.sessionControllers.has(sessionId);
13981420
}
13991421

1422+
adjustActiveBashTimeout(deltaMs: number): BashTimeoutAdjustment | null {
1423+
const sessionId = this.activeSessionId;
1424+
if (!sessionId || !Number.isFinite(deltaMs)) {
1425+
return null;
1426+
}
1427+
const session = this.getSession(sessionId);
1428+
if (!session?.processes) {
1429+
return null;
1430+
}
1431+
1432+
let selectedPid: string | null = null;
1433+
for (const pid of session.processes.keys()) {
1434+
if (this.processTimeoutControls.has(this.getProcessControlKey(sessionId, pid))) {
1435+
selectedPid = pid;
1436+
}
1437+
}
1438+
if (!selectedPid) {
1439+
return null;
1440+
}
1441+
1442+
const control = this.processTimeoutControls.get(this.getProcessControlKey(sessionId, selectedPid));
1443+
if (!control) {
1444+
return null;
1445+
}
1446+
1447+
const current = control.getInfo();
1448+
const next = control.setTimeoutMs(current.timeoutMs + deltaMs);
1449+
this.updateSessionProcessTimeout(sessionId, selectedPid, next);
1450+
return this.buildBashTimeoutAdjustment(selectedPid, next);
1451+
}
1452+
14001453
listSessions(): SessionEntry[] {
14011454
const index = this.loadSessionsIndex();
14021455
return index.entries;
@@ -1741,6 +1794,7 @@ ${skillMd}
17411794
onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command),
17421795
onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid),
17431796
onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk),
1797+
onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control),
17441798
shouldStop: () => this.isInterrupted(sessionId),
17451799
});
17461800
if (this.isInterrupted(sessionId)) {
@@ -2137,6 +2191,7 @@ ${skillMd}
21372191

21382192
private removeSessionProcess(sessionId: string, processId: string | number): void {
21392193
const now = new Date().toISOString();
2194+
this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, processId));
21402195
this.updateSessionEntry(sessionId, (entry) => {
21412196
const processes = new Map(entry.processes ?? []);
21422197
processes.delete(String(processId));
@@ -2148,7 +2203,58 @@ ${skillMd}
21482203
});
21492204
}
21502205

2151-
private getProcessIds(processes: Map<string, { startTime: string; command: string }> | null): number[] {
2206+
private setSessionProcessTimeoutControl(
2207+
sessionId: string,
2208+
processId: string | number,
2209+
control: ProcessTimeoutControl | null
2210+
): void {
2211+
const key = this.getProcessControlKey(sessionId, processId);
2212+
if (!control) {
2213+
this.processTimeoutControls.delete(key);
2214+
return;
2215+
}
2216+
2217+
this.processTimeoutControls.set(key, control);
2218+
this.updateSessionProcessTimeout(sessionId, processId, control.getInfo());
2219+
}
2220+
2221+
private updateSessionProcessTimeout(sessionId: string, processId: string | number, info: ProcessTimeoutInfo): void {
2222+
const now = new Date().toISOString();
2223+
this.updateSessionEntry(sessionId, (entry) => {
2224+
const processes = new Map(entry.processes ?? []);
2225+
const pid = String(processId);
2226+
const processInfo = processes.get(pid);
2227+
if (!processInfo) {
2228+
return entry;
2229+
}
2230+
processes.set(pid, {
2231+
...processInfo,
2232+
timeoutMs: info.timeoutMs,
2233+
deadlineAt: new Date(info.deadlineAtMs).toISOString(),
2234+
timedOut: info.timedOut,
2235+
});
2236+
return {
2237+
...entry,
2238+
processes,
2239+
updateTime: now,
2240+
};
2241+
});
2242+
}
2243+
2244+
private buildBashTimeoutAdjustment(processId: string, info: ProcessTimeoutInfo): BashTimeoutAdjustment {
2245+
return {
2246+
processId,
2247+
timeoutMs: info.timeoutMs,
2248+
deadlineAt: new Date(info.deadlineAtMs).toISOString(),
2249+
timedOut: info.timedOut,
2250+
};
2251+
}
2252+
2253+
private getProcessControlKey(sessionId: string, processId: string | number): string {
2254+
return `${sessionId}:${String(processId)}`;
2255+
}
2256+
2257+
private getProcessIds(processes: Map<string, SessionProcessEntry> | null): number[] {
21522258
if (!processes) {
21532259
return [];
21542260
}
@@ -2232,11 +2338,11 @@ ${skillMd}
22322338
return usagePerModel;
22332339
}
22342340

2235-
private deserializeProcesses(value: unknown): Map<string, { startTime: string; command: string }> | null {
2341+
private deserializeProcesses(value: unknown): Map<string, SessionProcessEntry> | null {
22362342
if (!value || typeof value !== "object") {
22372343
return null;
22382344
}
2239-
const processes = new Map<string, { startTime: string; command: string }>();
2345+
const processes = new Map<string, SessionProcessEntry>();
22402346
for (const [pid, entry] of Object.entries(value as Record<string, unknown>)) {
22412347
if (!pid) {
22422348
continue;
@@ -2245,22 +2351,34 @@ ${skillMd}
22452351
// Backward compatibility for old format where just stored start time
22462352
processes.set(pid, { startTime: entry, command: "Running process..." });
22472353
} else if (typeof entry === "object" && entry !== null) {
2248-
const obj = entry as { startTime?: unknown; command?: unknown };
2354+
const obj = entry as {
2355+
startTime?: unknown;
2356+
command?: unknown;
2357+
timeoutMs?: unknown;
2358+
deadlineAt?: unknown;
2359+
timedOut?: unknown;
2360+
};
22492361
const startTime = typeof obj.startTime === "string" ? obj.startTime : new Date().toISOString();
22502362
const command = typeof obj.command === "string" ? obj.command : "Running process...";
2251-
processes.set(pid, { startTime, command });
2363+
processes.set(pid, {
2364+
startTime,
2365+
command,
2366+
timeoutMs: typeof obj.timeoutMs === "number" ? obj.timeoutMs : undefined,
2367+
deadlineAt: typeof obj.deadlineAt === "string" ? obj.deadlineAt : undefined,
2368+
timedOut: typeof obj.timedOut === "boolean" ? obj.timedOut : undefined,
2369+
});
22522370
}
22532371
}
22542372
return processes.size > 0 ? processes : null;
22552373
}
22562374

22572375
private serializeProcesses(
2258-
processes: Map<string, { startTime: string; command: string }> | null
2259-
): Record<string, { startTime: string; command: string }> | null {
2376+
processes: Map<string, SessionProcessEntry> | null
2377+
): Record<string, SessionProcessEntry> | null {
22602378
if (!processes || processes.size === 0) {
22612379
return null;
22622380
}
2263-
const serialized: Record<string, { startTime: string; command: string }> = {};
2381+
const serialized: Record<string, SessionProcessEntry> = {};
22642382
for (const [pid, entry] of processes.entries()) {
22652383
serialized[pid] = entry;
22662384
}

src/tests/session.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1641,6 +1641,40 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () =
16411641
assert.equal(session?.failReason, "interrupted");
16421642
});
16431643

1644+
test("SessionManager adjusts the active Bash timeout control and session metadata", async () => {
1645+
const workspace = createTempDir("deepcode-bash-timeout-session-");
1646+
const manager = createSessionManager(workspace, "");
1647+
const sessionId = await manager.createSession({ text: "hello" });
1648+
1649+
(manager as any).addSessionProcess(sessionId, 123, "sleep 10");
1650+
1651+
let timeoutInfo = {
1652+
timeoutMs: 10 * 60 * 1000,
1653+
startedAtMs: 1000,
1654+
deadlineAtMs: 1000 + 10 * 60 * 1000,
1655+
timedOut: false,
1656+
};
1657+
(manager as any).setSessionProcessTimeoutControl(sessionId, 123, {
1658+
getInfo: () => timeoutInfo,
1659+
setTimeoutMs: (timeoutMs: number) => {
1660+
timeoutInfo = {
1661+
...timeoutInfo,
1662+
timeoutMs,
1663+
deadlineAtMs: timeoutInfo.startedAtMs + timeoutMs,
1664+
};
1665+
return timeoutInfo;
1666+
},
1667+
});
1668+
1669+
const adjustment = manager.adjustActiveBashTimeout(5 * 60 * 1000);
1670+
const processInfo = manager.getSession(sessionId)?.processes?.get("123");
1671+
1672+
assert.equal(adjustment?.processId, "123");
1673+
assert.equal(adjustment?.timeoutMs, 15 * 60 * 1000);
1674+
assert.equal(processInfo?.timeoutMs, 15 * 60 * 1000);
1675+
assert.equal(processInfo?.deadlineAt, new Date(timeoutInfo.deadlineAtMs).toISOString());
1676+
});
1677+
16441678
function createSessionManager(projectRoot: string, machineId: string): SessionManager {
16451679
return new SessionManager({
16461680
projectRoot,

src/tests/tool-handlers.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as fs from "fs";
44
import * as os from "os";
55
import * as path from "path";
66
import { setTimeout as delay } from "node:timers/promises";
7-
import type { ToolExecutionContext } from "../tools/executor";
7+
import type { ProcessTimeoutControl, ToolExecutionContext } from "../tools/executor";
88
import { handleBashTool } from "../tools/bash-handler";
99
import { handleEditTool } from "../tools/edit-handler";
1010
import { handleReadTool } from "../tools/read-handler";
@@ -52,6 +52,58 @@ test("Bash streams stdout and stderr before command completion", async () => {
5252
assert.match(streamedOutput, /err/);
5353
});
5454

55+
test("Bash terminates commands that exceed the configured timeout", async () => {
56+
const workspace = createTempWorkspace();
57+
const exitedPids: Array<string | number> = [];
58+
59+
const result = await handleBashTool(
60+
{
61+
command: "printf 'start\\n'; sleep 5; printf 'done\\n'",
62+
},
63+
createContext("bash-timeout", workspace, {
64+
bashTimeoutMs: 100,
65+
bashMinTimeoutMs: 1,
66+
onProcessExit: (pid) => {
67+
exitedPids.push(pid);
68+
},
69+
})
70+
);
71+
72+
assert.equal(result.ok, false);
73+
assert.equal(result.error, "Command timed out.");
74+
assert.equal(result.metadata?.timedOut, true);
75+
assert.equal(result.metadata?.timeoutMs, 100);
76+
assert.doesNotMatch(result.output ?? "", /done/);
77+
assert.equal(exitedPids.length, 1);
78+
});
79+
80+
test("Bash timeout control can extend the active command deadline", async () => {
81+
const workspace = createTempWorkspace();
82+
let timeoutControl: ProcessTimeoutControl | null = null;
83+
84+
const result = await handleBashTool(
85+
{
86+
command: "sleep 0.2; printf 'done\\n'",
87+
},
88+
createContext("bash-timeout-extend", workspace, {
89+
bashTimeoutMs: 100,
90+
bashMinTimeoutMs: 1,
91+
onProcessTimeoutControl: (_pid, control) => {
92+
if (control) {
93+
timeoutControl = control;
94+
control.setTimeoutMs(1000);
95+
}
96+
},
97+
})
98+
);
99+
100+
assert.ok(timeoutControl);
101+
assert.equal(result.ok, true);
102+
assert.match(result.output ?? "", /done/);
103+
assert.equal(result.metadata?.timedOut, false);
104+
assert.equal(result.metadata?.timeoutMs, 1000);
105+
});
106+
55107
test("UpdatePlan accepts a markdown task list string", async () => {
56108
const workspace = createTempWorkspace();
57109
const plan = ["## Task List", "", "- [>] Inspect current behavior", "- [ ] Implement UpdatePlan"].join("\n");

0 commit comments

Comments
 (0)