Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/specs/agent-tooling-v2/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@

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

约束:
Expand Down
9 changes: 9 additions & 0 deletions docs/specs/process-tool/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ As an AI agent, I want to start a command in the background so that I can run lo
- Command returns immediately with a `sessionId` and `status: "running"`
- Process continues running after tool returns

### US-1.1: Foreground Yield To Background
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.

**Acceptance Criteria:**
- Foreground `exec` waits only until `yieldMs` (or the default yield window)
- If the command finishes within that window, it returns the normal foreground result
- If the command is still running after that window, the same process is kept alive and `exec` returns `status: "running"` with a `sessionId`
- The yielded session is manageable through `process`

### US-2: Monitor Background Output
As an AI agent, I want to poll the output of a background command so that I can monitor its progress.

Expand Down
1 change: 1 addition & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export default defineConfig({
)
},
optimizeDeps: {
exclude: ['markstream-vue', 'stream-monaco'],
include: [
'monaco-editor',
'axios'
Expand Down
3 changes: 2 additions & 1 deletion src/main/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ export const SESSION_EVENTS = {
ACTIVATED: 'session:activated',
DEACTIVATED: 'session:deactivated',
STATUS_CHANGED: 'session:status-changed',
COMPACTION_UPDATED: 'session:compaction-updated'
COMPACTION_UPDATED: 'session:compaction-updated',
PENDING_INPUTS_UPDATED: 'session:pending-inputs-updated'
}

// 系统相关事件
Expand Down
162 changes: 131 additions & 31 deletions src/main/lib/agentRuntime/backgroundExecSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import path from 'path'
import { nanoid } from 'nanoid'
import logger from '@shared/logger'
import { getShellEnvironment, getUserShell } from './shellEnvHelper'
import { terminateProcessTree } from './processTree'
import { resolveSessionDir } from './sessionPaths'

// Configuration with environment variable support
const getConfig = () => ({
const FOREGROUND_PREVIEW_CHARS = 12000

export const getBackgroundExecConfig = () => ({
backgroundMs: parseInt(process.env.PI_BASH_YIELD_MS || '10000', 10),
timeoutSec: parseInt(process.env.PI_BASH_TIMEOUT_SEC || '1800', 10),
cleanupMs: parseInt(process.env.PI_BASH_JOB_TTL_MS || '1800000', 10),
Expand All @@ -21,6 +24,8 @@ const getConfig = () => ({
offloadThresholdChars: 10000 // Offload to file when output exceeds this
})

const getConfig = getBackgroundExecConfig

export interface SessionMeta {
sessionId: string
command: string
Expand All @@ -31,8 +36,22 @@ export interface SessionMeta {
exitCode?: number
outputLength: number
offloaded: boolean
timedOut?: boolean
}

export interface SessionCompletionResult {
status: 'done' | 'error' | 'killed'
output: string
exitCode: number | null
offloaded: boolean
outputFilePath?: string
timedOut: boolean
}

export type WaitForCompletionOrYieldResult =
| { kind: 'running'; sessionId: string }
| { kind: 'completed'; result: SessionCompletionResult }

interface BackgroundSession {
sessionId: string
conversationId: string
Expand All @@ -54,6 +73,7 @@ interface BackgroundSession {
resolveClose: () => void
closeSettled: boolean
killTimeoutId?: NodeJS.Timeout
timedOut: boolean
}

interface StartSessionResult {
Expand All @@ -67,6 +87,7 @@ interface PollResult {
exitCode?: number
offloaded?: boolean
outputFilePath?: string
timedOut?: boolean
}

interface LogResult {
Expand All @@ -76,6 +97,7 @@ interface LogResult {
exitCode?: number
offloaded?: boolean
outputFilePath?: string
timedOut?: boolean
}

export class BackgroundExecSessionManager {
Expand All @@ -93,6 +115,7 @@ export class BackgroundExecSessionManager {
options?: {
timeout?: number
env?: Record<string, string>
outputPrefix?: string
}
): Promise<StartSessionResult> {
const config = getConfig()
Expand All @@ -105,7 +128,9 @@ export class BackgroundExecSessionManager {
fs.mkdirSync(sessionDir, { recursive: true })
}

const outputFilePath = sessionDir ? path.join(sessionDir, `bgexec_${sessionId}.log`) : null
const outputFilePath = sessionDir
? this.createOutputFilePath(sessionDir, sessionId, options?.outputPrefix)
: null

const child = spawn(shell, [...args, command], {
cwd,
Expand All @@ -114,6 +139,7 @@ export class BackgroundExecSessionManager {
...shellEnv,
...options?.env
},
detached: process.platform !== 'win32',
stdio: ['pipe', 'pipe', 'pipe']
})

Expand All @@ -140,7 +166,8 @@ export class BackgroundExecSessionManager {
stderrEof: false,
closePromise,
resolveClose,
closeSettled: false
closeSettled: false,
timedOut: false
}

this.setupOutputHandling(session, config)
Expand Down Expand Up @@ -176,7 +203,8 @@ export class BackgroundExecSessionManager {
pid: session.child.pid,
exitCode: session.exitCode,
outputLength: session.totalOutputLength,
offloaded: this.hasPersistedOutput(session, getConfig())
offloaded: this.hasPersistedOutput(session, getConfig()),
timedOut: session.timedOut
}))
}

Expand All @@ -195,7 +223,8 @@ export class BackgroundExecSessionManager {
output,
exitCode: session.exitCode,
offloaded: true,
outputFilePath: session.outputFilePath
outputFilePath: session.outputFilePath,
timedOut: session.timedOut
}
}

Expand All @@ -204,7 +233,8 @@ export class BackgroundExecSessionManager {
status: session.status,
output,
exitCode: session.exitCode,
offloaded: false
offloaded: false,
timedOut: session.timedOut
}
}

Expand Down Expand Up @@ -234,10 +264,65 @@ export class BackgroundExecSessionManager {
totalLength: session.totalOutputLength,
exitCode: session.exitCode,
offloaded: isOffloaded,
outputFilePath: session.outputFilePath || undefined
outputFilePath: session.outputFilePath || undefined,
timedOut: session.timedOut
}
}

async waitForCompletionOrYield(
conversationId: string,
sessionId: string,
yieldMs = getConfig().backgroundMs
): Promise<WaitForCompletionOrYieldResult> {
const session = this.getSession(conversationId, sessionId)
session.lastAccessedAt = Date.now()

if (session.status !== 'running') {
return {
kind: 'completed',
result: await this.getCompletionResult(conversationId, sessionId)
}
}

let yieldTimer: NodeJS.Timeout | null = null

try {
await Promise.race([
session.closePromise,
new Promise((resolve) => {
yieldTimer = setTimeout(resolve, Math.max(0, yieldMs))
})
])
} finally {
if (yieldTimer) {
clearTimeout(yieldTimer)
}
}

if (session.status !== 'running') {
return {
kind: 'completed',
result: await this.getCompletionResult(conversationId, sessionId)
}
}

return {
kind: 'running',
sessionId
}
}

async getCompletionResult(
conversationId: string,
sessionId: string,
previewChars = FOREGROUND_PREVIEW_CHARS
): Promise<SessionCompletionResult> {
const session = this.getSession(conversationId, sessionId)
session.lastAccessedAt = Date.now()
await this.waitForSessionDrain(session)
return this.buildCompletionResult(session, previewChars)
}

write(conversationId: string, sessionId: string, data: string, eof = false): void {
const session = this.getSession(conversationId, sessionId)

Expand Down Expand Up @@ -446,31 +531,15 @@ export class BackgroundExecSessionManager {
clearTimeout(session.killTimeoutId)
}

const gracefulKill = new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
resolve()
}, 2000)

session.child.once('close', () => {
clearTimeout(timeout)
resolve()
})

try {
session.child.kill('SIGTERM')
} catch {
resolve()
}
})

await gracefulKill
if (reason === 'timeout') {
session.timedOut = true
}
session.status = 'killed'

if (session.status === 'running') {
try {
session.child.kill('SIGKILL')
} catch (error) {
logger.warn(`[BackgroundExec] Failed to force kill session ${session.sessionId}:`, error)
}
const closed = await terminateProcessTree(session.child, { graceMs: 2000 })
if (!closed && !session.closeSettled) {
session.exitCode = undefined
await this.finalizeSession(session, null, 'SIGKILL')
}

await session.closePromise
Expand Down Expand Up @@ -682,6 +751,37 @@ export class BackgroundExecSessionManager {
)
}

private buildCompletionResult(
session: BackgroundSession,
previewChars: number
): SessionCompletionResult {
const config = getConfig()
const offloaded = this.hasPersistedOutput(session, config)
const output =
offloaded && session.outputFilePath
? this.getRecentOutputFromSession(session, previewChars)
: this.getRecentOutput(session.outputBuffer, previewChars)

return {
status: session.status === 'running' ? 'killed' : session.status,
output,
exitCode: session.exitCode ?? null,
offloaded,
outputFilePath: session.outputFilePath || undefined,
timedOut: session.timedOut
}
}

private createOutputFilePath(
sessionDir: string,
sessionId: string,
outputPrefix?: string
): string {
const rawPrefix = outputPrefix?.trim() || 'bgexec'
const safePrefix = rawPrefix.replace(/[^a-zA-Z0-9_-]/g, '_')
return path.join(sessionDir, `${safePrefix}_${sessionId}.log`)
}

private resolveUtf8ByteRange(
fd: number,
fileSize: number,
Expand Down
Loading
Loading