@pushpalsdev/cli 1.0.18 → 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pushpals-cli.js +291 -44
- package/package.json +1 -1
- package/runtime/configs/backend.toml +1 -1
- package/runtime/configs/default.toml +1 -1
- package/runtime/sandbox/apps/workerpals/.python-version +1 -0
- package/runtime/sandbox/apps/workerpals/Dockerfile.sandbox +71 -0
- package/runtime/sandbox/apps/workerpals/package.json +25 -0
- package/runtime/sandbox/apps/workerpals/pyproject.toml +8 -0
- package/runtime/sandbox/apps/workerpals/src/backends/backend_config.ts +119 -0
- package/runtime/sandbox/apps/workerpals/src/backends/miniswe/miniswe_executor.py +2029 -0
- package/runtime/sandbox/apps/workerpals/src/backends/miniswe_backend.ts +48 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/openai_codex_executor.py +1259 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/test_openai_codex_runtime_config.py +110 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex_backend.ts +67 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openhands/openhands_executor.py +563 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openhands_backend.ts +161 -0
- package/runtime/sandbox/apps/workerpals/src/backends/openhands_task_execute.ts +536 -0
- package/runtime/sandbox/apps/workerpals/src/backends/shared/executor_base.py +746 -0
- package/runtime/sandbox/apps/workerpals/src/backends/shared/test_settings_resolver.py +60 -0
- package/runtime/sandbox/apps/workerpals/src/backends/task_execute_registry.ts +21 -0
- package/runtime/sandbox/apps/workerpals/src/backends/types.ts +52 -0
- package/runtime/sandbox/apps/workerpals/src/common/execution_utils.ts +149 -0
- package/runtime/sandbox/apps/workerpals/src/common/executor_backend.ts +15 -0
- package/runtime/sandbox/apps/workerpals/src/common/generic_python_executor.ts +210 -0
- package/runtime/sandbox/apps/workerpals/src/common/logger.ts +65 -0
- package/runtime/sandbox/apps/workerpals/src/common/types.ts +9 -0
- package/runtime/sandbox/apps/workerpals/src/common/worktree_cleanup.ts +66 -0
- package/runtime/sandbox/apps/workerpals/src/context_manager.ts +45 -0
- package/runtime/sandbox/apps/workerpals/src/docker_executor.ts +1842 -0
- package/runtime/sandbox/apps/workerpals/src/execute_job.ts +3063 -0
- package/runtime/sandbox/apps/workerpals/src/job_runner.ts +194 -0
- package/runtime/sandbox/apps/workerpals/src/shell_manager.ts +210 -0
- package/runtime/sandbox/apps/workerpals/src/timeout_policy.ts +24 -0
- package/runtime/sandbox/apps/workerpals/src/workerpals_main.ts +1436 -0
- package/runtime/sandbox/apps/workerpals/tsconfig.json +15 -0
- package/runtime/sandbox/apps/workerpals/uv.lock +2014 -0
- package/runtime/sandbox/bun.lock +2591 -0
- package/runtime/sandbox/configs/backend.toml +79 -0
- package/runtime/sandbox/configs/default.toml +260 -0
- package/runtime/sandbox/configs/dev.toml +2 -0
- package/runtime/sandbox/configs/local.example.toml +129 -0
- package/runtime/sandbox/package.json +65 -0
- package/runtime/sandbox/packages/protocol/README.md +168 -0
- package/runtime/sandbox/packages/protocol/package.json +37 -0
- package/runtime/sandbox/packages/protocol/scripts/copy-schemas.js +17 -0
- package/runtime/sandbox/packages/protocol/src/a2a/README.md +52 -0
- package/runtime/sandbox/packages/protocol/src/a2a/mapping.ts +55 -0
- package/runtime/sandbox/packages/protocol/src/index.browser.ts +25 -0
- package/runtime/sandbox/packages/protocol/src/index.ts +25 -0
- package/runtime/sandbox/packages/protocol/src/schemas/approvals.schema.json +6 -0
- package/runtime/sandbox/packages/protocol/src/schemas/envelope.schema.json +96 -0
- package/runtime/sandbox/packages/protocol/src/schemas/events.schema.json +679 -0
- package/runtime/sandbox/packages/protocol/src/schemas/http.schema.json +50 -0
- package/runtime/sandbox/packages/protocol/src/types.ts +267 -0
- package/runtime/sandbox/packages/protocol/src/validate.browser.ts +154 -0
- package/runtime/sandbox/packages/protocol/src/validate.ts +233 -0
- package/runtime/sandbox/packages/protocol/src/version.ts +1 -0
- package/runtime/sandbox/packages/protocol/tsconfig.json +20 -0
- package/runtime/sandbox/packages/shared/package.json +19 -0
- package/runtime/sandbox/packages/shared/src/autonomy_policy.ts +400 -0
- package/runtime/sandbox/packages/shared/src/client_preflight.ts +286 -0
- package/runtime/sandbox/packages/shared/src/communication.ts +313 -0
- package/runtime/sandbox/packages/shared/src/config.ts +2180 -0
- package/runtime/sandbox/packages/shared/src/config_template_parity.ts +70 -0
- package/runtime/sandbox/packages/shared/src/git_backend.ts +205 -0
- package/runtime/sandbox/packages/shared/src/index.ts +101 -0
- package/runtime/sandbox/packages/shared/src/local_network.ts +101 -0
- package/runtime/sandbox/packages/shared/src/localbuddy_runtime.ts +314 -0
- package/runtime/sandbox/packages/shared/src/prompts.ts +64 -0
- package/runtime/sandbox/packages/shared/src/repo.ts +134 -0
- package/runtime/sandbox/packages/shared/src/session_event_visibility.ts +25 -0
- package/runtime/sandbox/packages/shared/src/vision.ts +247 -0
- package/runtime/sandbox/packages/shared/tsconfig.json +16 -0
- package/runtime/sandbox/prompts/workerpals/codex_quality_critic_instruction_prompt.md +14 -0
- package/runtime/sandbox/prompts/workerpals/commit_message_prompt.md +36 -0
- package/runtime/sandbox/prompts/workerpals/commit_message_user_prompt.md +7 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_broker_system_prompt.md +33 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_broker_task_prompt.md +5 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_completion_requirement.md +1 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_context_compaction_retry_prompt.md +1 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_explicit_targets_block.md +2 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_base.md +4 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_blocker_line.md +1 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_strict_tool_use_guidance.md +6 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_supplemental_guidance_section.md +2 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_timeout_note.md +1 -0
- package/runtime/sandbox/prompts/workerpals/miniswe_toolcall_retry_guidance.md +1 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_default_system_prompt.md +4 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_instruction_wrapper.md +5 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_runtime_policy_appendix.md +5 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_supplemental_guidance_section.md +2 -0
- package/runtime/sandbox/prompts/workerpals/openai_codex_task_execute_system_prompt.md +12 -0
- package/runtime/sandbox/prompts/workerpals/openhands_minimal_security_policy.j2 +8 -0
- package/runtime/sandbox/prompts/workerpals/openhands_minimal_system_prompt.j2 +20 -0
- package/runtime/sandbox/prompts/workerpals/openhands_strict_tool_use_message.md +1 -0
- package/runtime/sandbox/prompts/workerpals/openhands_supplemental_guidance_message.md +2 -0
- package/runtime/sandbox/prompts/workerpals/openhands_task_execute_fallback_system_prompt.md +1 -0
- package/runtime/sandbox/prompts/workerpals/openhands_task_execute_system_prompt.md +21 -0
- package/runtime/sandbox/prompts/workerpals/openhands_task_user_prompt.md +6 -0
- package/runtime/sandbox/prompts/workerpals/openhands_timeout_note.md +1 -0
- package/runtime/sandbox/prompts/workerpals/pr_description.md +42 -0
- package/runtime/sandbox/prompts/workerpals/task_quality_critic_system_prompt.md +9 -0
- package/runtime/sandbox/prompts/workerpals/task_quality_critic_user_prompt.md +17 -0
- package/runtime/sandbox/prompts/workerpals/workerpals_system_prompt.md +115 -0
- package/runtime/sandbox/protocol/schemas/approvals.schema.json +6 -0
- package/runtime/sandbox/protocol/schemas/envelope.schema.json +96 -0
- package/runtime/sandbox/protocol/schemas/events.schema.json +679 -0
- package/runtime/sandbox/protocol/schemas/http.schema.json +50 -0
|
@@ -0,0 +1,1842 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DockerExecutor - Runs jobs inside Docker containers with git worktree isolation
|
|
3
|
+
*
|
|
4
|
+
* This executor:
|
|
5
|
+
* 1. Creates isolated git worktrees for each job
|
|
6
|
+
* 2. Runs jobs in a warm Docker container mounting the repo root
|
|
7
|
+
* 3. Parses structured output from the container
|
|
8
|
+
* 4. Cleans up worktrees after execution
|
|
9
|
+
*
|
|
10
|
+
* Architecture:
|
|
11
|
+
* HOST: Worker daemon → git worktree add → docker exec (warm container) → git worktree remove
|
|
12
|
+
* CONTAINER: job_runner.ts → executeJob → git commit/push → ___RESULT___
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from "crypto";
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
17
|
+
import { homedir } from "os";
|
|
18
|
+
import { isAbsolute, relative, resolve } from "path";
|
|
19
|
+
import { loadPushPalsConfig } from "shared";
|
|
20
|
+
import { resolveExecutor, type WorkerpalsRuntimeConfig } from "./common/executor_backend.js";
|
|
21
|
+
import type { ExecutorBackend } from "./common/types.js";
|
|
22
|
+
import { computeTimeoutWarningWindow, DEFAULT_DOCKER_TIMEOUT_MS } from "./timeout_policy.js";
|
|
23
|
+
import {
|
|
24
|
+
BACKEND_DOCKER_PASSTHROUGH_ENV,
|
|
25
|
+
BACKEND_RUNTIME_CONFIG_KEYS,
|
|
26
|
+
DOCKER_BACKENDS,
|
|
27
|
+
SHARED_DOCKER_PASSTHROUGH_ENV,
|
|
28
|
+
getDockerBackendSpec,
|
|
29
|
+
} from "./backends/backend_config.js";
|
|
30
|
+
import { forceDeleteWorktreePath } from "./common/worktree_cleanup.js";
|
|
31
|
+
import type {
|
|
32
|
+
DockerBackendRuntimeConfig,
|
|
33
|
+
DockerBackendSpec,
|
|
34
|
+
DockerWarmShellResult,
|
|
35
|
+
DockerWarmStartupContext,
|
|
36
|
+
} from "./backends/types.js";
|
|
37
|
+
|
|
38
|
+
const DEFAULT_OPENHANDS_MODEL = "local-model";
|
|
39
|
+
const DEFAULT_CONFIG = loadPushPalsConfig();
|
|
40
|
+
const SHARED_CONTAINER_VENV_PYTHON = "/workspace/.venv/bin/python";
|
|
41
|
+
const WORKERPAL_SANDBOX_RUNTIME_TAG_LABEL = "pushpals.runtime_tag";
|
|
42
|
+
const WORKERPAL_SANDBOX_COMPONENT_LABEL = "pushpals.component=workerpals-sandbox";
|
|
43
|
+
|
|
44
|
+
function parseClampedInt(value: unknown, defaultValue: number, min: number, max: number): number {
|
|
45
|
+
const parsed =
|
|
46
|
+
typeof value === "number"
|
|
47
|
+
? Math.floor(value)
|
|
48
|
+
: typeof value === "string"
|
|
49
|
+
? Number.parseInt(value, 10)
|
|
50
|
+
: Number.NaN;
|
|
51
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return defaultValue;
|
|
52
|
+
return Math.max(min, Math.min(max, parsed));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseClampedIntAllowZero(value: unknown, defaultValue: number, max: number): number {
|
|
56
|
+
const parsed =
|
|
57
|
+
typeof value === "number"
|
|
58
|
+
? Math.floor(value)
|
|
59
|
+
: typeof value === "string"
|
|
60
|
+
? Number.parseInt(value, 10)
|
|
61
|
+
: Number.NaN;
|
|
62
|
+
if (!Number.isFinite(parsed) || parsed < 0) return defaultValue;
|
|
63
|
+
return Math.max(0, Math.min(max, parsed));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function shellSingleQuote(value: string): string {
|
|
67
|
+
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveDockerExecutable(): string {
|
|
71
|
+
const absolute = String(process.env.PUSHPALS_DOCKER_BIN_ABSOLUTE ?? "").trim();
|
|
72
|
+
if (absolute) return absolute;
|
|
73
|
+
const configured = String(process.env.PUSHPALS_DOCKER_BIN ?? "").trim();
|
|
74
|
+
if (configured) return configured;
|
|
75
|
+
return process.platform === "win32" ? "docker.exe" : "docker";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveWorkerpalSandboxBuildContext(repoRoot: string): { root: string; dockerfilePath: string } {
|
|
79
|
+
const configuredRoot = String(process.env.PUSHPALS_WORKERPALS_SANDBOX_ROOT ?? "").trim();
|
|
80
|
+
const sandboxRoot = configuredRoot || repoRoot;
|
|
81
|
+
const dockerfilePath = configuredRoot
|
|
82
|
+
? resolve(sandboxRoot, "apps", "workerpals", "Dockerfile.sandbox")
|
|
83
|
+
: resolve(repoRoot, "apps", "workerpals", "Dockerfile.sandbox");
|
|
84
|
+
return {
|
|
85
|
+
root: sandboxRoot,
|
|
86
|
+
dockerfilePath,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveWorkerpalRuntimeTag(): string {
|
|
91
|
+
return String(process.env.PUSHPALS_RUNTIME_TAG ?? "").trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function dockerBuildFileArg(root: string, dockerfilePath: string): string {
|
|
95
|
+
const relativePath = relative(root, dockerfilePath).replace(/\\/g, "/").trim();
|
|
96
|
+
return relativePath || "apps/workerpals/Dockerfile.sandbox";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type ParsedWorktreeRecord = {
|
|
100
|
+
path: string;
|
|
101
|
+
detached: boolean;
|
|
102
|
+
prunable: boolean;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function normalizePathForMatching(path: string): string {
|
|
106
|
+
return path.replace(/\\/g, "/").toLowerCase();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isEphemeralWorkerWorktreePath(path: string): boolean {
|
|
110
|
+
const normalized = normalizePathForMatching(path);
|
|
111
|
+
const marker = "/.worktrees/";
|
|
112
|
+
const markerIndex = normalized.lastIndexOf(marker);
|
|
113
|
+
if (markerIndex < 0) return false;
|
|
114
|
+
const leaf = normalized.slice(markerIndex + marker.length);
|
|
115
|
+
return leaf.startsWith("job-") || leaf.startsWith("selfcheck-");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function parseGitWorktreeListPorcelain(output: string): ParsedWorktreeRecord[] {
|
|
119
|
+
const blocks = output
|
|
120
|
+
.split(/\r?\n\r?\n/g)
|
|
121
|
+
.map((block) => block.trim())
|
|
122
|
+
.filter(Boolean);
|
|
123
|
+
const records: ParsedWorktreeRecord[] = [];
|
|
124
|
+
|
|
125
|
+
for (const block of blocks) {
|
|
126
|
+
const lines = block.split(/\r?\n/g).map((line) => line.trim());
|
|
127
|
+
const pathLine = lines.find((line) => line.startsWith("worktree "));
|
|
128
|
+
if (!pathLine) continue;
|
|
129
|
+
records.push({
|
|
130
|
+
path: pathLine.slice("worktree ".length).trim(),
|
|
131
|
+
detached: lines.includes("detached"),
|
|
132
|
+
prunable: lines.some((line) => line === "prunable" || line.startsWith("prunable ")),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return records;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function collectPrunableEphemeralWorktrees(output: string): string[] {
|
|
140
|
+
return parseGitWorktreeListPorcelain(output)
|
|
141
|
+
.filter((entry) => entry.prunable && isEphemeralWorkerWorktreePath(entry.path))
|
|
142
|
+
.map((entry) => entry.path);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeMergeConflictHeadRef(value: unknown): string | null {
|
|
146
|
+
if (typeof value !== "string") return null;
|
|
147
|
+
const trimmed = value.trim();
|
|
148
|
+
if (!trimmed) return null;
|
|
149
|
+
const withoutRefs = trimmed.replace(/^refs\/heads\//, "");
|
|
150
|
+
const withoutOrigin = withoutRefs.replace(/^origin\//, "");
|
|
151
|
+
const normalized = withoutOrigin
|
|
152
|
+
.replace(/\\/g, "/")
|
|
153
|
+
.replace(/\/+/g, "/")
|
|
154
|
+
.replace(/^\/+|\/+$/g, "");
|
|
155
|
+
if (!normalized) return null;
|
|
156
|
+
if (
|
|
157
|
+
normalized.includes("..") ||
|
|
158
|
+
normalized.includes("@{") ||
|
|
159
|
+
normalized.endsWith(".") ||
|
|
160
|
+
normalized.endsWith(".lock")
|
|
161
|
+
) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (/[~^:?*\[\]\s]/.test(normalized)) return null;
|
|
165
|
+
return normalized;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class DockerExecutionExhaustedError extends Error {
|
|
169
|
+
readonly cooldownMs: number;
|
|
170
|
+
readonly category: "warm_setup" | "job_execution";
|
|
171
|
+
|
|
172
|
+
constructor(category: "warm_setup" | "job_execution", message: string, cooldownMs: number) {
|
|
173
|
+
super(message);
|
|
174
|
+
this.name = "DockerExecutionExhaustedError";
|
|
175
|
+
this.category = category;
|
|
176
|
+
this.cooldownMs = Math.max(0, Math.floor(cooldownMs));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface DockerExecutorOptions {
|
|
181
|
+
/** Path to the git repository on the host */
|
|
182
|
+
repo: string;
|
|
183
|
+
/** Worker ID for naming */
|
|
184
|
+
workerId: string;
|
|
185
|
+
/** Docker image to use */
|
|
186
|
+
imageName: string;
|
|
187
|
+
/** Git token for pushing from container */
|
|
188
|
+
gitToken?: string;
|
|
189
|
+
/** Timeout in milliseconds */
|
|
190
|
+
timeoutMs?: number;
|
|
191
|
+
/** Idle shutdown timeout for the warm container in milliseconds */
|
|
192
|
+
idleTimeoutMs?: number;
|
|
193
|
+
/** Git ref used as the base for per-job worktrees */
|
|
194
|
+
baseRef?: string;
|
|
195
|
+
/** Docker network mode for warm container (e.g. bridge, none) */
|
|
196
|
+
networkMode?: string;
|
|
197
|
+
/** Shared runtime config loaded by worker entrypoint */
|
|
198
|
+
config?: WorkerpalsRuntimeConfig;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface DockerJobResult {
|
|
202
|
+
ok: boolean;
|
|
203
|
+
summary: string;
|
|
204
|
+
stdout?: string;
|
|
205
|
+
stderr?: string;
|
|
206
|
+
exitCode?: number;
|
|
207
|
+
cooldownMs?: number;
|
|
208
|
+
commit?: {
|
|
209
|
+
branch: string;
|
|
210
|
+
sha: string;
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface Job {
|
|
215
|
+
id: string;
|
|
216
|
+
taskId: string;
|
|
217
|
+
kind: string;
|
|
218
|
+
params: Record<string, unknown>;
|
|
219
|
+
sessionId: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export class DockerExecutor {
|
|
223
|
+
private options: Required<Omit<DockerExecutorOptions, "config">>;
|
|
224
|
+
private worktreeDir: string;
|
|
225
|
+
private warmContainerName: string;
|
|
226
|
+
private warmAgentPort = 39231;
|
|
227
|
+
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
228
|
+
private activeJobs = 0;
|
|
229
|
+
private readonly warmAgentStartupTimeoutMs: number;
|
|
230
|
+
private readonly warmAgentStartupPollMs: number = 200;
|
|
231
|
+
private readonly warmSetupMaxAttempts: number;
|
|
232
|
+
private readonly warmSetupBackoffMs: number;
|
|
233
|
+
private readonly jobRetryMaxAttempts: number;
|
|
234
|
+
private readonly jobRetryBackoffMs: number;
|
|
235
|
+
private readonly failureCooldownMs: number;
|
|
236
|
+
private lastLoggedExecutionConfig = "";
|
|
237
|
+
private lastLoggedEndpointRewrite = "";
|
|
238
|
+
private warmedBackends = new Set<string>();
|
|
239
|
+
private mergeConflictRefreshPromise: Promise<void> | null = null;
|
|
240
|
+
private readonly config: WorkerpalsRuntimeConfig;
|
|
241
|
+
|
|
242
|
+
constructor(options: DockerExecutorOptions) {
|
|
243
|
+
const { config, ...optionValues } = options;
|
|
244
|
+
this.config = config ?? DEFAULT_CONFIG;
|
|
245
|
+
const startupTimeoutMs = parseClampedInt(
|
|
246
|
+
this.config.workerpals.dockerAgentStartupTimeoutMs,
|
|
247
|
+
45_000,
|
|
248
|
+
10_000,
|
|
249
|
+
180_000,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
this.options = {
|
|
253
|
+
gitToken: "",
|
|
254
|
+
// Keep headroom above backend wrapper timeout so the wrapper can emit
|
|
255
|
+
// a structured timeout failure before Docker hard-kills the job.
|
|
256
|
+
timeoutMs: DEFAULT_DOCKER_TIMEOUT_MS,
|
|
257
|
+
idleTimeoutMs: 10 * 60 * 1000,
|
|
258
|
+
baseRef: "HEAD",
|
|
259
|
+
networkMode: "bridge",
|
|
260
|
+
...optionValues,
|
|
261
|
+
};
|
|
262
|
+
this.worktreeDir = resolve(this.options.repo, ".worktrees");
|
|
263
|
+
this.warmContainerName = `pushpals-${this.options.workerId}-warm`;
|
|
264
|
+
this.warmAgentStartupTimeoutMs = startupTimeoutMs;
|
|
265
|
+
this.warmSetupMaxAttempts = parseClampedInt(
|
|
266
|
+
this.config.workerpals.dockerWarmMaxAttempts,
|
|
267
|
+
3,
|
|
268
|
+
1,
|
|
269
|
+
5,
|
|
270
|
+
);
|
|
271
|
+
this.warmSetupBackoffMs = parseClampedInt(
|
|
272
|
+
this.config.workerpals.dockerWarmRetryBackoffMs,
|
|
273
|
+
2_000,
|
|
274
|
+
250,
|
|
275
|
+
60_000,
|
|
276
|
+
);
|
|
277
|
+
this.jobRetryMaxAttempts = parseClampedInt(
|
|
278
|
+
this.config.workerpals.dockerJobMaxAttempts,
|
|
279
|
+
2,
|
|
280
|
+
1,
|
|
281
|
+
3,
|
|
282
|
+
);
|
|
283
|
+
this.jobRetryBackoffMs = parseClampedInt(
|
|
284
|
+
this.config.workerpals.dockerJobRetryBackoffMs,
|
|
285
|
+
3_000,
|
|
286
|
+
250,
|
|
287
|
+
60_000,
|
|
288
|
+
);
|
|
289
|
+
this.failureCooldownMs = parseClampedIntAllowZero(
|
|
290
|
+
this.config.workerpals.failureCooldownMs,
|
|
291
|
+
20_000,
|
|
292
|
+
300_000,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// Ensure worktrees directory exists
|
|
296
|
+
try {
|
|
297
|
+
mkdirSync(this.worktreeDir, { recursive: true });
|
|
298
|
+
} catch {
|
|
299
|
+
// Directory may already exist
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Execute a job in a Docker container with an isolated git worktree
|
|
305
|
+
*/
|
|
306
|
+
async execute(
|
|
307
|
+
job: Job,
|
|
308
|
+
onLog?: (stream: "stdout" | "stderr", line: string) => void,
|
|
309
|
+
): Promise<DockerJobResult> {
|
|
310
|
+
this.activeJobs += 1;
|
|
311
|
+
this.clearIdleTimer();
|
|
312
|
+
const worktreeName = this.buildEphemeralWorktreeName("job", job.id);
|
|
313
|
+
const worktreePath = resolve(this.worktreeDir, worktreeName);
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
await this.ensureFreshImageForMergeConflictJob(job, onLog);
|
|
317
|
+
const worktreeBaseRef = await this.resolveWorktreeBaseRefForJob(job, onLog);
|
|
318
|
+
// Step 1: Create isolated git worktree
|
|
319
|
+
await this.createWorktree(worktreePath, worktreeBaseRef);
|
|
320
|
+
|
|
321
|
+
// Step 2: Prepare job spec as base64
|
|
322
|
+
const jobSpec = {
|
|
323
|
+
jobId: job.id,
|
|
324
|
+
taskId: job.taskId,
|
|
325
|
+
kind: job.kind,
|
|
326
|
+
params: job.params,
|
|
327
|
+
workerId: this.options.workerId,
|
|
328
|
+
};
|
|
329
|
+
const base64Spec = Buffer.from(JSON.stringify(jobSpec)).toString("base64");
|
|
330
|
+
|
|
331
|
+
// Step 3: Run Docker container with the worktree mounted
|
|
332
|
+
for (let attempt = 1; attempt <= this.jobRetryMaxAttempts; attempt++) {
|
|
333
|
+
try {
|
|
334
|
+
this.logExecutionConfig();
|
|
335
|
+
const result = await this.runInWarmContainer(worktreePath, base64Spec, job, onLog);
|
|
336
|
+
if (result.ok) return result;
|
|
337
|
+
|
|
338
|
+
const retryableFailure = this.isRetryableJobFailure(result);
|
|
339
|
+
if (attempt >= this.jobRetryMaxAttempts || !retryableFailure) {
|
|
340
|
+
if (
|
|
341
|
+
retryableFailure &&
|
|
342
|
+
attempt >= this.jobRetryMaxAttempts &&
|
|
343
|
+
this.failureCooldownMs > 0
|
|
344
|
+
) {
|
|
345
|
+
return {
|
|
346
|
+
...result,
|
|
347
|
+
cooldownMs: this.failureCooldownMs,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const retryInMs = this.backoffDelayMs(this.jobRetryBackoffMs, attempt);
|
|
354
|
+
const note = `[DockerExecutor] Transient job failure detected for ${job.id}; retrying attempt ${
|
|
355
|
+
attempt + 1
|
|
356
|
+
}/${this.jobRetryMaxAttempts} in ${retryInMs}ms.`;
|
|
357
|
+
console.warn(note);
|
|
358
|
+
onLog?.("stderr", note);
|
|
359
|
+
await this.stopWarmContainer("job retry after transient failure", true);
|
|
360
|
+
await this.sleep(retryInMs);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
const retryableError = this.isRetryableError(err);
|
|
363
|
+
if (attempt >= this.jobRetryMaxAttempts || !retryableError) {
|
|
364
|
+
if (
|
|
365
|
+
retryableError &&
|
|
366
|
+
attempt >= this.jobRetryMaxAttempts &&
|
|
367
|
+
!(err instanceof DockerExecutionExhaustedError)
|
|
368
|
+
) {
|
|
369
|
+
throw new DockerExecutionExhaustedError(
|
|
370
|
+
"job_execution",
|
|
371
|
+
`Docker execution retries exhausted after ${this.jobRetryMaxAttempts} attempts: ${this.compactError(
|
|
372
|
+
err,
|
|
373
|
+
)}`,
|
|
374
|
+
this.failureCooldownMs,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
throw err;
|
|
378
|
+
}
|
|
379
|
+
const retryInMs = this.backoffDelayMs(this.jobRetryBackoffMs, attempt);
|
|
380
|
+
const note = `[DockerExecutor] Transient Docker execution error for ${job.id}: ${this.compactError(
|
|
381
|
+
err,
|
|
382
|
+
)}. Retrying attempt ${attempt + 1}/${this.jobRetryMaxAttempts} in ${retryInMs}ms.`;
|
|
383
|
+
console.warn(note);
|
|
384
|
+
onLog?.("stderr", note);
|
|
385
|
+
await this.stopWarmContainer("job retry after execution error", true);
|
|
386
|
+
await this.sleep(retryInMs);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
summary: "Docker job retries exhausted",
|
|
393
|
+
stderr: `Retries exhausted after ${this.jobRetryMaxAttempts} attempts`,
|
|
394
|
+
};
|
|
395
|
+
} finally {
|
|
396
|
+
this.activeJobs = Math.max(0, this.activeJobs - 1);
|
|
397
|
+
// Step 4: Clean up worktree (always cleanup)
|
|
398
|
+
await this.removeWorktree(worktreePath).catch((err) => {
|
|
399
|
+
console.error(`[DockerExecutor] Failed to remove worktree: ${err}`);
|
|
400
|
+
});
|
|
401
|
+
this.scheduleIdleShutdown();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Validate that a host-created worktree is usable by git inside the Linux
|
|
407
|
+
* worker container. This catches host/container path mapping issues early.
|
|
408
|
+
*/
|
|
409
|
+
async validateWorktreeGitInterop(): Promise<void> {
|
|
410
|
+
const worktreeName = this.buildEphemeralWorktreeName("selfcheck", "startup");
|
|
411
|
+
const worktreePath = resolve(this.worktreeDir, worktreeName);
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
await this.createWorktree(worktreePath, this.options.baseRef);
|
|
415
|
+
await this.runGitSelfCheckContainer(worktreePath);
|
|
416
|
+
console.log(`[DockerExecutor] Startup self-check passed (git/worktree in container).`);
|
|
417
|
+
} finally {
|
|
418
|
+
await this.removeWorktree(worktreePath).catch(() => {
|
|
419
|
+
// Ignore cleanup failures for startup self-check artifacts.
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Create a git worktree for isolated job execution
|
|
426
|
+
*/
|
|
427
|
+
private async createWorktree(worktreePath: string, baseRef: string): Promise<void> {
|
|
428
|
+
await this.ensureFreshWorktreePath(worktreePath);
|
|
429
|
+
|
|
430
|
+
// Create worktree from configured base ref (detached)
|
|
431
|
+
let proc = Bun.spawn(["git", "worktree", "add", "--detach", worktreePath, baseRef], {
|
|
432
|
+
cwd: this.options.repo,
|
|
433
|
+
stdout: "pipe",
|
|
434
|
+
stderr: "pipe",
|
|
435
|
+
});
|
|
436
|
+
let exitCode = await proc.exited;
|
|
437
|
+
let stdout = await new Response(proc.stdout).text();
|
|
438
|
+
let stderr = await new Response(proc.stderr).text();
|
|
439
|
+
let detail = [stderr, stdout].filter(Boolean).join("\n").trim();
|
|
440
|
+
|
|
441
|
+
if (exitCode !== 0 && /already registered worktree/i.test(detail)) {
|
|
442
|
+
const prune = Bun.spawn(["git", "worktree", "prune"], {
|
|
443
|
+
cwd: this.options.repo,
|
|
444
|
+
stdout: "pipe",
|
|
445
|
+
stderr: "pipe",
|
|
446
|
+
});
|
|
447
|
+
await prune.exited;
|
|
448
|
+
|
|
449
|
+
proc = Bun.spawn(
|
|
450
|
+
["git", "worktree", "add", "--force", "--detach", worktreePath, baseRef],
|
|
451
|
+
{
|
|
452
|
+
cwd: this.options.repo,
|
|
453
|
+
stdout: "pipe",
|
|
454
|
+
stderr: "pipe",
|
|
455
|
+
},
|
|
456
|
+
);
|
|
457
|
+
exitCode = await proc.exited;
|
|
458
|
+
stdout = await new Response(proc.stdout).text();
|
|
459
|
+
stderr = await new Response(proc.stderr).text();
|
|
460
|
+
detail = [stderr, stdout].filter(Boolean).join("\n").trim();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (exitCode !== 0) {
|
|
464
|
+
throw new Error(`Failed to create worktree from ${baseRef}: ${detail}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
this.rewriteWorktreeGitdirToRelative(worktreePath);
|
|
468
|
+
|
|
469
|
+
console.log(`[DockerExecutor] Created worktree: ${worktreePath}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* On Windows hosts, git worktree writes an absolute Windows path into
|
|
474
|
+
* `<worktree>/.git` (e.g. `C:/.../.git/worktrees/...`). That path is not
|
|
475
|
+
* valid inside Linux containers. Rewrite to a relative gitdir so both host
|
|
476
|
+
* and container can resolve it.
|
|
477
|
+
*/
|
|
478
|
+
private rewriteWorktreeGitdirToRelative(worktreePath: string): void {
|
|
479
|
+
try {
|
|
480
|
+
const gitFilePath = resolve(worktreePath, ".git");
|
|
481
|
+
const raw = readFileSync(gitFilePath, "utf-8").trim();
|
|
482
|
+
const match = raw.match(/^gitdir:\s*(.+)$/i);
|
|
483
|
+
if (!match) return;
|
|
484
|
+
|
|
485
|
+
const gitdirRaw = match[1].trim();
|
|
486
|
+
const hasWindowsDrive = /^[a-zA-Z]:[\\/]/.test(gitdirRaw);
|
|
487
|
+
if (!hasWindowsDrive && !isAbsolute(gitdirRaw)) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const rel = relative(worktreePath, gitdirRaw).replace(/\\/g, "/");
|
|
492
|
+
if (!rel || rel.startsWith("..") === false) {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
writeFileSync(gitFilePath, `gitdir: ${rel}\n`, "utf-8");
|
|
497
|
+
} catch {
|
|
498
|
+
// Best-effort normalization; if this fails, git commands will surface
|
|
499
|
+
// a concrete error during execution.
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Remove a git worktree
|
|
505
|
+
*/
|
|
506
|
+
private async removeWorktree(worktreePath: string): Promise<void> {
|
|
507
|
+
// Remove worktree
|
|
508
|
+
const proc = Bun.spawn(["git", "worktree", "remove", "--force", "--force", worktreePath], {
|
|
509
|
+
cwd: this.options.repo,
|
|
510
|
+
stdout: "pipe",
|
|
511
|
+
stderr: "pipe",
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const stdoutPromise = new Response(proc.stdout).text();
|
|
515
|
+
const stderrPromise = new Response(proc.stderr).text();
|
|
516
|
+
const exitCode = await proc.exited;
|
|
517
|
+
const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
|
|
518
|
+
|
|
519
|
+
if (exitCode !== 0) {
|
|
520
|
+
console.warn(`[DockerExecutor] Worktree removal warning: ${stderr || stdout}`);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Also prune worktree list
|
|
524
|
+
const prune = Bun.spawn(["git", "worktree", "prune"], {
|
|
525
|
+
cwd: this.options.repo,
|
|
526
|
+
stdout: "pipe",
|
|
527
|
+
stderr: "pipe",
|
|
528
|
+
});
|
|
529
|
+
const pruneExit = await prune.exited;
|
|
530
|
+
if (pruneExit !== 0) {
|
|
531
|
+
const pruneStderr = await new Response(prune.stderr).text();
|
|
532
|
+
console.warn(`[DockerExecutor] Worktree prune warning: ${pruneStderr}`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const forced = await forceDeleteWorktreePath(worktreePath, {
|
|
536
|
+
sleepFn: (ms) => this.sleep(ms),
|
|
537
|
+
});
|
|
538
|
+
if (!forced.removed) {
|
|
539
|
+
throw new Error(
|
|
540
|
+
`worktree path persisted after cleanup (${worktreePath})${forced.lastError ? `: ${forced.lastError}` : ""}`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
console.log(`[DockerExecutor] Removed worktree: ${worktreePath}`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Run the Docker container and parse output
|
|
549
|
+
*/
|
|
550
|
+
private containerBackendPython(
|
|
551
|
+
backend: ExecutorBackend,
|
|
552
|
+
runtimeConfig: DockerBackendRuntimeConfig = this.backendRuntimeConfig(),
|
|
553
|
+
): string {
|
|
554
|
+
const spec = getDockerBackendSpec(backend);
|
|
555
|
+
const configured = spec.configuredPython(runtimeConfig);
|
|
556
|
+
return spec.normalizeContainerPython(configured, SHARED_CONTAINER_VENV_PYTHON);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private backendRuntimeConfig(): DockerBackendRuntimeConfig {
|
|
560
|
+
const workerCfg = this.config.workerpals as Record<string, unknown>;
|
|
561
|
+
const runtimeConfig: DockerBackendRuntimeConfig = {};
|
|
562
|
+
for (const backend of DOCKER_BACKENDS) {
|
|
563
|
+
const keys = BACKEND_RUNTIME_CONFIG_KEYS[backend.name] ?? {
|
|
564
|
+
pythonKey: `${backend.name}Python`,
|
|
565
|
+
timeoutKey: `${backend.name}TimeoutMs`,
|
|
566
|
+
};
|
|
567
|
+
const python = String(workerCfg[keys.pythonKey] ?? "python").trim() || "python";
|
|
568
|
+
const timeoutRaw = Number(workerCfg[keys.timeoutKey]);
|
|
569
|
+
const timeoutMs = Number.isFinite(timeoutRaw)
|
|
570
|
+
? Math.max(10_000, Math.floor(timeoutRaw))
|
|
571
|
+
: 300_000;
|
|
572
|
+
runtimeConfig[backend.name] = { python, timeoutMs };
|
|
573
|
+
}
|
|
574
|
+
return runtimeConfig;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private currentBackend(): ExecutorBackend {
|
|
578
|
+
return resolveExecutor(this.config);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private currentBackendSpec(): DockerBackendSpec {
|
|
582
|
+
return getDockerBackendSpec(this.currentBackend());
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private warmStartupContext(): DockerWarmStartupContext {
|
|
586
|
+
const { attempts, sleepSeconds } = this.warmAgentStartupLoop();
|
|
587
|
+
return {
|
|
588
|
+
sharedVenvPython: SHARED_CONTAINER_VENV_PYTHON,
|
|
589
|
+
warmAgentPort: this.warmAgentPort,
|
|
590
|
+
startupAttempts: attempts,
|
|
591
|
+
sleepSeconds,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private collectContainerEnv(): string[] {
|
|
596
|
+
const containerLlmEndpoint = this.workerLlmEndpointForContainer();
|
|
597
|
+
const runtimeConfig = this.backendRuntimeConfig();
|
|
598
|
+
const fixedEnv: Record<string, string> = {
|
|
599
|
+
WORKERPALS_EXECUTOR: this.config.workerpals.executor,
|
|
600
|
+
WORKERPALS_LLM_MODEL: this.config.workerpals.llm.model,
|
|
601
|
+
WORKERPALS_LLM_ENDPOINT: containerLlmEndpoint,
|
|
602
|
+
WORKERPALS_LLM_BACKEND: this.config.workerpals.llm.backend,
|
|
603
|
+
WORKERPALS_LLM_SESSION_ID: this.config.workerpals.llm.sessionId,
|
|
604
|
+
PUSHPALS_PROJECT_ROOT_OVERRIDE: "/repo",
|
|
605
|
+
PUSHPALS_REPO_ROOT_OVERRIDE: "/repo",
|
|
606
|
+
PUSHPALS_CONFIG_DIR_OVERRIDE: "/workspace/configs",
|
|
607
|
+
PUSHPALS_PROMPTS_ROOT_OVERRIDE: "/workspace",
|
|
608
|
+
PUSHPALS_PROTOCOL_SCHEMAS_DIR: "/workspace/protocol/schemas",
|
|
609
|
+
};
|
|
610
|
+
for (const backend of DOCKER_BACKENDS) {
|
|
611
|
+
const name = backend.name.toUpperCase();
|
|
612
|
+
fixedEnv[`WORKERPALS_${name}_PYTHON`] = this.containerBackendPython(
|
|
613
|
+
backend.name,
|
|
614
|
+
runtimeConfig,
|
|
615
|
+
);
|
|
616
|
+
fixedEnv[`WORKERPALS_${name}_TIMEOUT_MS`] = String(backend.timeoutMs(runtimeConfig));
|
|
617
|
+
}
|
|
618
|
+
if (this.config.workerpals.llm.apiKey.trim()) {
|
|
619
|
+
fixedEnv.WORKERPALS_LLM_API_KEY = this.config.workerpals.llm.apiKey;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const allowlist = new Set<string>(SHARED_DOCKER_PASSTHROUGH_ENV);
|
|
623
|
+
for (const backend of DOCKER_BACKENDS) {
|
|
624
|
+
const names = BACKEND_DOCKER_PASSTHROUGH_ENV[backend.name] ?? [];
|
|
625
|
+
for (const name of names) allowlist.add(name);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const pairs: string[] = [];
|
|
629
|
+
for (const [key, value] of Object.entries(fixedEnv)) {
|
|
630
|
+
if (!value) continue;
|
|
631
|
+
pairs.push("-e", `${key}=${value}`);
|
|
632
|
+
}
|
|
633
|
+
for (const key of allowlist) {
|
|
634
|
+
const value = process.env[key];
|
|
635
|
+
if (!value) continue;
|
|
636
|
+
pairs.push("-e", `${key}=${value}`);
|
|
637
|
+
}
|
|
638
|
+
return pairs;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private clearIdleTimer(): void {
|
|
642
|
+
if (!this.idleTimer) return;
|
|
643
|
+
clearTimeout(this.idleTimer);
|
|
644
|
+
this.idleTimer = null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private warmAgentStartupLoop(): { attempts: number; sleepSeconds: string } {
|
|
648
|
+
const attempts = Math.max(
|
|
649
|
+
1,
|
|
650
|
+
Math.ceil(this.warmAgentStartupTimeoutMs / this.warmAgentStartupPollMs),
|
|
651
|
+
);
|
|
652
|
+
const sleepSeconds = String(this.warmAgentStartupPollMs / 1000);
|
|
653
|
+
return { attempts, sleepSeconds };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private scheduleIdleShutdown(): void {
|
|
657
|
+
if (this.options.idleTimeoutMs <= 0) return;
|
|
658
|
+
if (this.activeJobs > 0) return;
|
|
659
|
+
|
|
660
|
+
this.clearIdleTimer();
|
|
661
|
+
this.idleTimer = setTimeout(() => {
|
|
662
|
+
if (this.activeJobs > 0) return;
|
|
663
|
+
void this.stopWarmContainer("idle timeout");
|
|
664
|
+
}, this.options.idleTimeoutMs);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private async startWarmContainer(): Promise<void> {
|
|
668
|
+
await this.stopWarmContainer("pre-start cleanup", true);
|
|
669
|
+
const backend = this.currentBackend();
|
|
670
|
+
const backendSpec = getDockerBackendSpec(backend);
|
|
671
|
+
const warmContext = this.warmStartupContext();
|
|
672
|
+
const dockerRepoPath = this.toDockerPath(this.options.repo);
|
|
673
|
+
const envArgs = this.collectContainerEnv();
|
|
674
|
+
const authMountArgs = this.openaiCodexAuthMountArgs(backend);
|
|
675
|
+
const args: string[] = [
|
|
676
|
+
"run",
|
|
677
|
+
"-d",
|
|
678
|
+
"--name",
|
|
679
|
+
this.warmContainerName,
|
|
680
|
+
"--label",
|
|
681
|
+
"pushpals.component=workerpals-warm",
|
|
682
|
+
"--label",
|
|
683
|
+
`pushpals.repo=${this.options.repo}`,
|
|
684
|
+
"--label",
|
|
685
|
+
`pushpals.worker_id=${this.options.workerId}`,
|
|
686
|
+
"--memory",
|
|
687
|
+
`${this.config.workerpals.dockerWarmMemoryMb}m`,
|
|
688
|
+
"--cpus",
|
|
689
|
+
String(this.config.workerpals.dockerWarmCpus),
|
|
690
|
+
"--network",
|
|
691
|
+
this.options.networkMode,
|
|
692
|
+
"--add-host",
|
|
693
|
+
"host.docker.internal:host-gateway",
|
|
694
|
+
"-v",
|
|
695
|
+
`${dockerRepoPath}:/repo`,
|
|
696
|
+
"-w",
|
|
697
|
+
// Keep agent-server runtime artifacts off the host-mounted repo path.
|
|
698
|
+
"/workspace",
|
|
699
|
+
...envArgs,
|
|
700
|
+
...authMountArgs,
|
|
701
|
+
];
|
|
702
|
+
|
|
703
|
+
if (this.options.gitToken) {
|
|
704
|
+
args.push("-e", `GIT_TOKEN=${this.options.gitToken}`);
|
|
705
|
+
}
|
|
706
|
+
const backendEnv = backendSpec.warmContainerEnv?.(warmContext) ?? {};
|
|
707
|
+
for (const [key, value] of Object.entries(backendEnv)) {
|
|
708
|
+
if (!value) continue;
|
|
709
|
+
args.push("-e", `${key}=${value}`);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const startupCmd = backendSpec.warmContainerStartupCommand(warmContext);
|
|
713
|
+
|
|
714
|
+
args.push("--entrypoint", "/bin/sh", this.options.imageName, "-lc", startupCmd);
|
|
715
|
+
|
|
716
|
+
const proc = Bun.spawn([resolveDockerExecutable(), ...args], { stdout: "pipe", stderr: "pipe" });
|
|
717
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
718
|
+
proc.exited,
|
|
719
|
+
new Response(proc.stdout).text(),
|
|
720
|
+
new Response(proc.stderr).text(),
|
|
721
|
+
]);
|
|
722
|
+
if (exitCode !== 0) {
|
|
723
|
+
throw new Error(
|
|
724
|
+
`Failed to start warm container (exit ${exitCode}): ${
|
|
725
|
+
stderr.trim() || stdout.trim() || "no docker output"
|
|
726
|
+
}`,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
console.log(`[DockerExecutor] Warm container started: ${this.warmContainerName}`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private openaiCodexAuthMountArgs(backend: ExecutorBackend): string[] {
|
|
733
|
+
if (backend !== "openai_codex") return [];
|
|
734
|
+
|
|
735
|
+
const hostCodexHomeRaw = (process.env.PUSHPALS_OPENAI_CODEX_HOST_CODEX_HOME || "").trim();
|
|
736
|
+
const hostCodexHome = (
|
|
737
|
+
hostCodexHomeRaw
|
|
738
|
+
? isAbsolute(hostCodexHomeRaw)
|
|
739
|
+
? hostCodexHomeRaw
|
|
740
|
+
: resolve(this.options.repo, hostCodexHomeRaw)
|
|
741
|
+
: resolve(homedir(), ".codex")
|
|
742
|
+
).trim();
|
|
743
|
+
if (!hostCodexHome) return [];
|
|
744
|
+
|
|
745
|
+
if (!existsSync(hostCodexHome)) {
|
|
746
|
+
try {
|
|
747
|
+
mkdirSync(hostCodexHome, { recursive: true });
|
|
748
|
+
} catch (err) {
|
|
749
|
+
console.warn(
|
|
750
|
+
`[DockerExecutor] Failed to create Codex auth directory (${hostCodexHome}); skipping mount: ${this.compactError(
|
|
751
|
+
err,
|
|
752
|
+
)}`,
|
|
753
|
+
);
|
|
754
|
+
return [];
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
let containerCodexHome = (
|
|
759
|
+
process.env.PUSHPALS_OPENAI_CODEX_CONTAINER_CODEX_HOME || "/root/.codex"
|
|
760
|
+
).trim();
|
|
761
|
+
if (!containerCodexHome.startsWith("/")) {
|
|
762
|
+
console.warn(
|
|
763
|
+
`[DockerExecutor] Invalid PUSHPALS_OPENAI_CODEX_CONTAINER_CODEX_HOME=${containerCodexHome}; expected absolute path. Using /root/.codex.`,
|
|
764
|
+
);
|
|
765
|
+
containerCodexHome = "/root/.codex";
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const dockerHostPath = this.toDockerPath(hostCodexHome);
|
|
769
|
+
console.log(
|
|
770
|
+
`[DockerExecutor] Mounting Codex auth directory for openai_codex: ${hostCodexHome} -> ${containerCodexHome}`,
|
|
771
|
+
);
|
|
772
|
+
return [
|
|
773
|
+
"-v",
|
|
774
|
+
`${dockerHostPath}:${containerCodexHome}`,
|
|
775
|
+
"-e",
|
|
776
|
+
`CODEX_HOME=${containerCodexHome}`,
|
|
777
|
+
];
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
private async ensureWarmContainer(): Promise<void> {
|
|
781
|
+
const inspect = Bun.spawn(
|
|
782
|
+
[
|
|
783
|
+
resolveDockerExecutable(),
|
|
784
|
+
"inspect",
|
|
785
|
+
"-f",
|
|
786
|
+
"{{.State.Running}}|{{.HostConfig.NetworkMode}}",
|
|
787
|
+
this.warmContainerName,
|
|
788
|
+
],
|
|
789
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
790
|
+
);
|
|
791
|
+
const [exitCode, stdout] = await Promise.all([
|
|
792
|
+
inspect.exited,
|
|
793
|
+
new Response(inspect.stdout).text(),
|
|
794
|
+
]);
|
|
795
|
+
if (exitCode === 0) {
|
|
796
|
+
const [runningRaw, networkModeRaw] = stdout.trim().split("|");
|
|
797
|
+
const running = runningRaw?.trim() === "true";
|
|
798
|
+
const networkMode = (networkModeRaw ?? "").trim();
|
|
799
|
+
if (running && networkMode === this.options.networkMode) {
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (running && networkMode && networkMode !== this.options.networkMode) {
|
|
803
|
+
console.warn(
|
|
804
|
+
`[DockerExecutor] Warm container network mismatch (${networkMode} != ${this.options.networkMode}); recreating...`,
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
await this.startWarmContainer();
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private async runWarmShell(command: string): Promise<{
|
|
812
|
+
ok: boolean;
|
|
813
|
+
stdout: string;
|
|
814
|
+
stderr: string;
|
|
815
|
+
exitCode: number;
|
|
816
|
+
}> {
|
|
817
|
+
const proc = Bun.spawn([resolveDockerExecutable(), "exec", this.warmContainerName, "/bin/sh", "-lc", command], {
|
|
818
|
+
stdout: "pipe",
|
|
819
|
+
stderr: "pipe",
|
|
820
|
+
});
|
|
821
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
822
|
+
new Response(proc.stdout).text(),
|
|
823
|
+
new Response(proc.stderr).text(),
|
|
824
|
+
proc.exited,
|
|
825
|
+
]);
|
|
826
|
+
return {
|
|
827
|
+
ok: exitCode === 0,
|
|
828
|
+
stdout: stdout.trim(),
|
|
829
|
+
stderr: stderr.trim(),
|
|
830
|
+
exitCode,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
private async inspectWarmContainerState(): Promise<string> {
|
|
835
|
+
const proc = Bun.spawn(
|
|
836
|
+
[
|
|
837
|
+
resolveDockerExecutable(),
|
|
838
|
+
"inspect",
|
|
839
|
+
"-f",
|
|
840
|
+
"running={{.State.Running}} status={{.State.Status}} exit={{.State.ExitCode}} started={{.State.StartedAt}} finished={{.State.FinishedAt}} oom={{.State.OOMKilled}}",
|
|
841
|
+
this.warmContainerName,
|
|
842
|
+
],
|
|
843
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
844
|
+
);
|
|
845
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
846
|
+
new Response(proc.stdout).text(),
|
|
847
|
+
new Response(proc.stderr).text(),
|
|
848
|
+
proc.exited,
|
|
849
|
+
]);
|
|
850
|
+
const out = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
|
851
|
+
return exitCode === 0
|
|
852
|
+
? out || "no inspect output"
|
|
853
|
+
: `docker inspect failed (exit ${exitCode})${out ? `\n${out}` : ""}`;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
private async readWarmContainerLogs(tail = 160): Promise<string> {
|
|
857
|
+
const proc = Bun.spawn([resolveDockerExecutable(), "logs", "--tail", String(tail), this.warmContainerName], {
|
|
858
|
+
stdout: "pipe",
|
|
859
|
+
stderr: "pipe",
|
|
860
|
+
});
|
|
861
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
862
|
+
new Response(proc.stdout).text(),
|
|
863
|
+
new Response(proc.stderr).text(),
|
|
864
|
+
proc.exited,
|
|
865
|
+
]);
|
|
866
|
+
const out = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
|
867
|
+
return exitCode === 0
|
|
868
|
+
? out || "(no docker logs)"
|
|
869
|
+
: `docker logs failed (exit ${exitCode})${out ? `\n${out}` : ""}`;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private workerLlmProbeUrls(endpoint: string): string[] {
|
|
873
|
+
const normalized = endpoint.trim().replace(/\/+$/, "");
|
|
874
|
+
if (!normalized) return [];
|
|
875
|
+
const probes: string[] = [];
|
|
876
|
+
if (normalized.includes("/v1/chat/completions")) {
|
|
877
|
+
probes.push(normalized.replace(/\/v1\/chat\/completions$/, "/v1/models"));
|
|
878
|
+
} else if (normalized.endsWith("/api/chat")) {
|
|
879
|
+
probes.push(normalized.replace(/\/api\/chat$/, "/api/tags"));
|
|
880
|
+
} else if (normalized.includes("/chat/completions")) {
|
|
881
|
+
probes.push(normalized.replace(/\/chat\/completions$/, "/models"));
|
|
882
|
+
} else if (normalized.endsWith("/v1")) {
|
|
883
|
+
probes.push(`${normalized}/models`);
|
|
884
|
+
} else if (/^https?:\/\/[^/]+$/i.test(normalized)) {
|
|
885
|
+
probes.push(`${normalized}/v1/models`);
|
|
886
|
+
probes.push(`${normalized}/models`);
|
|
887
|
+
}
|
|
888
|
+
if (probes.length === 0) {
|
|
889
|
+
probes.push(normalized);
|
|
890
|
+
}
|
|
891
|
+
try {
|
|
892
|
+
const parsed = new URL(normalized);
|
|
893
|
+
probes.push(`${parsed.origin}/health`);
|
|
894
|
+
} catch {
|
|
895
|
+
// leave parsed probes empty
|
|
896
|
+
}
|
|
897
|
+
return Array.from(new Set(probes));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
private async probeWorkerLlmEndpoint(): Promise<string> {
|
|
901
|
+
const endpoint = (this.config.workerpals.llm.endpoint ?? "").trim();
|
|
902
|
+
if (!endpoint) return "endpoint not configured";
|
|
903
|
+
const probes = this.workerLlmProbeUrls(endpoint);
|
|
904
|
+
if (probes.length === 0) return `endpoint malformed: ${endpoint}`;
|
|
905
|
+
|
|
906
|
+
let lastError = "unreachable";
|
|
907
|
+
for (const probe of probes) {
|
|
908
|
+
const controller = new AbortController();
|
|
909
|
+
const timeout = setTimeout(() => controller.abort("timeout"), 2_500);
|
|
910
|
+
try {
|
|
911
|
+
const response = await fetch(probe, {
|
|
912
|
+
method: "GET",
|
|
913
|
+
signal: controller.signal,
|
|
914
|
+
headers: { Accept: "application/json, text/plain, */*" },
|
|
915
|
+
});
|
|
916
|
+
if (response.status >= 200 && response.status < 500) {
|
|
917
|
+
return `reachable via ${probe} (HTTP ${response.status})`;
|
|
918
|
+
}
|
|
919
|
+
lastError = `${probe}: HTTP ${response.status}`;
|
|
920
|
+
} catch (err) {
|
|
921
|
+
lastError = `${probe}: ${String(err)}`;
|
|
922
|
+
} finally {
|
|
923
|
+
clearTimeout(timeout);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return `UNREACHABLE (${lastError})`;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
private workerLlmEndpointForContainer(): string {
|
|
930
|
+
const raw = (this.config.workerpals.llm.endpoint ?? "").trim();
|
|
931
|
+
if (!raw) return raw;
|
|
932
|
+
try {
|
|
933
|
+
const parsed = new URL(raw);
|
|
934
|
+
const host = (parsed.hostname ?? "").trim().toLowerCase();
|
|
935
|
+
if (host !== "localhost" && host !== "127.0.0.1" && host !== "::1") {
|
|
936
|
+
return raw;
|
|
937
|
+
}
|
|
938
|
+
parsed.hostname = "host.docker.internal";
|
|
939
|
+
return parsed.toString();
|
|
940
|
+
} catch {
|
|
941
|
+
return raw;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
private async probeWorkerLlmEndpointFromContainer(): Promise<string> {
|
|
946
|
+
const endpoint = this.workerLlmEndpointForContainer();
|
|
947
|
+
if (!endpoint) return "endpoint not configured";
|
|
948
|
+
const probes = this.workerLlmProbeUrls(endpoint);
|
|
949
|
+
if (probes.length === 0) return `endpoint malformed: ${endpoint}`;
|
|
950
|
+
|
|
951
|
+
let lastError = "unreachable";
|
|
952
|
+
for (const probe of probes) {
|
|
953
|
+
const cmd =
|
|
954
|
+
`status="$(curl -sS -m 3 -o /dev/null -w "%{http_code}" ${shellSingleQuote(probe)} || true)"; ` +
|
|
955
|
+
'echo "$status"';
|
|
956
|
+
const result = await this.runWarmShell(cmd);
|
|
957
|
+
const status = Number.parseInt(result.stdout.trim(), 10);
|
|
958
|
+
if (Number.isFinite(status) && status >= 200 && status < 500) {
|
|
959
|
+
return `reachable via ${probe} (HTTP ${status})`;
|
|
960
|
+
}
|
|
961
|
+
if (Number.isFinite(status) && status > 0) {
|
|
962
|
+
lastError = `${probe}: HTTP ${status}`;
|
|
963
|
+
} else {
|
|
964
|
+
const detail = result.stderr ? ` (${result.stderr})` : "";
|
|
965
|
+
lastError = `${probe}: exit ${result.exitCode}${detail}`;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return `UNREACHABLE (${lastError})`;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private async collectWarmRuntimeDiagnostics(backend: ExecutorBackend): Promise<string> {
|
|
972
|
+
const spec = getDockerBackendSpec(backend);
|
|
973
|
+
const runtimeConfig = this.backendRuntimeConfig();
|
|
974
|
+
const sections: string[] = [];
|
|
975
|
+
const model = this.config.workerpals.llm.model.trim() || DEFAULT_OPENHANDS_MODEL;
|
|
976
|
+
const provider = this.normalizeProvider(this.config.workerpals.llm.backend);
|
|
977
|
+
const endpoint = this.config.workerpals.llm.endpoint.trim() || "(unset)";
|
|
978
|
+
const configuredPython = spec.configuredPython(runtimeConfig).trim() || "(unset)";
|
|
979
|
+
const containerPython = this.containerBackendPython(backend, runtimeConfig);
|
|
980
|
+
const containerEndpoint = this.workerLlmEndpointForContainer();
|
|
981
|
+
sections.push(`[backend] ${backend}`);
|
|
982
|
+
sections.push(`[llm-config] model=${model} provider=${provider} endpoint=${endpoint}`);
|
|
983
|
+
sections.push(
|
|
984
|
+
`[python-config] configured=${configuredPython} resolved_container_python=${containerPython}`,
|
|
985
|
+
);
|
|
986
|
+
if (endpoint && containerEndpoint && endpoint !== containerEndpoint) {
|
|
987
|
+
sections.push(`[llm-endpoint-rewrite] ${endpoint} -> ${containerEndpoint}`);
|
|
988
|
+
}
|
|
989
|
+
sections.push(`[llm-probe-host] ${await this.probeWorkerLlmEndpoint()}`);
|
|
990
|
+
sections.push(`[llm-probe-container] ${await this.probeWorkerLlmEndpointFromContainer()}`);
|
|
991
|
+
sections.push(`[container] ${await this.inspectWarmContainerState()}`);
|
|
992
|
+
sections.push(`[container-logs]\n${await this.readWarmContainerLogs(160)}`);
|
|
993
|
+
|
|
994
|
+
const shellProbe = await this.runWarmShell("true");
|
|
995
|
+
if (!shellProbe.ok) {
|
|
996
|
+
const probeOut = [shellProbe.stdout, shellProbe.stderr].filter(Boolean).join("\n");
|
|
997
|
+
sections.push(
|
|
998
|
+
`[container-exec] exit=${shellProbe.exitCode}${probeOut ? `\n${probeOut}` : "\n(no output)"}`,
|
|
999
|
+
);
|
|
1000
|
+
return sections.join("\n");
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const checks = spec.diagnosticChecks?.(SHARED_CONTAINER_VENV_PYTHON) ?? [];
|
|
1004
|
+
|
|
1005
|
+
for (const check of checks) {
|
|
1006
|
+
const result = await this.runWarmShell(check.command);
|
|
1007
|
+
const text = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
|
1008
|
+
sections.push(
|
|
1009
|
+
`[${check.label}] exit=${result.exitCode}${text ? `\n${text}` : "\n(no output)"}`,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
return sections.join("\n");
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
private async stopWarmContainer(reason: string, quiet = false): Promise<void> {
|
|
1016
|
+
this.clearIdleTimer();
|
|
1017
|
+
const stopProc = Bun.spawn([resolveDockerExecutable(), "rm", "-f", this.warmContainerName], {
|
|
1018
|
+
stdout: "pipe",
|
|
1019
|
+
stderr: "pipe",
|
|
1020
|
+
});
|
|
1021
|
+
const exitCode = await stopProc.exited;
|
|
1022
|
+
if (exitCode === 0) {
|
|
1023
|
+
if (!quiet)
|
|
1024
|
+
console.log(
|
|
1025
|
+
`[DockerExecutor] Warm container stopped (${reason}): ${this.warmContainerName}`,
|
|
1026
|
+
);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const stderr = (await new Response(stopProc.stderr).text()).trim();
|
|
1030
|
+
const notFound = /No such container/i.test(stderr);
|
|
1031
|
+
if (!quiet && !notFound) {
|
|
1032
|
+
console.error(`[DockerExecutor] Failed to stop warm container: ${stderr}`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async shutdown(): Promise<void> {
|
|
1037
|
+
await this.stopWarmContainer("worker shutdown", true);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private async runInWarmContainer(
|
|
1041
|
+
worktreePath: string,
|
|
1042
|
+
base64Spec: string,
|
|
1043
|
+
job: Job,
|
|
1044
|
+
onLog?: (stream: "stdout" | "stderr", line: string) => void,
|
|
1045
|
+
): Promise<DockerJobResult> {
|
|
1046
|
+
await this.ensureWarmRuntimeReady(job, onLog);
|
|
1047
|
+
const startedAtMs = Date.now();
|
|
1048
|
+
|
|
1049
|
+
const worktreeRelPath = relative(this.options.repo, worktreePath).replace(/\\/g, "/");
|
|
1050
|
+
const containerWorktreePath = `/repo/${worktreeRelPath}`;
|
|
1051
|
+
|
|
1052
|
+
const args: string[] = [
|
|
1053
|
+
"exec",
|
|
1054
|
+
"-w",
|
|
1055
|
+
containerWorktreePath,
|
|
1056
|
+
this.warmContainerName,
|
|
1057
|
+
"bun",
|
|
1058
|
+
"run",
|
|
1059
|
+
"/workspace/apps/workerpals/src/job_runner.ts",
|
|
1060
|
+
base64Spec,
|
|
1061
|
+
];
|
|
1062
|
+
|
|
1063
|
+
console.log(
|
|
1064
|
+
`[DockerExecutor] Running job in warm container: ${this.warmContainerName} (${this.executionConfigSummary()})`,
|
|
1065
|
+
);
|
|
1066
|
+
|
|
1067
|
+
const proc = Bun.spawn([resolveDockerExecutable(), ...args], {
|
|
1068
|
+
stdout: "pipe",
|
|
1069
|
+
stderr: "pipe",
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
const { leadMs: warningLeadMs, delayMs: warningDelayMs } = computeTimeoutWarningWindow(
|
|
1073
|
+
this.options.timeoutMs,
|
|
1074
|
+
);
|
|
1075
|
+
const warningTimer = setTimeout(() => {
|
|
1076
|
+
const warning = `[DockerExecutor] Job nearing timeout in warm container (${Math.round(
|
|
1077
|
+
warningLeadMs / 1000,
|
|
1078
|
+
)}s remaining): ${this.warmContainerName}`;
|
|
1079
|
+
console.warn(warning);
|
|
1080
|
+
onLog?.("stderr", warning);
|
|
1081
|
+
onLog?.(
|
|
1082
|
+
"stderr",
|
|
1083
|
+
"[DockerExecutor] Worker should finish quickly and return a concise failure/update if task cannot complete in time.",
|
|
1084
|
+
);
|
|
1085
|
+
}, warningDelayMs);
|
|
1086
|
+
|
|
1087
|
+
let timedOutByDocker = false;
|
|
1088
|
+
// Set up timeout
|
|
1089
|
+
const timer = setTimeout(() => {
|
|
1090
|
+
timedOutByDocker = true;
|
|
1091
|
+
const elapsedMs = Math.max(1, Date.now() - startedAtMs);
|
|
1092
|
+
const timeoutMsg = `[DockerExecutor] Job timeout in warm container after ${elapsedMs}ms (limit ${this.options.timeoutMs}ms): ${this.warmContainerName}`;
|
|
1093
|
+
console.log(timeoutMsg);
|
|
1094
|
+
onLog?.("stderr", timeoutMsg);
|
|
1095
|
+
try {
|
|
1096
|
+
proc.kill();
|
|
1097
|
+
// Reset the warm container to clear any stuck in-container process.
|
|
1098
|
+
Bun.spawn([resolveDockerExecutable(), "restart", "-t", "1", this.warmContainerName]);
|
|
1099
|
+
} catch {
|
|
1100
|
+
// Ignore kill errors
|
|
1101
|
+
}
|
|
1102
|
+
}, this.options.timeoutMs);
|
|
1103
|
+
|
|
1104
|
+
// Process streams
|
|
1105
|
+
const stdoutLines: string[] = [];
|
|
1106
|
+
const stderrLines: string[] = [];
|
|
1107
|
+
|
|
1108
|
+
await Promise.all([
|
|
1109
|
+
this.readStream(proc.stdout, "stdout", onLog, stdoutLines),
|
|
1110
|
+
this.readStream(proc.stderr, "stderr", onLog, stderrLines),
|
|
1111
|
+
]);
|
|
1112
|
+
|
|
1113
|
+
clearTimeout(warningTimer);
|
|
1114
|
+
clearTimeout(timer);
|
|
1115
|
+
const exitCode = await proc.exited;
|
|
1116
|
+
const elapsedMs = Math.max(1, Date.now() - startedAtMs);
|
|
1117
|
+
|
|
1118
|
+
// Parse result from stdout (look for ___RESULT___ sentinel)
|
|
1119
|
+
const result = this.parseResult(stdoutLines, stderrLines, exitCode, {
|
|
1120
|
+
timedOutByDocker,
|
|
1121
|
+
elapsedMs,
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
return result;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
private normalizeProvider(raw: string): string {
|
|
1128
|
+
const value = raw.trim().toLowerCase();
|
|
1129
|
+
if (!value) return "auto";
|
|
1130
|
+
if (value === "lmstudio" || value === "openai_compatible") return "openai";
|
|
1131
|
+
if (value === "ollama_chat") return "ollama";
|
|
1132
|
+
return value;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
private executionConfigSummary(): string {
|
|
1136
|
+
const backend = resolveExecutor(this.config);
|
|
1137
|
+
const model = this.config.workerpals.llm.model.trim() || DEFAULT_OPENHANDS_MODEL;
|
|
1138
|
+
const provider = this.normalizeProvider(this.config.workerpals.llm.backend);
|
|
1139
|
+
const warmMemoryMb = this.config.workerpals.dockerWarmMemoryMb;
|
|
1140
|
+
const warmCpus = this.config.workerpals.dockerWarmCpus;
|
|
1141
|
+
const warmPython = this.containerBackendPython(backend);
|
|
1142
|
+
return `backend=${backend} model=${model} provider=${provider} warm_memory_mb=${warmMemoryMb} warm_cpus=${warmCpus} warm_python=${warmPython}`;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
private logExecutionConfig(): void {
|
|
1146
|
+
const summary = this.executionConfigSummary();
|
|
1147
|
+
if (summary === this.lastLoggedExecutionConfig) return;
|
|
1148
|
+
this.lastLoggedExecutionConfig = summary;
|
|
1149
|
+
console.log(`[DockerExecutor] Execution config: ${summary}`);
|
|
1150
|
+
const configuredEndpoint = this.config.workerpals.llm.endpoint.trim();
|
|
1151
|
+
const containerEndpoint = this.workerLlmEndpointForContainer();
|
|
1152
|
+
if (configuredEndpoint && configuredEndpoint !== containerEndpoint) {
|
|
1153
|
+
const rewriteSummary = `${configuredEndpoint} -> ${containerEndpoint}`;
|
|
1154
|
+
if (rewriteSummary !== this.lastLoggedEndpointRewrite) {
|
|
1155
|
+
this.lastLoggedEndpointRewrite = rewriteSummary;
|
|
1156
|
+
console.log(
|
|
1157
|
+
`[DockerExecutor] Rewriting worker LLM endpoint for container networking: ${rewriteSummary}`,
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
private async runGitSelfCheckContainer(worktreePath: string): Promise<void> {
|
|
1164
|
+
const containerName = `pushpals-${this.options.workerId}-selfcheck-${Date.now()}`;
|
|
1165
|
+
const dockerRepoPath = this.toDockerPath(this.options.repo);
|
|
1166
|
+
const worktreeRelPath = relative(this.options.repo, worktreePath).replace(/\\/g, "/");
|
|
1167
|
+
const containerWorktreePath = `/repo/${worktreeRelPath}`;
|
|
1168
|
+
|
|
1169
|
+
const proc = Bun.spawn(
|
|
1170
|
+
[
|
|
1171
|
+
resolveDockerExecutable(),
|
|
1172
|
+
"run",
|
|
1173
|
+
"--rm",
|
|
1174
|
+
"--name",
|
|
1175
|
+
containerName,
|
|
1176
|
+
"--network",
|
|
1177
|
+
"none",
|
|
1178
|
+
"-v",
|
|
1179
|
+
`${dockerRepoPath}:/repo`,
|
|
1180
|
+
"-w",
|
|
1181
|
+
containerWorktreePath,
|
|
1182
|
+
"--entrypoint",
|
|
1183
|
+
"/bin/sh",
|
|
1184
|
+
this.options.imageName,
|
|
1185
|
+
"-lc",
|
|
1186
|
+
"git rev-parse --is-inside-work-tree && git rev-parse --git-dir && git status --porcelain",
|
|
1187
|
+
],
|
|
1188
|
+
{
|
|
1189
|
+
stdout: "pipe",
|
|
1190
|
+
stderr: "pipe",
|
|
1191
|
+
},
|
|
1192
|
+
);
|
|
1193
|
+
|
|
1194
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1195
|
+
new Response(proc.stdout).text(),
|
|
1196
|
+
new Response(proc.stderr).text(),
|
|
1197
|
+
proc.exited,
|
|
1198
|
+
]);
|
|
1199
|
+
|
|
1200
|
+
if (exitCode !== 0) {
|
|
1201
|
+
const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
1202
|
+
throw new Error(`Docker git/worktree startup self-check failed: ${detail}`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Read a stream, forwarding lines to onLog callback and collecting to array
|
|
1208
|
+
*/
|
|
1209
|
+
private async readStream(
|
|
1210
|
+
readable: ReadableStream<Uint8Array>,
|
|
1211
|
+
streamName: "stdout" | "stderr",
|
|
1212
|
+
onLog: ((stream: "stdout" | "stderr", line: string) => void) | undefined,
|
|
1213
|
+
lines: string[],
|
|
1214
|
+
): Promise<void> {
|
|
1215
|
+
const decoder = new TextDecoder();
|
|
1216
|
+
const reader = readable.getReader();
|
|
1217
|
+
let pending = "";
|
|
1218
|
+
|
|
1219
|
+
const forwardLine = (line: string) => {
|
|
1220
|
+
const cleanLine = line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
1221
|
+
if (!cleanLine) return;
|
|
1222
|
+
lines.push(cleanLine);
|
|
1223
|
+
|
|
1224
|
+
// For stderr, try to parse as JSON log line
|
|
1225
|
+
if (streamName === "stderr") {
|
|
1226
|
+
try {
|
|
1227
|
+
const logEntry = JSON.parse(cleanLine);
|
|
1228
|
+
if (logEntry.stream && logEntry.line) {
|
|
1229
|
+
onLog?.(logEntry.stream, logEntry.line);
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
} catch {
|
|
1233
|
+
// Not JSON, forward as-is below.
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
onLog?.(streamName, cleanLine);
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
while (true) {
|
|
1240
|
+
const { done, value } = await reader.read();
|
|
1241
|
+
if (done) break;
|
|
1242
|
+
|
|
1243
|
+
pending += decoder.decode(value, { stream: true });
|
|
1244
|
+
let newlineIndex = pending.indexOf("\n");
|
|
1245
|
+
while (newlineIndex >= 0) {
|
|
1246
|
+
const line = pending.slice(0, newlineIndex);
|
|
1247
|
+
pending = pending.slice(newlineIndex + 1);
|
|
1248
|
+
forwardLine(line);
|
|
1249
|
+
newlineIndex = pending.indexOf("\n");
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
pending += decoder.decode();
|
|
1254
|
+
if (pending) {
|
|
1255
|
+
forwardLine(pending);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Parse the result from stdout lines looking for ___RESULT___ sentinel
|
|
1261
|
+
*/
|
|
1262
|
+
private parseResult(
|
|
1263
|
+
stdoutLines: string[],
|
|
1264
|
+
stderrLines: string[],
|
|
1265
|
+
exitCode: number,
|
|
1266
|
+
context: { timedOutByDocker: boolean; elapsedMs: number },
|
|
1267
|
+
): DockerJobResult {
|
|
1268
|
+
let sawSentinel = false;
|
|
1269
|
+
let sentinelParseError = "";
|
|
1270
|
+
// Look for ___RESULT___ sentinel
|
|
1271
|
+
for (let i = stdoutLines.length - 1; i >= 0; i--) {
|
|
1272
|
+
const line = stdoutLines[i];
|
|
1273
|
+
const match = line.match(/^___RESULT___ (.+)$/);
|
|
1274
|
+
if (match) {
|
|
1275
|
+
sawSentinel = true;
|
|
1276
|
+
try {
|
|
1277
|
+
const result = JSON.parse(match[1]) as DockerJobResult;
|
|
1278
|
+
return result;
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
sentinelParseError = String(err);
|
|
1281
|
+
console.error(
|
|
1282
|
+
`[DockerExecutor] Failed to parse result JSON (line length=${line.length}): ${sentinelParseError}`,
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const stdout = stdoutLines.join("\n");
|
|
1289
|
+
const stderr = stderrLines.join("\n");
|
|
1290
|
+
if (sawSentinel) {
|
|
1291
|
+
const details = [
|
|
1292
|
+
`Malformed ___RESULT___ payload: ${sentinelParseError || "unknown parse error"}`,
|
|
1293
|
+
];
|
|
1294
|
+
if (stderr) details.push(stderr);
|
|
1295
|
+
return {
|
|
1296
|
+
ok: false,
|
|
1297
|
+
summary: `Worker returned malformed structured result after ${context.elapsedMs}ms`,
|
|
1298
|
+
stdout,
|
|
1299
|
+
stderr: details.join("\n"),
|
|
1300
|
+
exitCode,
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// No sentinel found, return generic result.
|
|
1305
|
+
if (context.timedOutByDocker) {
|
|
1306
|
+
return {
|
|
1307
|
+
ok: false,
|
|
1308
|
+
summary: `Job timed out in Docker executor after ${context.elapsedMs}ms (limit ${this.options.timeoutMs}ms; terminated before structured result).`,
|
|
1309
|
+
stdout,
|
|
1310
|
+
stderr,
|
|
1311
|
+
exitCode,
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
if (exitCode === 143 || exitCode === 137) {
|
|
1315
|
+
return {
|
|
1316
|
+
ok: false,
|
|
1317
|
+
summary: `Job process was terminated (exit ${exitCode}) after ${context.elapsedMs}ms before structured result was produced.`,
|
|
1318
|
+
stdout,
|
|
1319
|
+
stderr,
|
|
1320
|
+
exitCode,
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
return {
|
|
1325
|
+
ok: exitCode === 0,
|
|
1326
|
+
summary:
|
|
1327
|
+
exitCode === 0
|
|
1328
|
+
? `Job completed in ${context.elapsedMs}ms`
|
|
1329
|
+
: `Job failed (exit ${exitCode}, elapsed ${context.elapsedMs}ms)`,
|
|
1330
|
+
stdout,
|
|
1331
|
+
stderr,
|
|
1332
|
+
exitCode,
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
private async ensureWarmRuntimeReady(
|
|
1337
|
+
job: Job,
|
|
1338
|
+
onLog?: (stream: "stdout" | "stderr", line: string) => void,
|
|
1339
|
+
): Promise<void> {
|
|
1340
|
+
const backend = resolveExecutor(this.config);
|
|
1341
|
+
for (let attempt = 1; attempt <= this.warmSetupMaxAttempts; attempt++) {
|
|
1342
|
+
try {
|
|
1343
|
+
await this.ensureWarmContainer();
|
|
1344
|
+
await this.ensureBackendWarmup(backend);
|
|
1345
|
+
return;
|
|
1346
|
+
} catch (err) {
|
|
1347
|
+
const retryable = this.isRetryableError(err);
|
|
1348
|
+
if (attempt >= this.warmSetupMaxAttempts || !retryable) {
|
|
1349
|
+
if (
|
|
1350
|
+
retryable &&
|
|
1351
|
+
attempt >= this.warmSetupMaxAttempts &&
|
|
1352
|
+
!(err instanceof DockerExecutionExhaustedError)
|
|
1353
|
+
) {
|
|
1354
|
+
throw new DockerExecutionExhaustedError(
|
|
1355
|
+
"warm_setup",
|
|
1356
|
+
`Warm runtime setup retries exhausted after ${this.warmSetupMaxAttempts} attempts: ${this.compactError(
|
|
1357
|
+
err,
|
|
1358
|
+
)}`,
|
|
1359
|
+
this.failureCooldownMs,
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
throw err;
|
|
1363
|
+
}
|
|
1364
|
+
const retryInMs = this.backoffDelayMs(this.warmSetupBackoffMs, attempt);
|
|
1365
|
+
const note = `[DockerExecutor] Warm runtime setup failed (attempt ${attempt}/${this.warmSetupMaxAttempts}): ${this.compactError(
|
|
1366
|
+
err,
|
|
1367
|
+
)}. Retrying in ${retryInMs}ms.`;
|
|
1368
|
+
console.warn(note);
|
|
1369
|
+
onLog?.("stderr", note);
|
|
1370
|
+
await this.stopWarmContainer("warm setup retry", true);
|
|
1371
|
+
await this.sleep(retryInMs);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
private async ensureBackendWarmup(backend: ExecutorBackend): Promise<void> {
|
|
1377
|
+
if (this.warmedBackends.has(backend)) return;
|
|
1378
|
+
const spec = getDockerBackendSpec(backend);
|
|
1379
|
+
const warmContext = this.warmStartupContext();
|
|
1380
|
+
if (spec.ensureWarmRuntime) {
|
|
1381
|
+
await spec.ensureWarmRuntime({
|
|
1382
|
+
...warmContext,
|
|
1383
|
+
warmContainerName: this.warmContainerName,
|
|
1384
|
+
runWarmShell: (command: string): Promise<DockerWarmShellResult> =>
|
|
1385
|
+
this.runWarmShell(command),
|
|
1386
|
+
restartWarmContainer: async () => {
|
|
1387
|
+
await this.startWarmContainer();
|
|
1388
|
+
},
|
|
1389
|
+
collectWarmDiagnostics: async () => this.collectWarmRuntimeDiagnostics(backend),
|
|
1390
|
+
});
|
|
1391
|
+
this.warmedBackends.add(backend);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const cmd = spec.warmupProbeCommand?.(SHARED_CONTAINER_VENV_PYTHON);
|
|
1395
|
+
if (cmd) {
|
|
1396
|
+
const result = await this.runWarmShell(cmd);
|
|
1397
|
+
if (!result.ok) {
|
|
1398
|
+
const detail = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
|
1399
|
+
throw new Error(
|
|
1400
|
+
`${backend} runtime warmup failed (exit ${result.exitCode})${detail ? `: ${detail}` : ""}`,
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
this.warmedBackends.add(backend);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
private backoffDelayMs(baseMs: number, attempt: number): number {
|
|
1408
|
+
const factor = Math.max(0, attempt - 1);
|
|
1409
|
+
const exponential = baseMs * Math.pow(2, factor);
|
|
1410
|
+
return Math.max(250, Math.min(60_000, Math.floor(exponential)));
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
private async sleep(ms: number): Promise<void> {
|
|
1414
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
private compactError(err: unknown): string {
|
|
1418
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
1419
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
1420
|
+
if (normalized.length <= 280) return normalized;
|
|
1421
|
+
return `${normalized.slice(0, 277)}...`;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
private isRetryableError(err: unknown): boolean {
|
|
1425
|
+
const text = this.compactError(err).toLowerCase();
|
|
1426
|
+
return this.matchesRetryablePattern(text);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
private isRetryableJobFailure(result: DockerJobResult): boolean {
|
|
1430
|
+
const text = `${result.summary ?? ""}\n${result.stderr ?? ""}`.toLowerCase();
|
|
1431
|
+
return this.matchesRetryablePattern(text);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
private matchesRetryablePattern(text: string): boolean {
|
|
1435
|
+
const transientPatterns: RegExp[] = [
|
|
1436
|
+
/warm .*runtime/i,
|
|
1437
|
+
/failed to start warm container/i,
|
|
1438
|
+
/docker execution error/i,
|
|
1439
|
+
/cannot connect to the docker daemon/i,
|
|
1440
|
+
/agent server health check failed/i,
|
|
1441
|
+
/\bconnection (?:error|refused|reset|aborted|closed)\b/i,
|
|
1442
|
+
/\bnetwork is unreachable\b/i,
|
|
1443
|
+
/\b(?:econnrefused|econnreset|eai_again)\b/i,
|
|
1444
|
+
/\blitellm\.timeout\b/i,
|
|
1445
|
+
/\bapitimeouterror\b/i,
|
|
1446
|
+
/\b(?:api|request|connection|health check|startup|model preflight|llm)\s+timed out\b/i,
|
|
1447
|
+
/\bdeadline exceeded\b/i,
|
|
1448
|
+
/\bcontext deadline exceeded\b/i,
|
|
1449
|
+
/\btls handshake timeout\b/i,
|
|
1450
|
+
/\btemporary failure\b/i,
|
|
1451
|
+
/\bopenhands wrapper timed out\b/i,
|
|
1452
|
+
/\bjob timed out in docker executor\b/i,
|
|
1453
|
+
];
|
|
1454
|
+
return transientPatterns.some((pattern) => pattern.test(text));
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* Convert Windows path to Docker-compatible path
|
|
1459
|
+
* C:\foo\bar → /c/foo/bar
|
|
1460
|
+
*/
|
|
1461
|
+
private toDockerPath(hostPath: string): string {
|
|
1462
|
+
// Check if Windows path (contains :\ or starts with drive letter)
|
|
1463
|
+
const winMatch = hostPath.match(/^([a-zA-Z]):([\\/])(.*)$/);
|
|
1464
|
+
if (winMatch) {
|
|
1465
|
+
const drive = winMatch[1].toLowerCase();
|
|
1466
|
+
const rest = winMatch[3].replace(/\\/g, "/");
|
|
1467
|
+
return `/${drive}/${rest}`;
|
|
1468
|
+
}
|
|
1469
|
+
return hostPath;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* Clean up orphaned worktrees at startup
|
|
1474
|
+
*/
|
|
1475
|
+
async cleanupOrphanedWorktrees(): Promise<void> {
|
|
1476
|
+
try {
|
|
1477
|
+
// List all worktrees and only prune stale metadata entries.
|
|
1478
|
+
const proc = Bun.spawn(["git", "worktree", "list", "--porcelain"], {
|
|
1479
|
+
cwd: this.options.repo,
|
|
1480
|
+
stdout: "pipe",
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
const output = await new Response(proc.stdout).text();
|
|
1484
|
+
const exitCode = await proc.exited;
|
|
1485
|
+
|
|
1486
|
+
if (exitCode !== 0) return;
|
|
1487
|
+
|
|
1488
|
+
const prunablePaths = collectPrunableEphemeralWorktrees(output);
|
|
1489
|
+
if (prunablePaths.length > 0) {
|
|
1490
|
+
for (const path of prunablePaths) {
|
|
1491
|
+
console.log(`[DockerExecutor] Pruning stale worktree metadata: ${path}`);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const prune = Bun.spawn(["git", "worktree", "prune"], {
|
|
1496
|
+
cwd: this.options.repo,
|
|
1497
|
+
stdout: "pipe",
|
|
1498
|
+
stderr: "pipe",
|
|
1499
|
+
});
|
|
1500
|
+
const pruneExit = await prune.exited;
|
|
1501
|
+
if (pruneExit !== 0) {
|
|
1502
|
+
const pruneStderr = await new Response(prune.stderr).text();
|
|
1503
|
+
console.warn(`[DockerExecutor] Worktree prune warning: ${pruneStderr}`);
|
|
1504
|
+
}
|
|
1505
|
+
} catch (err) {
|
|
1506
|
+
console.error(`[DockerExecutor] Cleanup error: ${err}`);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
private buildEphemeralWorktreeName(prefix: "job" | "selfcheck", token: string): string {
|
|
1511
|
+
const safeWorker = this.sanitizeWorktreeToken(this.options.workerId, 24);
|
|
1512
|
+
const safeToken = this.sanitizeWorktreeToken(token, 40);
|
|
1513
|
+
const nonce = `${Date.now().toString(36)}-${randomUUID().slice(0, 8).toLowerCase()}`;
|
|
1514
|
+
return `${prefix}-${safeToken}-${safeWorker}-${nonce}`;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
private sanitizeWorktreeToken(value: string, maxLength: number): string {
|
|
1518
|
+
const normalized = String(value ?? "")
|
|
1519
|
+
.toLowerCase()
|
|
1520
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
1521
|
+
.replace(/-+/g, "-")
|
|
1522
|
+
.replace(/^-+|-+$/g, "");
|
|
1523
|
+
if (!normalized) return "work";
|
|
1524
|
+
return normalized.slice(0, maxLength);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
private async ensureFreshWorktreePath(worktreePath: string): Promise<void> {
|
|
1528
|
+
if (!existsSync(worktreePath)) return;
|
|
1529
|
+
|
|
1530
|
+
console.warn(
|
|
1531
|
+
`[DockerExecutor] Worktree path already exists; forcing cleanup before create: ${worktreePath}`,
|
|
1532
|
+
);
|
|
1533
|
+
|
|
1534
|
+
const unregister = Bun.spawn(["git", "worktree", "remove", "--force", "--force", worktreePath], {
|
|
1535
|
+
cwd: this.options.repo,
|
|
1536
|
+
stdout: "pipe",
|
|
1537
|
+
stderr: "pipe",
|
|
1538
|
+
});
|
|
1539
|
+
await unregister.exited;
|
|
1540
|
+
|
|
1541
|
+
const prune = Bun.spawn(["git", "worktree", "prune"], {
|
|
1542
|
+
cwd: this.options.repo,
|
|
1543
|
+
stdout: "pipe",
|
|
1544
|
+
stderr: "pipe",
|
|
1545
|
+
});
|
|
1546
|
+
await prune.exited;
|
|
1547
|
+
|
|
1548
|
+
const forced = await forceDeleteWorktreePath(worktreePath, {
|
|
1549
|
+
sleepFn: (ms) => this.sleep(ms),
|
|
1550
|
+
});
|
|
1551
|
+
if (!forced.removed) {
|
|
1552
|
+
throw new Error(
|
|
1553
|
+
`Failed to remove stale worktree path before create (${worktreePath})${
|
|
1554
|
+
forced.lastError ? `: ${forced.lastError}` : ""
|
|
1555
|
+
}`,
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
private isMergeConflictResolutionJob(job: Job): boolean {
|
|
1561
|
+
const reviewAgent =
|
|
1562
|
+
job.params?.reviewAgent && typeof job.params.reviewAgent === "object"
|
|
1563
|
+
? (job.params.reviewAgent as Record<string, unknown>)
|
|
1564
|
+
: null;
|
|
1565
|
+
const resolutionType =
|
|
1566
|
+
reviewAgent && typeof reviewAgent.resolutionType === "string"
|
|
1567
|
+
? reviewAgent.resolutionType.trim().toLowerCase()
|
|
1568
|
+
: "";
|
|
1569
|
+
return resolutionType === "merge_conflict";
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
private async ensureFreshImageForMergeConflictJob(
|
|
1573
|
+
job: Job,
|
|
1574
|
+
onLog?: (stream: "stdout" | "stderr", line: string) => void,
|
|
1575
|
+
): Promise<void> {
|
|
1576
|
+
if (!this.isMergeConflictResolutionJob(job)) return;
|
|
1577
|
+
|
|
1578
|
+
if (this.mergeConflictRefreshPromise) {
|
|
1579
|
+
await this.mergeConflictRefreshPromise;
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
this.mergeConflictRefreshPromise = this.rebuildImageForMergeConflictJob(job, onLog);
|
|
1584
|
+
try {
|
|
1585
|
+
await this.mergeConflictRefreshPromise;
|
|
1586
|
+
} finally {
|
|
1587
|
+
this.mergeConflictRefreshPromise = null;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
private async rebuildImageForMergeConflictJob(
|
|
1592
|
+
job: Job,
|
|
1593
|
+
onLog?: (stream: "stdout" | "stderr", line: string) => void,
|
|
1594
|
+
): Promise<void> {
|
|
1595
|
+
const sandboxContext = resolveWorkerpalSandboxBuildContext(this.options.repo);
|
|
1596
|
+
const dockerfilePath = sandboxContext.dockerfilePath;
|
|
1597
|
+
if (!existsSync(dockerfilePath)) {
|
|
1598
|
+
throw new Error(
|
|
1599
|
+
`Merge-conflict job ${job.id} requires Docker image refresh, but Dockerfile is missing at ${dockerfilePath}.`,
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
const startMsg = `[DockerExecutor] Merge-conflict job ${job.id}: rebuilding ${this.options.imageName} with --no-cache and restarting warm runtime.`;
|
|
1604
|
+
console.log(startMsg);
|
|
1605
|
+
onLog?.("stdout", startMsg);
|
|
1606
|
+
|
|
1607
|
+
await this.stopWarmContainer("merge-conflict image refresh", true);
|
|
1608
|
+
this.warmedBackends.clear();
|
|
1609
|
+
|
|
1610
|
+
const build = Bun.spawn(
|
|
1611
|
+
[
|
|
1612
|
+
resolveDockerExecutable(),
|
|
1613
|
+
"build",
|
|
1614
|
+
"--no-cache",
|
|
1615
|
+
"-f",
|
|
1616
|
+
dockerfilePath,
|
|
1617
|
+
"-t",
|
|
1618
|
+
this.options.imageName,
|
|
1619
|
+
".",
|
|
1620
|
+
],
|
|
1621
|
+
{
|
|
1622
|
+
cwd: sandboxContext.root,
|
|
1623
|
+
stdout: "pipe",
|
|
1624
|
+
stderr: "pipe",
|
|
1625
|
+
},
|
|
1626
|
+
);
|
|
1627
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
1628
|
+
build.exited,
|
|
1629
|
+
new Response(build.stdout).text(),
|
|
1630
|
+
new Response(build.stderr).text(),
|
|
1631
|
+
]);
|
|
1632
|
+
if (exitCode !== 0) {
|
|
1633
|
+
const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
1634
|
+
throw new Error(
|
|
1635
|
+
`Failed to rebuild Docker image for merge-conflict job ${job.id}: ${detail || `exit ${exitCode}`}`,
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const doneMsg = `[DockerExecutor] Merge-conflict job ${job.id}: Docker image refresh complete (${this.options.imageName}).`;
|
|
1640
|
+
console.log(doneMsg);
|
|
1641
|
+
onLog?.("stdout", doneMsg);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
private async resolveWorktreeBaseRefForJob(
|
|
1645
|
+
job: Job,
|
|
1646
|
+
onLog?: (stream: "stdout" | "stderr", line: string) => void,
|
|
1647
|
+
): Promise<string> {
|
|
1648
|
+
const reviewAgent =
|
|
1649
|
+
job.params?.reviewAgent && typeof job.params.reviewAgent === "object"
|
|
1650
|
+
? (job.params.reviewAgent as Record<string, unknown>)
|
|
1651
|
+
: null;
|
|
1652
|
+
const resolutionType =
|
|
1653
|
+
reviewAgent && typeof reviewAgent.resolutionType === "string"
|
|
1654
|
+
? reviewAgent.resolutionType.trim().toLowerCase()
|
|
1655
|
+
: "";
|
|
1656
|
+
if (resolutionType !== "merge_conflict") return this.options.baseRef;
|
|
1657
|
+
|
|
1658
|
+
const normalizedHeadRef = normalizeMergeConflictHeadRef(reviewAgent?.prHeadRef);
|
|
1659
|
+
if (!normalizedHeadRef) {
|
|
1660
|
+
const note = `[DockerExecutor] Merge-conflict job ${job.id} has no usable prHeadRef; falling back to ${this.options.baseRef}.`;
|
|
1661
|
+
console.warn(note);
|
|
1662
|
+
onLog?.("stderr", note);
|
|
1663
|
+
return this.options.baseRef;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const remoteRef = `origin/${normalizedHeadRef}`;
|
|
1667
|
+
const fetch = Bun.spawn(["git", "fetch", "origin", normalizedHeadRef, "--quiet"], {
|
|
1668
|
+
cwd: this.options.repo,
|
|
1669
|
+
stdout: "pipe",
|
|
1670
|
+
stderr: "pipe",
|
|
1671
|
+
});
|
|
1672
|
+
const fetchExit = await fetch.exited;
|
|
1673
|
+
if (fetchExit !== 0) {
|
|
1674
|
+
const fetchErr = (await new Response(fetch.stderr).text()).trim();
|
|
1675
|
+
const note = `[DockerExecutor] Merge-conflict job ${job.id} could not refresh ${remoteRef}; falling back to ${this.options.baseRef}${fetchErr ? ` (${fetchErr})` : ""}.`;
|
|
1676
|
+
console.warn(note);
|
|
1677
|
+
onLog?.("stderr", note);
|
|
1678
|
+
return this.options.baseRef;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
const verify = Bun.spawn(["git", "rev-parse", "--verify", "--quiet", remoteRef], {
|
|
1682
|
+
cwd: this.options.repo,
|
|
1683
|
+
stdout: "pipe",
|
|
1684
|
+
stderr: "pipe",
|
|
1685
|
+
});
|
|
1686
|
+
const verifyExit = await verify.exited;
|
|
1687
|
+
if (verifyExit !== 0) {
|
|
1688
|
+
const note = `[DockerExecutor] Merge-conflict job ${job.id} could not verify ${remoteRef}; falling back to ${this.options.baseRef}.`;
|
|
1689
|
+
console.warn(note);
|
|
1690
|
+
onLog?.("stderr", note);
|
|
1691
|
+
return this.options.baseRef;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
const info = `[DockerExecutor] Merge-conflict job ${job.id}: using fresh worktree base ${remoteRef}.`;
|
|
1695
|
+
console.log(info);
|
|
1696
|
+
onLog?.("stdout", info);
|
|
1697
|
+
return remoteRef;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* Pull the Docker image
|
|
1702
|
+
*/
|
|
1703
|
+
async pullImage(): Promise<boolean> {
|
|
1704
|
+
const runtimeTag = resolveWorkerpalRuntimeTag();
|
|
1705
|
+
const existingRuntimeTag = runtimeTag ? await this.inspectImageRuntimeTag() : "";
|
|
1706
|
+
if (await this.imageExists()) {
|
|
1707
|
+
if (!runtimeTag || existingRuntimeTag === runtimeTag) {
|
|
1708
|
+
console.log(`[DockerExecutor] Using local image: ${this.options.imageName}`);
|
|
1709
|
+
return true;
|
|
1710
|
+
}
|
|
1711
|
+
console.warn(
|
|
1712
|
+
`[DockerExecutor] Local image ${this.options.imageName} is stale or unlabeled (runtimeTag=${existingRuntimeTag || "missing"}, expected=${runtimeTag}).`,
|
|
1713
|
+
);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
if (await this.buildLocalImage(runtimeTag)) {
|
|
1717
|
+
const rebuiltRuntimeTag = runtimeTag ? await this.inspectImageRuntimeTag() : "";
|
|
1718
|
+
if (!runtimeTag || rebuiltRuntimeTag === runtimeTag) {
|
|
1719
|
+
console.log(`[DockerExecutor] Using locally built image: ${this.options.imageName}`);
|
|
1720
|
+
return true;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
console.log(
|
|
1725
|
+
`[DockerExecutor] Local image is unavailable or unsuitable. Pulling: ${this.options.imageName}`,
|
|
1726
|
+
);
|
|
1727
|
+
const proc = Bun.spawn([resolveDockerExecutable(), "pull", this.options.imageName], {
|
|
1728
|
+
stdout: "pipe",
|
|
1729
|
+
stderr: "pipe",
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
const exitCode = await proc.exited;
|
|
1733
|
+
if (exitCode === 0) {
|
|
1734
|
+
console.log(`[DockerExecutor] Image pulled successfully`);
|
|
1735
|
+
return true;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
const stderr = (await new Response(proc.stderr).text()).trim();
|
|
1739
|
+
console.error(`[DockerExecutor] Failed to pull image: ${stderr}`);
|
|
1740
|
+
|
|
1741
|
+
// Another process may have built/pulled the image while this pull was running.
|
|
1742
|
+
if (await this.imageExists()) {
|
|
1743
|
+
console.warn(
|
|
1744
|
+
`[DockerExecutor] Pull failed but local image is now available: ${this.options.imageName}`,
|
|
1745
|
+
);
|
|
1746
|
+
return true;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
return false;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* Check if the Docker image exists locally
|
|
1754
|
+
*/
|
|
1755
|
+
private async imageExists(): Promise<boolean> {
|
|
1756
|
+
const proc = Bun.spawn([resolveDockerExecutable(), "image", "inspect", this.options.imageName], {
|
|
1757
|
+
stdout: "pipe",
|
|
1758
|
+
stderr: "pipe",
|
|
1759
|
+
});
|
|
1760
|
+
const exitCode = await proc.exited;
|
|
1761
|
+
return exitCode === 0;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
private async inspectImageRuntimeTag(): Promise<string> {
|
|
1765
|
+
const proc = Bun.spawn(
|
|
1766
|
+
[
|
|
1767
|
+
resolveDockerExecutable(),
|
|
1768
|
+
"image",
|
|
1769
|
+
"inspect",
|
|
1770
|
+
"--format",
|
|
1771
|
+
`{{ index .Config.Labels "${WORKERPAL_SANDBOX_RUNTIME_TAG_LABEL}" }}`,
|
|
1772
|
+
this.options.imageName,
|
|
1773
|
+
],
|
|
1774
|
+
{
|
|
1775
|
+
stdout: "pipe",
|
|
1776
|
+
stderr: "pipe",
|
|
1777
|
+
},
|
|
1778
|
+
);
|
|
1779
|
+
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
1780
|
+
if (exitCode !== 0) return "";
|
|
1781
|
+
const value = stdout.trim();
|
|
1782
|
+
return value === "<no value>" ? "" : value;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
private async buildLocalImage(runtimeTag: string): Promise<boolean> {
|
|
1786
|
+
const sandboxContext = resolveWorkerpalSandboxBuildContext(this.options.repo);
|
|
1787
|
+
if (!existsSync(sandboxContext.dockerfilePath)) {
|
|
1788
|
+
return false;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
const dockerfileArg = dockerBuildFileArg(sandboxContext.root, sandboxContext.dockerfilePath);
|
|
1792
|
+
console.log(
|
|
1793
|
+
runtimeTag
|
|
1794
|
+
? `[DockerExecutor] Building local WorkerPal sandbox image ${this.options.imageName} for runtimeTag=${runtimeTag}`
|
|
1795
|
+
: `[DockerExecutor] Building local WorkerPal sandbox image ${this.options.imageName}`,
|
|
1796
|
+
);
|
|
1797
|
+
const args = [
|
|
1798
|
+
resolveDockerExecutable(),
|
|
1799
|
+
"build",
|
|
1800
|
+
"-f",
|
|
1801
|
+
dockerfileArg,
|
|
1802
|
+
"--label",
|
|
1803
|
+
WORKERPAL_SANDBOX_COMPONENT_LABEL,
|
|
1804
|
+
...(runtimeTag ? ["--label", `${WORKERPAL_SANDBOX_RUNTIME_TAG_LABEL}=${runtimeTag}`] : []),
|
|
1805
|
+
"-t",
|
|
1806
|
+
this.options.imageName,
|
|
1807
|
+
".",
|
|
1808
|
+
];
|
|
1809
|
+
const proc = Bun.spawn(args, {
|
|
1810
|
+
cwd: sandboxContext.root,
|
|
1811
|
+
stdout: "pipe",
|
|
1812
|
+
stderr: "pipe",
|
|
1813
|
+
});
|
|
1814
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1815
|
+
new Response(proc.stdout).text(),
|
|
1816
|
+
new Response(proc.stderr).text(),
|
|
1817
|
+
proc.exited,
|
|
1818
|
+
]);
|
|
1819
|
+
if (exitCode === 0) {
|
|
1820
|
+
return true;
|
|
1821
|
+
}
|
|
1822
|
+
const detail = stderr.trim() || stdout.trim() || `docker build exited ${exitCode}`;
|
|
1823
|
+
console.error(`[DockerExecutor] Failed to build local image: ${detail}`);
|
|
1824
|
+
return false;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/**
|
|
1828
|
+
* Check if Docker is available
|
|
1829
|
+
*/
|
|
1830
|
+
static async isDockerAvailable(): Promise<boolean> {
|
|
1831
|
+
try {
|
|
1832
|
+
const proc = Bun.spawn([resolveDockerExecutable(), "version"], {
|
|
1833
|
+
stdout: "pipe",
|
|
1834
|
+
stderr: "pipe",
|
|
1835
|
+
});
|
|
1836
|
+
const exitCode = await proc.exited;
|
|
1837
|
+
return exitCode === 0;
|
|
1838
|
+
} catch {
|
|
1839
|
+
return false;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
}
|