Skip to content

Commit cc06e2b

Browse files
authored
feat: steer and queue (#1372)
* feat(deepchat): add queue steer lane * feat(renderer): compact pending lane ui * fix(deepchat): harden queue and steer flows * fix(agent): harden exec yield cleanup * fix(agent): tighten exec lifecycle * refactor(chat): simplify numeric validation * chore: vite config
1 parent 3d7719c commit cc06e2b

50 files changed

Lines changed: 5271 additions & 691 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/specs/agent-tooling-v2/spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575

7676
| 工具 | 必填参数 | 可选参数 | 说明 |
7777
|---|---|---|---|
78-
| `exec` | `command: string` | `cwd?: string`, `timeoutMs?: number`, `background?: boolean`, `yieldMs?: number` | 命令执行;长任务建议后台|
78+
| `exec` | `command: string` | `cwd?: string`, `timeoutMs?: number`, `background?: boolean`, `yieldMs?: number` | 命令执行;前台仅等待 yield 窗口,超时后自动转后台并返回 `sessionId`|
7979
| `process` | `action: enum` | `sessionId?: string`, `offset?: number`, `limit?: number`, `data?: string`, `eof?: boolean` | 后台会话管理(list/poll/log/write/kill/clear/remove)。 |
8080

8181
约束:

docs/specs/process-tool/spec.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ As an AI agent, I want to start a command in the background so that I can run lo
1414
- Command returns immediately with a `sessionId` and `status: "running"`
1515
- Process continues running after tool returns
1616

17+
### US-1.1: Foreground Yield To Background
18+
As an AI agent, I want a foreground `exec` call to yield into a background session when it runs too long, so that the loop can continue without restarting the command.
19+
20+
**Acceptance Criteria:**
21+
- Foreground `exec` waits only until `yieldMs` (or the default yield window)
22+
- If the command finishes within that window, it returns the normal foreground result
23+
- If the command is still running after that window, the same process is kept alive and `exec` returns `status: "running"` with a `sessionId`
24+
- The yielded session is manageable through `process`
25+
1726
### US-2: Monitor Background Output
1827
As an AI agent, I want to poll the output of a background command so that I can monitor its progress.
1928

electron.vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export default defineConfig({
5656
)
5757
},
5858
optimizeDeps: {
59+
exclude: ['markstream-vue', 'stream-monaco'],
5960
include: [
6061
'monaco-editor',
6162
'axios'

src/main/events.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ export const SESSION_EVENTS = {
7878
ACTIVATED: 'session:activated',
7979
DEACTIVATED: 'session:deactivated',
8080
STATUS_CHANGED: 'session:status-changed',
81-
COMPACTION_UPDATED: 'session:compaction-updated'
81+
COMPACTION_UPDATED: 'session:compaction-updated',
82+
PENDING_INPUTS_UPDATED: 'session:pending-inputs-updated'
8283
}
8384

8485
// 系统相关事件

src/main/lib/agentRuntime/backgroundExecSessionManager.ts

Lines changed: 131 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import path from 'path'
44
import { nanoid } from 'nanoid'
55
import logger from '@shared/logger'
66
import { getShellEnvironment, getUserShell } from './shellEnvHelper'
7+
import { terminateProcessTree } from './processTree'
78
import { resolveSessionDir } from './sessionPaths'
89

910
// Configuration with environment variable support
10-
const getConfig = () => ({
11+
const FOREGROUND_PREVIEW_CHARS = 12000
12+
13+
export const getBackgroundExecConfig = () => ({
1114
backgroundMs: parseInt(process.env.PI_BASH_YIELD_MS || '10000', 10),
1215
timeoutSec: parseInt(process.env.PI_BASH_TIMEOUT_SEC || '1800', 10),
1316
cleanupMs: parseInt(process.env.PI_BASH_JOB_TTL_MS || '1800000', 10),
@@ -21,6 +24,8 @@ const getConfig = () => ({
2124
offloadThresholdChars: 10000 // Offload to file when output exceeds this
2225
})
2326

27+
const getConfig = getBackgroundExecConfig
28+
2429
export interface SessionMeta {
2530
sessionId: string
2631
command: string
@@ -31,8 +36,22 @@ export interface SessionMeta {
3136
exitCode?: number
3237
outputLength: number
3338
offloaded: boolean
39+
timedOut?: boolean
40+
}
41+
42+
export interface SessionCompletionResult {
43+
status: 'done' | 'error' | 'killed'
44+
output: string
45+
exitCode: number | null
46+
offloaded: boolean
47+
outputFilePath?: string
48+
timedOut: boolean
3449
}
3550

51+
export type WaitForCompletionOrYieldResult =
52+
| { kind: 'running'; sessionId: string }
53+
| { kind: 'completed'; result: SessionCompletionResult }
54+
3655
interface BackgroundSession {
3756
sessionId: string
3857
conversationId: string
@@ -54,6 +73,7 @@ interface BackgroundSession {
5473
resolveClose: () => void
5574
closeSettled: boolean
5675
killTimeoutId?: NodeJS.Timeout
76+
timedOut: boolean
5777
}
5878

5979
interface StartSessionResult {
@@ -67,6 +87,7 @@ interface PollResult {
6787
exitCode?: number
6888
offloaded?: boolean
6989
outputFilePath?: string
90+
timedOut?: boolean
7091
}
7192

7293
interface LogResult {
@@ -76,6 +97,7 @@ interface LogResult {
7697
exitCode?: number
7798
offloaded?: boolean
7899
outputFilePath?: string
100+
timedOut?: boolean
79101
}
80102

81103
export class BackgroundExecSessionManager {
@@ -93,6 +115,7 @@ export class BackgroundExecSessionManager {
93115
options?: {
94116
timeout?: number
95117
env?: Record<string, string>
118+
outputPrefix?: string
96119
}
97120
): Promise<StartSessionResult> {
98121
const config = getConfig()
@@ -105,7 +128,9 @@ export class BackgroundExecSessionManager {
105128
fs.mkdirSync(sessionDir, { recursive: true })
106129
}
107130

108-
const outputFilePath = sessionDir ? path.join(sessionDir, `bgexec_${sessionId}.log`) : null
131+
const outputFilePath = sessionDir
132+
? this.createOutputFilePath(sessionDir, sessionId, options?.outputPrefix)
133+
: null
109134

110135
const child = spawn(shell, [...args, command], {
111136
cwd,
@@ -114,6 +139,7 @@ export class BackgroundExecSessionManager {
114139
...shellEnv,
115140
...options?.env
116141
},
142+
detached: process.platform !== 'win32',
117143
stdio: ['pipe', 'pipe', 'pipe']
118144
})
119145

@@ -140,7 +166,8 @@ export class BackgroundExecSessionManager {
140166
stderrEof: false,
141167
closePromise,
142168
resolveClose,
143-
closeSettled: false
169+
closeSettled: false,
170+
timedOut: false
144171
}
145172

146173
this.setupOutputHandling(session, config)
@@ -176,7 +203,8 @@ export class BackgroundExecSessionManager {
176203
pid: session.child.pid,
177204
exitCode: session.exitCode,
178205
outputLength: session.totalOutputLength,
179-
offloaded: this.hasPersistedOutput(session, getConfig())
206+
offloaded: this.hasPersistedOutput(session, getConfig()),
207+
timedOut: session.timedOut
180208
}))
181209
}
182210

@@ -195,7 +223,8 @@ export class BackgroundExecSessionManager {
195223
output,
196224
exitCode: session.exitCode,
197225
offloaded: true,
198-
outputFilePath: session.outputFilePath
226+
outputFilePath: session.outputFilePath,
227+
timedOut: session.timedOut
199228
}
200229
}
201230

@@ -204,7 +233,8 @@ export class BackgroundExecSessionManager {
204233
status: session.status,
205234
output,
206235
exitCode: session.exitCode,
207-
offloaded: false
236+
offloaded: false,
237+
timedOut: session.timedOut
208238
}
209239
}
210240

@@ -234,10 +264,65 @@ export class BackgroundExecSessionManager {
234264
totalLength: session.totalOutputLength,
235265
exitCode: session.exitCode,
236266
offloaded: isOffloaded,
237-
outputFilePath: session.outputFilePath || undefined
267+
outputFilePath: session.outputFilePath || undefined,
268+
timedOut: session.timedOut
269+
}
270+
}
271+
272+
async waitForCompletionOrYield(
273+
conversationId: string,
274+
sessionId: string,
275+
yieldMs = getConfig().backgroundMs
276+
): Promise<WaitForCompletionOrYieldResult> {
277+
const session = this.getSession(conversationId, sessionId)
278+
session.lastAccessedAt = Date.now()
279+
280+
if (session.status !== 'running') {
281+
return {
282+
kind: 'completed',
283+
result: await this.getCompletionResult(conversationId, sessionId)
284+
}
285+
}
286+
287+
let yieldTimer: NodeJS.Timeout | null = null
288+
289+
try {
290+
await Promise.race([
291+
session.closePromise,
292+
new Promise((resolve) => {
293+
yieldTimer = setTimeout(resolve, Math.max(0, yieldMs))
294+
})
295+
])
296+
} finally {
297+
if (yieldTimer) {
298+
clearTimeout(yieldTimer)
299+
}
300+
}
301+
302+
if (session.status !== 'running') {
303+
return {
304+
kind: 'completed',
305+
result: await this.getCompletionResult(conversationId, sessionId)
306+
}
307+
}
308+
309+
return {
310+
kind: 'running',
311+
sessionId
238312
}
239313
}
240314

315+
async getCompletionResult(
316+
conversationId: string,
317+
sessionId: string,
318+
previewChars = FOREGROUND_PREVIEW_CHARS
319+
): Promise<SessionCompletionResult> {
320+
const session = this.getSession(conversationId, sessionId)
321+
session.lastAccessedAt = Date.now()
322+
await this.waitForSessionDrain(session)
323+
return this.buildCompletionResult(session, previewChars)
324+
}
325+
241326
write(conversationId: string, sessionId: string, data: string, eof = false): void {
242327
const session = this.getSession(conversationId, sessionId)
243328

@@ -446,31 +531,15 @@ export class BackgroundExecSessionManager {
446531
clearTimeout(session.killTimeoutId)
447532
}
448533

449-
const gracefulKill = new Promise<void>((resolve) => {
450-
const timeout = setTimeout(() => {
451-
resolve()
452-
}, 2000)
453-
454-
session.child.once('close', () => {
455-
clearTimeout(timeout)
456-
resolve()
457-
})
458-
459-
try {
460-
session.child.kill('SIGTERM')
461-
} catch {
462-
resolve()
463-
}
464-
})
465-
466-
await gracefulKill
534+
if (reason === 'timeout') {
535+
session.timedOut = true
536+
}
537+
session.status = 'killed'
467538

468-
if (session.status === 'running') {
469-
try {
470-
session.child.kill('SIGKILL')
471-
} catch (error) {
472-
logger.warn(`[BackgroundExec] Failed to force kill session ${session.sessionId}:`, error)
473-
}
539+
const closed = await terminateProcessTree(session.child, { graceMs: 2000 })
540+
if (!closed && !session.closeSettled) {
541+
session.exitCode = undefined
542+
await this.finalizeSession(session, null, 'SIGKILL')
474543
}
475544

476545
await session.closePromise
@@ -682,6 +751,37 @@ export class BackgroundExecSessionManager {
682751
)
683752
}
684753

754+
private buildCompletionResult(
755+
session: BackgroundSession,
756+
previewChars: number
757+
): SessionCompletionResult {
758+
const config = getConfig()
759+
const offloaded = this.hasPersistedOutput(session, config)
760+
const output =
761+
offloaded && session.outputFilePath
762+
? this.getRecentOutputFromSession(session, previewChars)
763+
: this.getRecentOutput(session.outputBuffer, previewChars)
764+
765+
return {
766+
status: session.status === 'running' ? 'killed' : session.status,
767+
output,
768+
exitCode: session.exitCode ?? null,
769+
offloaded,
770+
outputFilePath: session.outputFilePath || undefined,
771+
timedOut: session.timedOut
772+
}
773+
}
774+
775+
private createOutputFilePath(
776+
sessionDir: string,
777+
sessionId: string,
778+
outputPrefix?: string
779+
): string {
780+
const rawPrefix = outputPrefix?.trim() || 'bgexec'
781+
const safePrefix = rawPrefix.replace(/[^a-zA-Z0-9_-]/g, '_')
782+
return path.join(sessionDir, `${safePrefix}_${sessionId}.log`)
783+
}
784+
685785
private resolveUtf8ByteRange(
686786
fd: number,
687787
fileSize: number,

0 commit comments

Comments
 (0)