fix(agent): keep background sandbox tasks running
Auto-approve sandbox Shell only for host-bound task execution, stage native results within their owning session, and preserve active lease state on response stream loss. The repeated failures came from interactive approval in unattended runs and transport cleanup invalidating execution state; focused regressions cover both boundaries.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// Called only for native "asked" events. Explicit denials and host isolation
|
||||
// still run before this point; the selected round mode answers pending requests.
|
||||
export function canAutomaticallyApprove(mode: string, _action: string) {
|
||||
return mode === "always";
|
||||
export function canAutomaticallyApprove(mode: string, action: string, backgroundSandboxShell = false) {
|
||||
return mode === "always" || (backgroundSandboxShell && action === "shell");
|
||||
}
|
||||
|
||||
@@ -116,6 +116,47 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
||||
const toSse = (event: string, data: Record<string, unknown>) =>
|
||||
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
|
||||
export async function runBackgroundTaskExecution(input: {
|
||||
sessionId: string;
|
||||
credential: ProductCredential;
|
||||
model: string;
|
||||
workflow?: string;
|
||||
isRunning: () => boolean;
|
||||
}) {
|
||||
const { sessionId, credential, model, workflow } = input;
|
||||
const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`;
|
||||
await fetch(new URL("/api/v1/responses", baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: credential.authorization,
|
||||
"Content-Type": "application/json",
|
||||
"X-Project-Id": credential.projectId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversation: sessionId,
|
||||
model,
|
||||
stream: true,
|
||||
input: buildTaskExecutionPrompt(workflow),
|
||||
}),
|
||||
}).then(async (response) => {
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
while (!(await reader.read()).done) {
|
||||
// Drain the stream without retaining the model response in memory.
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (!input.isRunning()) clearOperationalTaskLease(sessionId);
|
||||
logger.error({ sessionId, status: response.status, executionContinues: input.isRunning() }, "background task execution failed");
|
||||
}
|
||||
}).catch((error) => {
|
||||
// A lost response stream does not end the host-owned execution. Its route
|
||||
// still renews/finalizes the lease and owns cleanup of the cached revision.
|
||||
if (!input.isRunning()) clearOperationalTaskLease(sessionId);
|
||||
logger.warn({ err: error, sessionId, executionContinues: input.isRunning() }, "background task response stream failed");
|
||||
});
|
||||
}
|
||||
|
||||
export const registerChatSessionRoutes = (
|
||||
chatRouter: Router,
|
||||
options: RegisterChatSessionRoutesOptions,
|
||||
@@ -183,35 +224,8 @@ export const registerChatSessionRoutes = (
|
||||
workflow?: string,
|
||||
) => {
|
||||
queueMicrotask(() => {
|
||||
const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`;
|
||||
void fetch(new URL("/api/v1/responses", baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: credential.authorization,
|
||||
"Content-Type": "application/json",
|
||||
"X-Project-Id": credential.projectId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversation: record.sessionId,
|
||||
model,
|
||||
stream: true,
|
||||
input: buildTaskExecutionPrompt(workflow),
|
||||
}),
|
||||
}).then(async (response) => {
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
while (!(await reader.read()).done) {
|
||||
// Drain the stream without retaining the model response in memory.
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
clearOperationalTaskLease(record.sessionId);
|
||||
logger.error({ sessionId: record.sessionId, status: response.status }, "background task execution failed");
|
||||
}
|
||||
}).catch((error) => {
|
||||
clearOperationalTaskLease(record.sessionId);
|
||||
logger.error({ err: error, sessionId: record.sessionId }, "background task execution request failed");
|
||||
});
|
||||
void runBackgroundTaskExecution({ sessionId: record.sessionId, credential, model, workflow,
|
||||
isRunning: () => activeRuns.get(record.sessionId)?.status === "running" });
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ type StreamPromptOptions = {
|
||||
message: string;
|
||||
model?: SupportedModel;
|
||||
approvalMode?: ApprovalMode;
|
||||
backgroundSandboxShell?: boolean;
|
||||
traceId?: string;
|
||||
projectId?: string;
|
||||
signal?: AbortSignal;
|
||||
@@ -148,6 +149,7 @@ export const streamPromptResponse = async ({
|
||||
message,
|
||||
model,
|
||||
approvalMode = "request",
|
||||
backgroundSandboxShell = false,
|
||||
traceId,
|
||||
projectId,
|
||||
signal,
|
||||
@@ -481,7 +483,7 @@ export const streamPromptResponse = async ({
|
||||
patterns: event.properties.patterns,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission);
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission, backgroundSandboxShell);
|
||||
emitProgress({
|
||||
id: `permission-${event.properties.id}`,
|
||||
phase: "permission",
|
||||
@@ -489,7 +491,9 @@ export const streamPromptResponse = async ({
|
||||
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
|
||||
detail:
|
||||
autoApprove
|
||||
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
|
||||
? approvalMode === "always"
|
||||
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
|
||||
: "后台任务的沙箱 Shell 已自动批准;沙箱与明确拒绝规则仍生效。"
|
||||
: buildPermissionDetail(event),
|
||||
});
|
||||
if (autoApprove) {
|
||||
@@ -529,7 +533,7 @@ export const streamPromptResponse = async ({
|
||||
resources: event.properties.resources,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action);
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action, backgroundSandboxShell);
|
||||
emitProgress({
|
||||
id: `permission-${event.properties.id}`,
|
||||
phase: "permission",
|
||||
@@ -537,7 +541,9 @@ export const streamPromptResponse = async ({
|
||||
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
|
||||
detail:
|
||||
autoApprove
|
||||
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
|
||||
? approvalMode === "always"
|
||||
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
|
||||
: "后台任务的沙箱 Shell 已自动批准;沙箱与明确拒绝规则仍生效。"
|
||||
: buildPermissionV2Detail(event),
|
||||
});
|
||||
if (autoApprove) {
|
||||
|
||||
@@ -997,6 +997,7 @@ export const registerChatStreamRoute = (
|
||||
message: preparedMessage,
|
||||
model: parsed.data.model,
|
||||
approvalMode: parsed.data.approval_mode,
|
||||
backgroundSandboxShell: sessionPurpose === "task_execution",
|
||||
traceId: requestContext.traceId,
|
||||
projectId: requestContext.projectId,
|
||||
signal: abortController.signal,
|
||||
|
||||
@@ -8,6 +8,8 @@ import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.
|
||||
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
import { executeSandboxCommand } from "../sandbox/landlockSandbox.js";
|
||||
import { setSandboxOwnership } from "../runtime/conversationWorkspace.js";
|
||||
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { nativeToolOutputDirectory, stageSessionToolOutputs } from "../sandbox/tool-output.js";
|
||||
|
||||
export const sandboxInput = z.object({
|
||||
session_id: z.string().min(1).max(128),
|
||||
@@ -37,6 +39,7 @@ export function isShellCommandDenied(command: string): boolean {
|
||||
export function registerSandboxToolBridge(app: Express, options: {
|
||||
internalToken: string;
|
||||
runtime: {
|
||||
messages: OpencodeRuntimeAdapter["messages"];
|
||||
ensureSessionWorkspace(sessionId: string): Promise<string>;
|
||||
approveSandboxShell(input: { sessionId: string; command: string; description?: string; callId: string; messageId: string }, signal: AbortSignal): Promise<void>;
|
||||
};
|
||||
@@ -61,6 +64,15 @@ export function registerSandboxToolBridge(app: Express, options: {
|
||||
sessionId: context.sessionId, command: parsed.data.command, description: parsed.data.description,
|
||||
callId: parsed.data.call_id, messageId: parsed.data.message_id,
|
||||
}, controller.signal);
|
||||
if (parsed.data.command.includes(`${nativeToolOutputDirectory()}/`)) {
|
||||
const files = await stageSessionToolOutputs(parsed.data.command, workspace, context.sessionId,
|
||||
await options.runtime.messages(context.sessionId, 100));
|
||||
if (files.length) {
|
||||
res.json({ outcome: "requires_session_path", files,
|
||||
detail: "命令尚未执行。当前会话的完整工具结果已复制到会话目录。请用返回的 shell_path 重新调用 Shell;relative_path 相对于会话 workspace,shell_path 相对于 Shell 的 files/ 工作目录。" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = await executeSandboxCommand(workspace, parsed.data.command, parsed.data.timeout, controller.signal);
|
||||
const files: Partial<Record<"stdout_file" | "stderr_file", string>> = {};
|
||||
for (const stream of ["stdout", "stderr"] as const) {
|
||||
|
||||
Reference in New Issue
Block a user