Skip to content

Commit 01d21cb

Browse files
author
Eric Oliver
committed
mcp servers and ask followup are playing nicely. moved mcp server init to cli startup
1 parent 9c2d7ce commit 01d21cb

File tree

16 files changed

+1937
-113
lines changed

16 files changed

+1937
-113
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Sliding Timeout Implementation for Batch Execution
2+
3+
## Problem
4+
5+
The batch execution code had a fixed 60-second timeout that would kill processes regardless of whether work was happening. This was problematic for long-running tasks that had periods of activity.
6+
7+
## Solution
8+
9+
Implemented a sliding timeout that resets whenever there's activity, ensuring tasks only timeout during periods of inactivity.
10+
11+
## Implementation
12+
13+
### Key Changes in `src/cli/commands/batch.ts`
14+
15+
1. **Replaced Fixed Timeout with Sliding Timeout**:
16+
17+
```typescript
18+
// Before: Fixed timeout
19+
const timeout = setTimeout(() => {
20+
reject(new Error(`Task execution timeout after ${timeoutMs}ms`))
21+
}, timeoutMs)
22+
23+
// After: Sliding timeout with reset capability
24+
let timeout: NodeJS.Timeout
25+
const resetTimeout = () => {
26+
if (timeout) clearTimeout(timeout)
27+
timeout = setTimeout(() => {
28+
reject(new Error(`Task execution timeout after ${timeoutMs}ms of inactivity`))
29+
}, timeoutMs)
30+
}
31+
```
32+
33+
2. **Activity Detection**:
34+
The timeout resets on these Task events:
35+
36+
- `taskStarted` - When task begins
37+
- `taskModeSwitched` - When switching between modes (e.g., code, debug)
38+
- `taskPaused` / `taskUnpaused` - When task state changes
39+
- `taskSpawned` - When new tasks are created
40+
- `taskTokenUsageUpdated` - When API calls are made (frequent during active work)
41+
- `message` - When messages are created/updated
42+
43+
3. **User Input Handling**:
44+
45+
- Timeout is **paused** when questions are asked to the user
46+
- Timeout is **resumed** when user responds (`taskAskResponded` event)
47+
- This prevents false timeouts while waiting for user input
48+
- Users can take as long as needed to respond to questions
49+
50+
4. **Proper Cleanup**:
51+
- Timeout is cleared when task completes successfully
52+
- Timeout is cleared when task is aborted
53+
- Timeout is cleared when tool failures occur
54+
55+
## Benefits
56+
57+
1. **No More False Timeouts**: Tasks won't timeout while actively working
58+
2. **Still Prevents Hanging**: Tasks will timeout after 60 seconds of true inactivity
59+
3. **Better User Experience**: Long-running tasks can complete without manual intervention
60+
4. **Maintains Safety**: Still protects against truly stuck processes
61+
62+
## Testing
63+
64+
Created comprehensive tests and a demonstration script showing:
65+
66+
- Basic timeout behavior after inactivity period
67+
- Timeout reset on activity events
68+
- Proper cleanup on completion/abort
69+
- Multiple rapid activity events handling
70+
71+
## Demo Output
72+
73+
```
74+
=== Sliding Timeout Demo ===
75+
76+
Scenario 1: Task times out after 5 seconds of inactivity
77+
Timeout reset - task has 5000ms of inactivity before timeout
78+
Task timed out after 5000ms of inactivity
79+
❌ Task timed out due to inactivity!
80+
81+
Scenario 2: Task with periodic activity
82+
Timeout reset - task has 5000ms of inactivity before timeout
83+
Activity detected - resetting timeout
84+
Timeout reset - task has 5000ms of inactivity before timeout
85+
Activity detected - resetting timeout
86+
Timeout reset - task has 5000ms of inactivity before timeout
87+
88+
Scenario 3: Task completes before timeout
89+
Timeout reset - task has 5000ms of inactivity before timeout
90+
Task completed - clearing timeout
91+
Timeout cleared
92+
✅ Task completed successfully before timeout
93+
```
94+
95+
## Configuration
96+
97+
The timeout duration remains configurable at 60 seconds (60000ms) but now represents inactivity timeout rather than total execution time.
98+
99+
## Backward Compatibility
100+
101+
This change is backward compatible - existing batch execution will work the same but with improved timeout behavior.
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { BatchProcessor } from "../batch"
2+
import { Task } from "../../../core/task/Task"
3+
import { CliConfigManager } from "../../config/CliConfigManager"
4+
import { EventEmitter } from "events"
5+
6+
// Mock dependencies
7+
jest.mock("../../../core/task/Task")
8+
jest.mock("../../config/CliConfigManager")
9+
jest.mock("../../services/CLILogger", () => ({
10+
getCLILogger: () => ({
11+
debug: jest.fn(),
12+
info: jest.fn(),
13+
error: jest.fn(),
14+
}),
15+
}))
16+
jest.mock("../../../core/adapters/cli", () => ({
17+
createCliAdapters: jest.fn().mockReturnValue({
18+
fileSystem: {},
19+
terminal: {},
20+
browser: {},
21+
telemetry: {},
22+
}),
23+
}))
24+
jest.mock("../../services/CLIUIService")
25+
26+
describe("BatchProcessor", () => {
27+
let batchProcessor: BatchProcessor
28+
let mockTask: any
29+
let mockTaskPromise: Promise<void>
30+
let mockConfigManager: any
31+
32+
const defaultOptions = {
33+
cwd: "/test/cwd",
34+
verbose: false,
35+
color: false,
36+
}
37+
38+
beforeEach(() => {
39+
jest.clearAllMocks()
40+
jest.useFakeTimers()
41+
42+
// Create a mock task that extends EventEmitter
43+
mockTask = new EventEmitter()
44+
mockTask.taskId = "test-task-id"
45+
mockTask.isInitialized = true
46+
mockTask.abort = false
47+
mockTask.dispose = jest.fn().mockResolvedValue(undefined)
48+
49+
// Mock task promise that never resolves (we'll control completion via events)
50+
mockTaskPromise = new Promise(() => {}) // Never resolves
51+
52+
// Mock Task.create to return our mock task and promise
53+
;(Task.create as jest.Mock).mockReturnValue([mockTask, mockTaskPromise])
54+
55+
mockConfigManager = {
56+
loadConfiguration: jest.fn().mockResolvedValue({
57+
apiProvider: "anthropic",
58+
apiKey: "test-key",
59+
apiModelId: "claude-3-5-sonnet-20241022",
60+
}),
61+
}
62+
;(CliConfigManager as any).mockImplementation(() => mockConfigManager)
63+
64+
batchProcessor = new BatchProcessor(defaultOptions, mockConfigManager)
65+
})
66+
67+
afterEach(() => {
68+
jest.useRealTimers()
69+
})
70+
71+
describe("sliding timeout", () => {
72+
it("should timeout after 60 seconds of inactivity", async () => {
73+
const runPromise = batchProcessor.run("test task")
74+
75+
// Fast-forward 60 seconds
76+
jest.advanceTimersByTime(60000)
77+
78+
await expect(runPromise).rejects.toThrow("Task execution timeout after 60000ms of inactivity")
79+
})
80+
81+
it("should reset timeout when task activity occurs", async () => {
82+
const runPromise = batchProcessor.run("test task")
83+
84+
// Wait 59 seconds (just before timeout)
85+
jest.advanceTimersByTime(59000)
86+
87+
// Trigger activity event - this should reset the timeout
88+
mockTask.emit("taskTokenUsageUpdated", "test-task-id", { inputTokens: 100 })
89+
90+
// Wait another 59 seconds (should not timeout yet)
91+
jest.advanceTimersByTime(59000)
92+
93+
// Task should still be running, no timeout yet
94+
expect(runPromise).not.toBe(undefined)
95+
96+
// Now wait the full 60 seconds from the last activity
97+
jest.advanceTimersByTime(1000)
98+
99+
await expect(runPromise).rejects.toThrow("Task execution timeout after 60000ms of inactivity")
100+
})
101+
102+
it("should reset timeout on various activity events", async () => {
103+
const runPromise = batchProcessor.run("test task")
104+
105+
const activityEvents = [
106+
["taskStarted"],
107+
["taskModeSwitched", "test-task-id", "code"],
108+
["taskPaused"],
109+
["taskUnpaused"],
110+
["taskAskResponded"],
111+
["taskSpawned", "new-task-id"],
112+
["taskTokenUsageUpdated", "test-task-id", { inputTokens: 100 }],
113+
["message", { action: "created" }],
114+
]
115+
116+
for (const event of activityEvents) {
117+
// Wait 59 seconds
118+
jest.advanceTimersByTime(59000)
119+
120+
// Trigger activity event
121+
mockTask.emit(event[0], ...event.slice(1))
122+
123+
// Should not timeout yet
124+
expect(runPromise).not.toBe(undefined)
125+
}
126+
127+
// Wait full timeout after last activity
128+
jest.advanceTimersByTime(60000)
129+
130+
await expect(runPromise).rejects.toThrow("Task execution timeout after 60000ms of inactivity")
131+
})
132+
133+
it("should clear timeout when task completes successfully", async () => {
134+
const runPromise = batchProcessor.run("test task")
135+
136+
// Wait 30 seconds
137+
jest.advanceTimersByTime(30000)
138+
139+
// Complete the task
140+
mockTask.emit("taskCompleted", "test-task-id", { inputTokens: 100 }, { toolName: 1 })
141+
142+
// Should resolve successfully
143+
await expect(runPromise).resolves.toBeUndefined()
144+
145+
// Advance timers to ensure no timeout occurs
146+
jest.advanceTimersByTime(120000)
147+
})
148+
149+
it("should clear timeout when task is aborted", async () => {
150+
const runPromise = batchProcessor.run("test task")
151+
152+
// Wait 30 seconds
153+
jest.advanceTimersByTime(30000)
154+
155+
// Abort the task
156+
mockTask.emit("taskAborted")
157+
158+
// Should reject with abort error
159+
await expect(runPromise).rejects.toThrow("Task was aborted")
160+
161+
// Advance timers to ensure no timeout occurs
162+
jest.advanceTimersByTime(120000)
163+
})
164+
165+
it("should clear timeout when tool fails", async () => {
166+
const runPromise = batchProcessor.run("test task")
167+
168+
// Wait 30 seconds
169+
jest.advanceTimersByTime(30000)
170+
171+
// Trigger tool failure
172+
mockTask.emit("taskToolFailed", "test-task-id", "read_file", "File not found")
173+
174+
// Should reject with tool failure error
175+
await expect(runPromise).rejects.toThrow("Tool read_file failed: File not found")
176+
177+
// Advance timers to ensure no timeout occurs
178+
jest.advanceTimersByTime(120000)
179+
})
180+
181+
it("should handle multiple rapid activity events correctly", async () => {
182+
const runPromise = batchProcessor.run("test task")
183+
184+
// Trigger multiple rapid events
185+
for (let i = 0; i < 10; i++) {
186+
jest.advanceTimersByTime(5000) // 5 seconds between events
187+
mockTask.emit("taskTokenUsageUpdated", "test-task-id", { inputTokens: 100 + i })
188+
}
189+
190+
// Total elapsed time: 50 seconds, but timeout should be reset to start from last event
191+
// Wait another 59 seconds (should not timeout)
192+
jest.advanceTimersByTime(59000)
193+
194+
// Should still be running
195+
expect(runPromise).not.toBe(undefined)
196+
197+
// Wait the final second to trigger timeout
198+
jest.advanceTimersByTime(1000)
199+
200+
await expect(runPromise).rejects.toThrow("Task execution timeout after 60000ms of inactivity")
201+
})
202+
203+
it("should not reset timeout for non-activity events", async () => {
204+
const runPromise = batchProcessor.run("test task")
205+
206+
// Wait 59 seconds
207+
jest.advanceTimersByTime(59000)
208+
209+
// Trigger a non-activity event that shouldn't reset timeout
210+
// (Note: In our current implementation, we don't have non-activity events that are listened to,
211+
// but this test ensures we're only listening to the right events)
212+
213+
// Wait 1 more second to trigger timeout
214+
jest.advanceTimersByTime(1000)
215+
216+
await expect(runPromise).rejects.toThrow("Task execution timeout after 60000ms of inactivity")
217+
})
218+
})
219+
220+
describe("error handling", () => {
221+
it("should handle configuration loading errors gracefully", async () => {
222+
mockConfigManager.loadConfiguration.mockRejectedValue(new Error("Config error"))
223+
224+
// Should fall back to environment variables
225+
process.env.ANTHROPIC_API_KEY = "env-key"
226+
227+
const runPromise = batchProcessor.run("test task")
228+
229+
// Should still proceed with fallback config
230+
expect(Task.create).toHaveBeenCalled()
231+
232+
// Clean up
233+
delete process.env.ANTHROPIC_API_KEY
234+
235+
// Complete the task to avoid hanging test
236+
mockTask.emit("taskCompleted", "test-task-id", {}, {})
237+
await expect(runPromise).resolves.toBeUndefined()
238+
})
239+
240+
it("should exit if no API key is available", async () => {
241+
mockConfigManager.loadConfiguration.mockRejectedValue(new Error("Config error"))
242+
delete process.env.ANTHROPIC_API_KEY
243+
delete process.env.ROO_API_KEY
244+
245+
const mockExit = jest.spyOn(process, "exit").mockImplementation(() => {
246+
throw new Error("process.exit called")
247+
})
248+
249+
await expect(batchProcessor.run("test task")).rejects.toThrow("process.exit called")
250+
251+
expect(mockExit).toHaveBeenCalledWith(1)
252+
mockExit.mockRestore()
253+
})
254+
})
255+
})

0 commit comments

Comments
 (0)