@pushpalsdev/cli 1.0.18 → 1.0.19

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.
Files changed (106) hide show
  1. package/dist/pushpals-cli.js +277 -12
  2. package/package.json +1 -1
  3. package/runtime/sandbox/apps/workerpals/.python-version +1 -0
  4. package/runtime/sandbox/apps/workerpals/Dockerfile.sandbox +71 -0
  5. package/runtime/sandbox/apps/workerpals/package.json +25 -0
  6. package/runtime/sandbox/apps/workerpals/pyproject.toml +8 -0
  7. package/runtime/sandbox/apps/workerpals/src/backends/backend_config.ts +111 -0
  8. package/runtime/sandbox/apps/workerpals/src/backends/miniswe/miniswe_executor.py +2029 -0
  9. package/runtime/sandbox/apps/workerpals/src/backends/miniswe_backend.ts +48 -0
  10. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/openai_codex_executor.py +1259 -0
  11. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/test_openai_codex_runtime_config.py +110 -0
  12. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex_backend.ts +67 -0
  13. package/runtime/sandbox/apps/workerpals/src/backends/openhands/openhands_executor.py +563 -0
  14. package/runtime/sandbox/apps/workerpals/src/backends/openhands_backend.ts +161 -0
  15. package/runtime/sandbox/apps/workerpals/src/backends/openhands_task_execute.ts +536 -0
  16. package/runtime/sandbox/apps/workerpals/src/backends/shared/executor_base.py +746 -0
  17. package/runtime/sandbox/apps/workerpals/src/backends/shared/test_settings_resolver.py +60 -0
  18. package/runtime/sandbox/apps/workerpals/src/backends/task_execute_registry.ts +21 -0
  19. package/runtime/sandbox/apps/workerpals/src/backends/types.ts +52 -0
  20. package/runtime/sandbox/apps/workerpals/src/common/execution_utils.ts +149 -0
  21. package/runtime/sandbox/apps/workerpals/src/common/executor_backend.ts +15 -0
  22. package/runtime/sandbox/apps/workerpals/src/common/generic_python_executor.ts +210 -0
  23. package/runtime/sandbox/apps/workerpals/src/common/logger.ts +65 -0
  24. package/runtime/sandbox/apps/workerpals/src/common/types.ts +9 -0
  25. package/runtime/sandbox/apps/workerpals/src/common/worktree_cleanup.ts +66 -0
  26. package/runtime/sandbox/apps/workerpals/src/context_manager.ts +45 -0
  27. package/runtime/sandbox/apps/workerpals/src/docker_executor.ts +1842 -0
  28. package/runtime/sandbox/apps/workerpals/src/execute_job.ts +3063 -0
  29. package/runtime/sandbox/apps/workerpals/src/job_runner.ts +194 -0
  30. package/runtime/sandbox/apps/workerpals/src/shell_manager.ts +210 -0
  31. package/runtime/sandbox/apps/workerpals/src/timeout_policy.ts +24 -0
  32. package/runtime/sandbox/apps/workerpals/src/workerpals_main.ts +1436 -0
  33. package/runtime/sandbox/apps/workerpals/tsconfig.json +15 -0
  34. package/runtime/sandbox/apps/workerpals/uv.lock +2014 -0
  35. package/runtime/sandbox/bun.lock +2591 -0
  36. package/runtime/sandbox/configs/backend.toml +79 -0
  37. package/runtime/sandbox/configs/default.toml +260 -0
  38. package/runtime/sandbox/configs/dev.toml +2 -0
  39. package/runtime/sandbox/configs/local.example.toml +129 -0
  40. package/runtime/sandbox/package.json +65 -0
  41. package/runtime/sandbox/packages/protocol/README.md +168 -0
  42. package/runtime/sandbox/packages/protocol/package.json +37 -0
  43. package/runtime/sandbox/packages/protocol/scripts/copy-schemas.js +17 -0
  44. package/runtime/sandbox/packages/protocol/src/a2a/README.md +52 -0
  45. package/runtime/sandbox/packages/protocol/src/a2a/mapping.ts +55 -0
  46. package/runtime/sandbox/packages/protocol/src/index.browser.ts +25 -0
  47. package/runtime/sandbox/packages/protocol/src/index.ts +25 -0
  48. package/runtime/sandbox/packages/protocol/src/schemas/approvals.schema.json +6 -0
  49. package/runtime/sandbox/packages/protocol/src/schemas/envelope.schema.json +96 -0
  50. package/runtime/sandbox/packages/protocol/src/schemas/events.schema.json +679 -0
  51. package/runtime/sandbox/packages/protocol/src/schemas/http.schema.json +50 -0
  52. package/runtime/sandbox/packages/protocol/src/types.ts +267 -0
  53. package/runtime/sandbox/packages/protocol/src/validate.browser.ts +154 -0
  54. package/runtime/sandbox/packages/protocol/src/validate.ts +233 -0
  55. package/runtime/sandbox/packages/protocol/src/version.ts +1 -0
  56. package/runtime/sandbox/packages/protocol/tsconfig.json +20 -0
  57. package/runtime/sandbox/packages/shared/package.json +19 -0
  58. package/runtime/sandbox/packages/shared/src/autonomy_policy.ts +400 -0
  59. package/runtime/sandbox/packages/shared/src/client_preflight.ts +297 -0
  60. package/runtime/sandbox/packages/shared/src/communication.ts +313 -0
  61. package/runtime/sandbox/packages/shared/src/config.ts +2201 -0
  62. package/runtime/sandbox/packages/shared/src/config_template_parity.ts +70 -0
  63. package/runtime/sandbox/packages/shared/src/git_backend.ts +205 -0
  64. package/runtime/sandbox/packages/shared/src/index.ts +100 -0
  65. package/runtime/sandbox/packages/shared/src/local_network.ts +101 -0
  66. package/runtime/sandbox/packages/shared/src/localbuddy_runtime.ts +329 -0
  67. package/runtime/sandbox/packages/shared/src/prompts.ts +64 -0
  68. package/runtime/sandbox/packages/shared/src/repo.ts +134 -0
  69. package/runtime/sandbox/packages/shared/src/session_event_visibility.ts +25 -0
  70. package/runtime/sandbox/packages/shared/src/vision.ts +247 -0
  71. package/runtime/sandbox/packages/shared/tsconfig.json +16 -0
  72. package/runtime/sandbox/prompts/workerpals/codex_quality_critic_instruction_prompt.md +14 -0
  73. package/runtime/sandbox/prompts/workerpals/commit_message_prompt.md +36 -0
  74. package/runtime/sandbox/prompts/workerpals/commit_message_user_prompt.md +7 -0
  75. package/runtime/sandbox/prompts/workerpals/miniswe_broker_system_prompt.md +33 -0
  76. package/runtime/sandbox/prompts/workerpals/miniswe_broker_task_prompt.md +5 -0
  77. package/runtime/sandbox/prompts/workerpals/miniswe_completion_requirement.md +1 -0
  78. package/runtime/sandbox/prompts/workerpals/miniswe_context_compaction_retry_prompt.md +1 -0
  79. package/runtime/sandbox/prompts/workerpals/miniswe_explicit_targets_block.md +2 -0
  80. package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_base.md +4 -0
  81. package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_blocker_line.md +1 -0
  82. package/runtime/sandbox/prompts/workerpals/miniswe_strict_tool_use_guidance.md +6 -0
  83. package/runtime/sandbox/prompts/workerpals/miniswe_supplemental_guidance_section.md +2 -0
  84. package/runtime/sandbox/prompts/workerpals/miniswe_timeout_note.md +1 -0
  85. package/runtime/sandbox/prompts/workerpals/miniswe_toolcall_retry_guidance.md +1 -0
  86. package/runtime/sandbox/prompts/workerpals/openai_codex_default_system_prompt.md +4 -0
  87. package/runtime/sandbox/prompts/workerpals/openai_codex_instruction_wrapper.md +5 -0
  88. package/runtime/sandbox/prompts/workerpals/openai_codex_runtime_policy_appendix.md +5 -0
  89. package/runtime/sandbox/prompts/workerpals/openai_codex_supplemental_guidance_section.md +2 -0
  90. package/runtime/sandbox/prompts/workerpals/openai_codex_task_execute_system_prompt.md +12 -0
  91. package/runtime/sandbox/prompts/workerpals/openhands_minimal_security_policy.j2 +8 -0
  92. package/runtime/sandbox/prompts/workerpals/openhands_minimal_system_prompt.j2 +20 -0
  93. package/runtime/sandbox/prompts/workerpals/openhands_strict_tool_use_message.md +1 -0
  94. package/runtime/sandbox/prompts/workerpals/openhands_supplemental_guidance_message.md +2 -0
  95. package/runtime/sandbox/prompts/workerpals/openhands_task_execute_fallback_system_prompt.md +1 -0
  96. package/runtime/sandbox/prompts/workerpals/openhands_task_execute_system_prompt.md +21 -0
  97. package/runtime/sandbox/prompts/workerpals/openhands_task_user_prompt.md +6 -0
  98. package/runtime/sandbox/prompts/workerpals/openhands_timeout_note.md +1 -0
  99. package/runtime/sandbox/prompts/workerpals/pr_description.md +42 -0
  100. package/runtime/sandbox/prompts/workerpals/task_quality_critic_system_prompt.md +9 -0
  101. package/runtime/sandbox/prompts/workerpals/task_quality_critic_user_prompt.md +17 -0
  102. package/runtime/sandbox/prompts/workerpals/workerpals_system_prompt.md +115 -0
  103. package/runtime/sandbox/protocol/schemas/approvals.schema.json +6 -0
  104. package/runtime/sandbox/protocol/schemas/envelope.schema.json +96 -0
  105. package/runtime/sandbox/protocol/schemas/events.schema.json +679 -0
  106. package/runtime/sandbox/protocol/schemas/http.schema.json +50 -0
@@ -0,0 +1,1436 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * PushPals WorkerPals Daemon
4
+ *
5
+ * Usage:
6
+ * bun run workerpals --server http://localhost:3001 [--poll 2000] [--repo <path>] [--docker]
7
+ *
8
+ * Polls the server job queue, claims jobs, executes them, and reports results.
9
+ * Streams stdout/stderr as `job_log` events with seq numbers.
10
+ *
11
+ * Job execution modes:
12
+ * - Direct mode (default): jobs run on host in isolated git worktrees
13
+ * - Docker mode (--docker): jobs run in isolated Docker containers
14
+ *
15
+ * Workerpals_main --> docker_executor (if Docker mode) --> job_runner (inside container if Docker mode)
16
+ * job_runner -> job_runner::executeJob (executes the actual job command) -> job_runner (returns result) -> workerpals_main (handles completion)
17
+ *
18
+ * WorkerPals_main handles job claiming, heartbeats, and completion enqueuing.
19
+ *
20
+ * JobRunner executes a single job, streams logs, and outputs a final result with a sentinel line.
21
+ */
22
+
23
+ import type { CommandRequest } from "protocol";
24
+ import { randomUUID } from "crypto";
25
+ import { mkdirSync } from "fs";
26
+ import { resolve } from "path";
27
+ import {
28
+ detectRepoRoot,
29
+ loadPromptTemplate,
30
+ loadPushPalsConfig,
31
+ resolveLocalServerConnection,
32
+ resolveGitTokenForRemote,
33
+ } from "shared";
34
+ import { resolveExecutor } from "./common/executor_backend.js";
35
+ import { Logger } from "./common/logger.js";
36
+ import {
37
+ executeJob,
38
+ shouldCommit,
39
+ createJobCommit,
40
+ git,
41
+ redactSensitiveText,
42
+ resolveReviewNoChangeCompletionBranch,
43
+ type JobResult,
44
+ } from "./execute_job.js";
45
+ import { DockerExecutionExhaustedError, DockerExecutor } from "./docker_executor.js";
46
+ import { forceDeleteWorktreePath } from "./common/worktree_cleanup.js";
47
+ import { DEFAULT_DOCKER_TIMEOUT_MS, parseDockerTimeoutMs } from "./timeout_policy.js";
48
+
49
+ type CommitRef = {
50
+ branch: string;
51
+ sha: string;
52
+ };
53
+
54
+ type CompletionPrMetadata = {
55
+ title: string;
56
+ body: string;
57
+ };
58
+
59
+ type WorkerJobResult = JobResult & {
60
+ commit?: CommitRef;
61
+ cooldownMs?: number;
62
+ };
63
+
64
+ const DEFAULT_LLM_MODEL = "local-model";
65
+ const CODEX_UNAVAILABLE_WORKER_EXIT_CODE = 86;
66
+ const CONFIG = loadPushPalsConfig();
67
+ const LOG = new Logger("WorkerPals");
68
+
69
+ function workerLlmConfig(runtimeConfig: ReturnType<typeof loadPushPalsConfig>): {
70
+ model: string;
71
+ provider: string;
72
+ baseUrl: string;
73
+ } {
74
+ const normalizeProvider = (raw: string): string => {
75
+ const value = raw.trim().toLowerCase();
76
+ if (!value) return "auto";
77
+ if (value === "lmstudio") return "openai";
78
+ if (value === "openai_compatible") return "openai";
79
+ if (value === "ollama_chat") return "ollama";
80
+ return value;
81
+ };
82
+
83
+ const model = runtimeConfig.workerpals.llm.model.trim().replace(/\s+/g, " ");
84
+ const provider = normalizeProvider(runtimeConfig.workerpals.llm.backend);
85
+ const baseUrl = runtimeConfig.workerpals.llm.endpoint.trim();
86
+
87
+ return {
88
+ model: model || DEFAULT_LLM_MODEL,
89
+ provider: provider || "auto",
90
+ baseUrl,
91
+ };
92
+ }
93
+
94
+ function integrationBranchName(): string {
95
+ const configuredBaseRef = CONFIG.workerpals.baseRef.trim();
96
+ if (!configuredBaseRef) return "main_agents";
97
+ return configuredBaseRef.replace(/^origin\//, "").trim() || "main_agents";
98
+ }
99
+
100
+ function formatDurationMs(durationMs: number): string {
101
+ const ms = Math.max(0, Math.floor(durationMs));
102
+ if (ms < 1_000) return `${ms}ms`;
103
+ const totalSeconds = Math.floor(ms / 1_000);
104
+ const minutes = Math.floor(totalSeconds / 60);
105
+ const seconds = totalSeconds % 60;
106
+ if (minutes <= 0) return `${totalSeconds}s`;
107
+ return `${minutes}m ${seconds}s`;
108
+ }
109
+
110
+ function sanitizeJobLogLine(line: string): string {
111
+ // Strip ANSI escape/control sequences and collapse whitespace.
112
+ const cleaned = line
113
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
114
+ .replace(/\r/g, "")
115
+ .replace(/\s+/g, " ")
116
+ .trim();
117
+ return redactSensitiveText(cleaned);
118
+ }
119
+
120
+ function isNoisyProgressLine(line: string): boolean {
121
+ return /^(📦 Installing \[\d+\/\d+\]|🔍 Resolving\.\.\.|🔒 Saving lockfile\.\.\.)$/.test(line);
122
+ }
123
+
124
+ function shouldRecycleWorkerForCodexUnavailableFailure(
125
+ summary: string,
126
+ stderr?: string | null,
127
+ ): boolean {
128
+ const text = `${summary}\n${stderr ?? ""}`.toLowerCase();
129
+ return [
130
+ "openai_codex cli is not installed",
131
+ "openai_codex chatgpt auth is not ready",
132
+ "openai_codex api_key auth requires openai_api_key",
133
+ "openai_codex policy violation: codex cli workaround detected",
134
+ "codex cli isn't available",
135
+ "codex cli is mandatory in this backend",
136
+ ].some((needle) => text.includes(needle));
137
+ }
138
+
139
+ function parseArgs(): {
140
+ server: string;
141
+ pollMs: number;
142
+ heartbeatMs: number;
143
+ repo: string;
144
+ workerId: string;
145
+ authToken: string | null;
146
+ docker: boolean;
147
+ requireDocker: boolean;
148
+ dockerImage: string;
149
+ gitToken: string | null;
150
+ dockerTimeout: number;
151
+ dockerIdleTimeout: number;
152
+ dockerNetworkMode: string;
153
+ worktreeBaseRef: string;
154
+ labels: string[];
155
+ failureCooldownMs: number;
156
+ } {
157
+ const args = process.argv.slice(2);
158
+ let server = CONFIG.server.url;
159
+ let pollMs = CONFIG.workerpals.pollMs;
160
+ let heartbeatMs = CONFIG.workerpals.heartbeatMs;
161
+ let repo = detectRepoRoot(process.cwd());
162
+ let workerId = `workerpal-${randomUUID().substring(0, 8)}`;
163
+ let authToken = CONFIG.authToken;
164
+ let docker = false;
165
+ let requireDocker = CONFIG.workerpals.requireDocker;
166
+ let dockerImage = CONFIG.workerpals.dockerImage;
167
+ let gitToken = CONFIG.gitToken;
168
+ let dockerTimeout = CONFIG.workerpals.dockerTimeoutMs;
169
+ let dockerIdleTimeout = CONFIG.workerpals.dockerIdleTimeoutMs;
170
+ let dockerNetworkMode = CONFIG.workerpals.dockerNetworkMode;
171
+ let worktreeBaseRef = CONFIG.workerpals.baseRef || `origin/${integrationBranchName()}`;
172
+ let labels = [...CONFIG.workerpals.labels];
173
+ let failureCooldownMs = CONFIG.workerpals.failureCooldownMs;
174
+
175
+ for (let i = 0; i < args.length; i++) {
176
+ switch (args[i]) {
177
+ case "--server":
178
+ server = args[++i];
179
+ break;
180
+ case "--poll":
181
+ pollMs = parseInt(args[++i], 10);
182
+ break;
183
+ case "--heartbeat":
184
+ heartbeatMs = parseInt(args[++i], 10);
185
+ break;
186
+ case "--repo":
187
+ repo = detectRepoRoot(args[++i]);
188
+ break;
189
+ case "--workerId":
190
+ workerId = args[++i];
191
+ break;
192
+ case "--token":
193
+ authToken = args[++i];
194
+ break;
195
+ case "--docker":
196
+ docker = true;
197
+ break;
198
+ case "--require-docker":
199
+ requireDocker = true;
200
+ break;
201
+ case "--docker-image":
202
+ dockerImage = args[++i];
203
+ break;
204
+ case "--git-token":
205
+ gitToken = args[++i];
206
+ break;
207
+ case "--docker-timeout":
208
+ dockerTimeout = parseDockerTimeoutMs(args[++i]);
209
+ break;
210
+ case "--docker-idle-timeout":
211
+ dockerIdleTimeout = parseInt(args[++i], 10);
212
+ break;
213
+ case "--docker-network":
214
+ dockerNetworkMode = (args[++i] ?? "").trim() || dockerNetworkMode;
215
+ break;
216
+ case "--base-ref":
217
+ worktreeBaseRef = args[++i];
218
+ break;
219
+ case "--labels":
220
+ labels = args[++i]
221
+ .split(",")
222
+ .map((label) => label.trim())
223
+ .filter(Boolean);
224
+ break;
225
+ case "--failure-cooldown-ms":
226
+ failureCooldownMs = parseInt(args[++i], 10);
227
+ break;
228
+ }
229
+ }
230
+
231
+ const resolved = resolveLocalServerConnection({
232
+ serverUrl: server,
233
+ authToken,
234
+ fallbackPort: CONFIG.server.port,
235
+ });
236
+ if (resolved.serverWasNormalized) {
237
+ LOG.warn(`Coerced server URL to local-only endpoint: ${resolved.serverUrl}`);
238
+ }
239
+ if (resolved.authTokenWasIgnored) {
240
+ LOG.warn("Ignoring auth token in local-only mode.");
241
+ }
242
+
243
+ return {
244
+ server: resolved.serverUrl,
245
+ pollMs,
246
+ heartbeatMs: Number.isFinite(heartbeatMs) && heartbeatMs > 0 ? heartbeatMs : pollMs,
247
+ repo,
248
+ workerId,
249
+ authToken: resolved.authToken,
250
+ docker,
251
+ requireDocker,
252
+ dockerImage,
253
+ gitToken,
254
+ dockerTimeout:
255
+ Number.isFinite(dockerTimeout) && dockerTimeout > 0
256
+ ? dockerTimeout
257
+ : DEFAULT_DOCKER_TIMEOUT_MS,
258
+ dockerIdleTimeout:
259
+ Number.isFinite(dockerIdleTimeout) && dockerIdleTimeout >= 0 ? dockerIdleTimeout : 600000,
260
+ dockerNetworkMode,
261
+ worktreeBaseRef,
262
+ labels,
263
+ failureCooldownMs:
264
+ Number.isFinite(failureCooldownMs) && failureCooldownMs >= 0
265
+ ? Math.min(failureCooldownMs, 300_000)
266
+ : 20_000,
267
+ };
268
+ }
269
+
270
+ async function resolveGitRemoteUrl(repo: string, remote = "origin"): Promise<string> {
271
+ const result = await git(repo, ["remote", "get-url", remote]);
272
+ if (!result.ok) return "";
273
+ return String(result.stdout ?? "").trim();
274
+ }
275
+
276
+ async function resolveWorkerGitToken(
277
+ repo: string,
278
+ configuredToken: string | null,
279
+ ): Promise<string> {
280
+ const remoteUrl = await resolveGitRemoteUrl(repo, "origin");
281
+ const resolved = await resolveGitTokenForRemote({
282
+ remoteUrl,
283
+ configuredToken: configuredToken ?? "",
284
+ cwd: repo,
285
+ });
286
+ if (resolved.token) {
287
+ console.log(
288
+ `[WorkerPals] Git auth: backend=${resolved.backend} host=${resolved.host || "unknown"} source=${resolved.source}`,
289
+ );
290
+ } else {
291
+ console.warn(
292
+ `[WorkerPals] Git auth token not found (backend=${resolved.backend}, host=${resolved.host || "unknown"}). Push-required jobs may fail.`,
293
+ );
294
+ }
295
+ return resolved.token;
296
+ }
297
+
298
+ async function runJob(
299
+ job: {
300
+ id: string;
301
+ taskId: string;
302
+ kind: string;
303
+ params: Record<string, unknown>;
304
+ sessionId: string;
305
+ },
306
+ repo: string,
307
+ dockerExecutor: DockerExecutor | null,
308
+ runtimeConfig: ReturnType<typeof loadPushPalsConfig>,
309
+ onLog?: (stream: "stdout" | "stderr", line: string) => void,
310
+ ): Promise<WorkerJobResult> {
311
+ if (dockerExecutor) {
312
+ const result = await dockerExecutor.execute(job, onLog);
313
+ return {
314
+ ok: result.ok,
315
+ summary: result.summary,
316
+ stdout: result.stdout,
317
+ stderr: result.stderr,
318
+ exitCode: result.exitCode,
319
+ commit: result.commit,
320
+ };
321
+ }
322
+ return executeJob(job.kind, job.params, repo, onLog, runtimeConfig);
323
+ }
324
+
325
+ async function resolveWorktreeBaseRef(repo: string, requestedRef: string): Promise<string> {
326
+ const integrationBranch = integrationBranchName();
327
+ const integrationRemoteRef = `origin/${integrationBranch}`;
328
+ const candidates = new Set<string>([
329
+ requestedRef,
330
+ integrationRemoteRef,
331
+ integrationBranch,
332
+ "HEAD",
333
+ ]);
334
+ if (requestedRef.startsWith("origin/")) {
335
+ const branch = requestedRef.slice("origin/".length);
336
+ const fetchResult = await git(repo, ["fetch", "origin", branch, "--quiet"]);
337
+ if (!fetchResult.ok) {
338
+ console.warn(
339
+ `[WorkerPals] Could not refresh ${requestedRef}; continuing with local refs (${fetchResult.stderr || fetchResult.stdout})`,
340
+ );
341
+ }
342
+ candidates.add(branch);
343
+ } else if (requestedRef !== "HEAD") {
344
+ candidates.add(`origin/${requestedRef}`);
345
+ }
346
+
347
+ for (const ref of candidates) {
348
+ const parsed = await git(repo, ["rev-parse", "--verify", "--quiet", ref]);
349
+ if (parsed.ok) return ref;
350
+ }
351
+
352
+ return "HEAD";
353
+ }
354
+
355
+ async function createIsolatedWorktree(
356
+ repo: string,
357
+ jobId: string,
358
+ baseRef: string,
359
+ ): Promise<string> {
360
+ const worktreeRoot = resolve(repo, ".worktrees");
361
+ mkdirSync(worktreeRoot, { recursive: true });
362
+
363
+ const worktreePath = resolve(
364
+ worktreeRoot,
365
+ `host-job-${jobId}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
366
+ );
367
+
368
+ const addResult = await git(repo, ["worktree", "add", "--detach", worktreePath, baseRef]);
369
+ if (!addResult.ok) {
370
+ throw new Error(`Failed to create isolated worktree: ${addResult.stderr}`);
371
+ }
372
+
373
+ return worktreePath;
374
+ }
375
+
376
+ async function removeIsolatedWorktree(repo: string, worktreePath: string): Promise<void> {
377
+ const removeResult = await git(repo, ["worktree", "remove", "--force", worktreePath]);
378
+ if (!removeResult.ok) {
379
+ console.warn(
380
+ `[WorkerPals] Worktree cleanup warning (${worktreePath}): ${removeResult.stderr || removeResult.stdout}`,
381
+ );
382
+ }
383
+ const pruneResult = await git(repo, ["worktree", "prune"]);
384
+ if (!pruneResult.ok) {
385
+ console.warn(
386
+ `[WorkerPals] Worktree prune warning (${worktreePath}): ${pruneResult.stderr || pruneResult.stdout}`,
387
+ );
388
+ }
389
+
390
+ const forced = await forceDeleteWorktreePath(worktreePath);
391
+ if (!forced.removed) {
392
+ throw new Error(
393
+ `worktree path persisted after cleanup (${worktreePath})${forced.lastError ? `: ${forced.lastError}` : ""}`,
394
+ );
395
+ }
396
+ }
397
+
398
+ function sanitizePrText(value: unknown, max = 240): string {
399
+ const text = String(value ?? "")
400
+ .replace(/\s+/g, " ")
401
+ .trim();
402
+ if (!text) return "";
403
+ return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
404
+ }
405
+
406
+ function inferPrArea(kind: string, changedPaths: string[]): string {
407
+ const looksLikeTests = (path: string): boolean => {
408
+ const normalized = path.replace(/\\/g, "/").toLowerCase();
409
+ return (
410
+ normalized.startsWith("tests/") ||
411
+ normalized.includes("/tests/") ||
412
+ normalized.endsWith(".test.ts") ||
413
+ normalized.endsWith(".test.tsx") ||
414
+ normalized.endsWith(".spec.ts") ||
415
+ normalized.endsWith(".spec.tsx") ||
416
+ normalized.endsWith("_test.py") ||
417
+ normalized.endsWith("_test.js") ||
418
+ normalized.endsWith("_test.ts")
419
+ );
420
+ };
421
+ if (changedPaths.some(looksLikeTests)) return "tests";
422
+ if (kind.startsWith("task.")) return "repo";
423
+ if (kind.startsWith("file.")) return "repo";
424
+ if (kind.startsWith("bun.test") || kind.startsWith("test.")) return "tests";
425
+ if (kind.startsWith("bun.lint")) return "repo";
426
+ if (kind.startsWith("git.")) return "repo";
427
+ return "infra";
428
+ }
429
+
430
+ function inferChangedPaths(params: Record<string, unknown> | undefined): string[] {
431
+ if (!params) return [];
432
+ const candidates: string[] = [];
433
+
434
+ const add = (value: unknown) => {
435
+ if (typeof value !== "string") return;
436
+ const trimmed = value.trim();
437
+ if (!trimmed) return;
438
+ candidates.push(trimmed);
439
+ };
440
+
441
+ add(params.path);
442
+ add(params.targetPath);
443
+ add(params.from);
444
+ add(params.to);
445
+
446
+ if (Array.isArray(params.paths)) {
447
+ for (const value of params.paths) add(value);
448
+ }
449
+ if (params.planning && typeof params.planning === "object") {
450
+ const planning = params.planning as Record<string, unknown>;
451
+ if (Array.isArray(planning.targetPaths)) {
452
+ for (const value of planning.targetPaths) add(value);
453
+ }
454
+ }
455
+
456
+ const deduped: string[] = [];
457
+ const seen = new Set<string>();
458
+ for (const entry of candidates) {
459
+ if (seen.has(entry)) continue;
460
+ seen.add(entry);
461
+ deduped.push(entry);
462
+ if (deduped.length >= 8) break;
463
+ }
464
+ return deduped;
465
+ }
466
+
467
+ function inferValidationSteps(params: Record<string, unknown> | undefined): string[] {
468
+ if (!params || !params.planning || typeof params.planning !== "object") return [];
469
+ const planning = params.planning as Record<string, unknown>;
470
+ if (!Array.isArray(planning.validationSteps)) return [];
471
+ const out: string[] = [];
472
+ const seen = new Set<string>();
473
+ for (const raw of planning.validationSteps) {
474
+ if (typeof raw !== "string") continue;
475
+ const step = sanitizePrText(raw, 200);
476
+ if (!step || seen.has(step)) continue;
477
+ seen.add(step);
478
+ out.push(step);
479
+ if (out.length >= 10) break;
480
+ }
481
+ return out;
482
+ }
483
+
484
+ function inferTaskInstruction(params: Record<string, unknown> | undefined): string {
485
+ if (!params || typeof params.instruction !== "string") return "";
486
+ return sanitizePrText(params.instruction, 240);
487
+ }
488
+
489
+ function isLowSignalResultSummary(summary: string): boolean {
490
+ const text = summary.trim().toLowerCase();
491
+ if (!text) return true;
492
+ return (
493
+ text.includes("executed task and modified") ||
494
+ text.includes("executed task via") ||
495
+ text.includes("no file changes detected") ||
496
+ text.includes("task summary")
497
+ );
498
+ }
499
+
500
+ function derivePrSummary(
501
+ kind: string,
502
+ params: Record<string, unknown> | undefined,
503
+ resultSummary: string,
504
+ ): string {
505
+ const workerSummary = sanitizePrText(resultSummary, 96);
506
+ if (workerSummary && !isLowSignalResultSummary(workerSummary)) {
507
+ return workerSummary;
508
+ }
509
+
510
+ const instruction = inferTaskInstruction(params);
511
+ if (instruction) {
512
+ let normalized = instruction
513
+ .replace(/^(can you|could you|would you|please)\s+/i, "")
514
+ .replace(/\?+$/, "")
515
+ .trim();
516
+ if (normalized.length > 0) {
517
+ normalized = normalized[0].toUpperCase() + normalized.slice(1);
518
+ return sanitizePrText(normalized, 96);
519
+ }
520
+ }
521
+
522
+ return sanitizePrText(`${kind} update`, 96);
523
+ }
524
+
525
+ function inferPrTitleType(kind: string, area: string): "test" | "fix" | "chore" {
526
+ if (area === "tests") return "test";
527
+ if (kind.startsWith("task.") || kind.startsWith("file.")) return "fix";
528
+ return "chore";
529
+ }
530
+
531
+ function toBulletList(lines: string[]): string {
532
+ if (lines.length === 0) return "- None";
533
+ return lines.map((line) => (line.startsWith("- ") ? line : `- ${line}`)).join("\n");
534
+ }
535
+
536
+ function buildCompletionPrMetadataFallback(args: {
537
+ workerId: string;
538
+ integrationBranch: string;
539
+ job: { id: string; taskId: string; kind: string; params?: Record<string, unknown> };
540
+ commit: CommitRef;
541
+ resultSummary: string;
542
+ title: string;
543
+ changedPaths: string[];
544
+ taskInstruction: string;
545
+ validationSteps: string[];
546
+ risk: "low" | "medium";
547
+ }): CompletionPrMetadata {
548
+ const changesSection =
549
+ args.changedPaths.length > 0
550
+ ? args.changedPaths.map((path) => `- Updated \`${sanitizePrText(path, 180)}\``)
551
+ : [`- Updated worker completion for \`${sanitizePrText(args.job.kind, 80)}\``];
552
+ const validationSection =
553
+ args.validationSteps.length > 0
554
+ ? args.validationSteps.map((step) => `- ${sanitizePrText(step, 200)}`)
555
+ : ["- Not specified by planner"];
556
+
557
+ const body = [
558
+ "### Summary",
559
+ `- Apply WorkerPal completion \`${sanitizePrText(args.job.id, 64)}\` to \`${sanitizePrText(args.integrationBranch, 64)}\`.`,
560
+ `- Integrate commit \`${sanitizePrText(args.commit.sha, 64)}\` from \`${sanitizePrText(args.commit.branch, 120)}\`.`,
561
+ `- Worker: \`${sanitizePrText(args.workerId, 64)}\`.`,
562
+ `- Canonical task request: ${args.taskInstruction ? `\`${sanitizePrText(args.taskInstruction, 220)}\`` : "_(not provided)_"}`,
563
+ "",
564
+ "### Motivation / Context",
565
+ "- Preserve and review autonomous worker output before final merge to base branch.",
566
+ "- Keep integration branch current with queued worker completions.",
567
+ "",
568
+ "### Changes",
569
+ ...changesSection,
570
+ "",
571
+ "### Testing / Validation",
572
+ ...validationSection,
573
+ "- Worker did not provide explicit per-command pass/fail logs in completion summary.",
574
+ "",
575
+ "### Impact / Risk",
576
+ `- Risk level: ${args.risk} (automated worker-generated change; maintainer review required).`,
577
+ "- No secrets or credentials are expected in this PR body.",
578
+ "",
579
+ "### SourceControlManager Note",
580
+ "- Use this worker-provided PR title/body when creating the integration PR.",
581
+ "",
582
+ "### Checklist",
583
+ "- [ ] Tests added/updated where appropriate",
584
+ "- [ ] Validation commands run (or noted as not run)",
585
+ "- [ ] Docs/comments updated if needed",
586
+ "- [ ] No sensitive data (secrets/tokens) committed",
587
+ ].join("\n");
588
+ return { title: args.title, body };
589
+ }
590
+
591
+ function buildCompletionPrMetadata(args: {
592
+ workerId: string;
593
+ integrationBranch: string;
594
+ job: { id: string; taskId: string; kind: string; params?: Record<string, unknown> };
595
+ commit: CommitRef;
596
+ resultSummary: string;
597
+ }): CompletionPrMetadata {
598
+ const changedPaths = inferChangedPaths(args.job.params);
599
+ const validationSteps = inferValidationSteps(args.job.params);
600
+ const taskInstruction = inferTaskInstruction(args.job.params);
601
+ const area = inferPrArea(args.job.kind, changedPaths);
602
+ const prType = inferPrTitleType(args.job.kind, area);
603
+ const summary = derivePrSummary(args.job.kind, args.job.params, args.resultSummary);
604
+ const title = `${prType}(${area}): ${summary}`;
605
+ const risk =
606
+ args.job.kind.startsWith("task.") || args.job.kind.startsWith("file.") ? "medium" : "low";
607
+ const changesLines =
608
+ changedPaths.length > 0
609
+ ? changedPaths.map((path) => `Updated \`${sanitizePrText(path, 180)}\``)
610
+ : [`Updated worker completion for \`${sanitizePrText(args.job.kind, 80)}\``];
611
+ const validationLines =
612
+ validationSteps.length > 0
613
+ ? validationSteps.map((step) => `Planned: ${sanitizePrText(step, 200)}`)
614
+ : ["No explicit planner validation steps were provided."];
615
+ const motivationLines = [
616
+ "Preserve and review autonomous worker output before final merge to base branch.",
617
+ "Keep integration branch current with queued worker completions.",
618
+ ];
619
+ const testingLines = [
620
+ ...validationLines,
621
+ "Worker completion summary did not include explicit command pass/fail output.",
622
+ ];
623
+ const impactLines = [
624
+ `Risk level: ${risk} (automated worker-generated change; maintainer review required).`,
625
+ "No secrets or credentials are expected in this PR body.",
626
+ ];
627
+
628
+ const replacements: Record<string, string> = {
629
+ title,
630
+ area: sanitizePrText(area, 48),
631
+ summary: sanitizePrText(summary, 120),
632
+ completion_id: sanitizePrText(args.job.id, 64),
633
+ task_id: sanitizePrText(args.job.taskId, 64),
634
+ job_kind: sanitizePrText(args.job.kind, 64),
635
+ worker_id: sanitizePrText(args.workerId, 64),
636
+ integration_branch: sanitizePrText(args.integrationBranch, 64),
637
+ commit_sha: sanitizePrText(args.commit.sha, 64),
638
+ commit_branch: sanitizePrText(args.commit.branch, 140),
639
+ result_summary: sanitizePrText(args.resultSummary, 240),
640
+ task_instruction: taskInstruction || "(not provided)",
641
+ motivation_lines: toBulletList(motivationLines),
642
+ target_paths_lines: toBulletList(
643
+ changedPaths.length > 0
644
+ ? changedPaths.map((path) => `\`${sanitizePrText(path, 180)}\``)
645
+ : ["None identified"],
646
+ ),
647
+ validation_plan_lines: toBulletList(validationLines),
648
+ changes_lines: toBulletList(changesLines),
649
+ testing_lines: toBulletList(testingLines),
650
+ impact_lines: toBulletList(impactLines),
651
+ risk_level: risk,
652
+ };
653
+
654
+ const isInstructionalTemplateOutput = (value: string): boolean => {
655
+ const text = value.trim().toLowerCase();
656
+ if (!text) return true;
657
+ if (text.includes("pr description writer")) return true;
658
+ if (text.includes("absolute prohibitions")) return true;
659
+ if (text.includes("required structure")) return true;
660
+ if (text.includes("{{")) return true;
661
+ return false;
662
+ };
663
+
664
+ try {
665
+ const body = loadPromptTemplate("workerpals/pr_description.md", replacements).trim();
666
+ if (!isInstructionalTemplateOutput(body)) {
667
+ return { title, body };
668
+ }
669
+ console.warn(
670
+ `[WorkerPals] PR description template appears instructional/unrendered; using deterministic fallback metadata.`,
671
+ );
672
+ } catch (err) {
673
+ console.warn(`[WorkerPals] Failed to load PR description template: ${String(err)}`);
674
+ }
675
+
676
+ return buildCompletionPrMetadataFallback({
677
+ ...args,
678
+ title,
679
+ changedPaths,
680
+ taskInstruction,
681
+ validationSteps,
682
+ risk,
683
+ });
684
+ }
685
+
686
+ function parseLsRemoteSha(output: string): string | null {
687
+ const firstLine =
688
+ (output ?? "")
689
+ .split(/\r?\n/)
690
+ .map((line) => line.trim())
691
+ .find(Boolean) ?? "";
692
+ const match = firstLine.match(/^([0-9a-f]{40})\s+/i);
693
+ return match ? match[1] : null;
694
+ }
695
+
696
+ async function resolveReReviewNoChangeCommit(
697
+ repo: string,
698
+ params: Record<string, unknown> | null | undefined,
699
+ ): Promise<CommitRef | null> {
700
+ const branch = resolveReviewNoChangeCompletionBranch(params);
701
+ if (!branch) return null;
702
+
703
+ const remoteRef = `refs/heads/${branch}`;
704
+ const lsRemote = await git(repo, ["ls-remote", "origin", remoteRef]);
705
+ if (lsRemote.ok) {
706
+ const sha = parseLsRemoteSha(lsRemote.stdout);
707
+ if (sha) return { branch, sha };
708
+ }
709
+
710
+ const localRefs = [branch, `refs/heads/${branch}`, `origin/${branch}`];
711
+ for (const ref of localRefs) {
712
+ const revParse = await git(repo, ["rev-parse", "--verify", ref]);
713
+ if (revParse.ok) {
714
+ const sha = revParse.stdout.trim();
715
+ if (sha) return { branch, sha };
716
+ }
717
+ }
718
+
719
+ return null;
720
+ }
721
+
722
+ async function enqueueCompletion(
723
+ server: string,
724
+ headers: Record<string, string>,
725
+ workerId: string,
726
+ integrationBranch: string,
727
+ job: {
728
+ id: string;
729
+ taskId: string;
730
+ kind: string;
731
+ sessionId: string;
732
+ params?: Record<string, unknown>;
733
+ },
734
+ commit: CommitRef,
735
+ resultSummary: string,
736
+ ): Promise<boolean> {
737
+ try {
738
+ const reviewAgent =
739
+ job.params?.reviewAgent && typeof job.params.reviewAgent === "object"
740
+ ? (job.params.reviewAgent as Record<string, unknown>)
741
+ : null;
742
+ const prUrl =
743
+ reviewAgent && typeof reviewAgent.prUrl === "string" && reviewAgent.prUrl.trim().length > 0
744
+ ? reviewAgent.prUrl.trim()
745
+ : null;
746
+ const pr = buildCompletionPrMetadata({
747
+ workerId,
748
+ integrationBranch,
749
+ job,
750
+ commit,
751
+ resultSummary,
752
+ });
753
+
754
+ const response = await fetch(`${server}/completions/enqueue`, {
755
+ method: "POST",
756
+ headers,
757
+ body: JSON.stringify({
758
+ jobId: job.id,
759
+ sessionId: job.sessionId,
760
+ commitSha: commit.sha,
761
+ branch: commit.branch,
762
+ message: `${job.kind}: ${job.taskId} (worker PR metadata attached)`,
763
+ prUrl,
764
+ prTitle: pr.title,
765
+ prBody: pr.body,
766
+ }),
767
+ });
768
+
769
+ if (response.ok) {
770
+ console.log(`[WorkerPals] Enqueued completion for job ${job.id} (commit ${commit.sha})`);
771
+ return true;
772
+ } else {
773
+ console.error(
774
+ `[WorkerPals] Failed to enqueue completion: ${response.status} ${await response.text()}`,
775
+ );
776
+ return false;
777
+ }
778
+ } catch (err) {
779
+ console.error(`[WorkerPals] Failed to enqueue completion:`, err);
780
+ return false;
781
+ }
782
+ }
783
+
784
+ function sendCommand(
785
+ server: string,
786
+ sessionId: string,
787
+ headers: Record<string, string>,
788
+ cmd: CommandRequest,
789
+ ): Promise<void> {
790
+ return fetch(`${server}/sessions/${sessionId}/command`, {
791
+ method: "POST",
792
+ headers,
793
+ body: JSON.stringify(cmd),
794
+ })
795
+ .then((res) => {
796
+ if (!res.ok) console.error(`[WorkerPals] Command ${cmd.type} failed: ${res.status}`);
797
+ })
798
+ .catch((err) => console.error(`[WorkerPals] Command ${cmd.type} error:`, err));
799
+ }
800
+
801
+ type WorkerHeartbeatStatus = "idle" | "busy" | "error" | "offline";
802
+ type WorkerRuntimeState = {
803
+ currentJobId: string | null;
804
+ currentSessionId: string | null;
805
+ shutdownRequested: boolean;
806
+ };
807
+
808
+ function buildWorkerHeaders(authToken: string | null): Record<string, string> {
809
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
810
+ if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
811
+ return headers;
812
+ }
813
+
814
+ async function sendWorkerHeartbeat(
815
+ opts: ReturnType<typeof parseArgs>,
816
+ headers: Record<string, string>,
817
+ status: WorkerHeartbeatStatus,
818
+ currentJobId: string | null = null,
819
+ ): Promise<void> {
820
+ try {
821
+ await fetch(`${opts.server}/workers/heartbeat`, {
822
+ method: "POST",
823
+ headers,
824
+ body: JSON.stringify({
825
+ workerId: opts.workerId,
826
+ status,
827
+ currentJobId,
828
+ pollMs: opts.pollMs,
829
+ capabilities: {
830
+ docker: opts.docker,
831
+ labels: opts.labels,
832
+ executor: resolveExecutor(CONFIG),
833
+ requireDocker: opts.requireDocker,
834
+ },
835
+ details: {
836
+ repo: opts.repo,
837
+ baseRef: opts.worktreeBaseRef,
838
+ dockerImage: opts.docker ? opts.dockerImage : null,
839
+ dockerNetworkMode: opts.docker ? opts.dockerNetworkMode : null,
840
+ },
841
+ }),
842
+ });
843
+ } catch (err) {
844
+ console.error(`[WorkerPals] Heartbeat error:`, err);
845
+ }
846
+ }
847
+
848
+ async function failActiveJobOnShutdown(
849
+ opts: ReturnType<typeof parseArgs>,
850
+ headers: Record<string, string>,
851
+ runtimeState: WorkerRuntimeState,
852
+ signalName: string,
853
+ ): Promise<void> {
854
+ const activeJobId = runtimeState.currentJobId;
855
+ if (!activeJobId) return;
856
+
857
+ const message = "Worker process shutting down during claimed job";
858
+ const detail = `worker=${opts.workerId}; signal=${signalName}; action=fail-claimed-job-on-shutdown`;
859
+
860
+ try {
861
+ await fetch(`${opts.server}/jobs/${activeJobId}/fail`, {
862
+ method: "POST",
863
+ headers,
864
+ body: JSON.stringify({ message, detail }),
865
+ });
866
+ } catch (err) {
867
+ console.error(
868
+ `[WorkerPals] Failed to mark active job ${activeJobId} as failed during shutdown:`,
869
+ err,
870
+ );
871
+ }
872
+
873
+ if (runtimeState.currentSessionId) {
874
+ await sendCommand(opts.server, runtimeState.currentSessionId, headers, {
875
+ type: "job_failed",
876
+ payload: {
877
+ jobId: activeJobId,
878
+ message,
879
+ detail,
880
+ },
881
+ from: `worker:${opts.workerId}`,
882
+ });
883
+ }
884
+ }
885
+
886
+ async function workerLoop(
887
+ opts: ReturnType<typeof parseArgs>,
888
+ dockerExecutor: DockerExecutor | null,
889
+ runtimeState: WorkerRuntimeState,
890
+ ): Promise<void> {
891
+ const headers = buildWorkerHeaders(opts.authToken);
892
+
893
+ console.log(`[WorkerPals ${opts.workerId}] Polling ${opts.server} every ${opts.pollMs}ms`);
894
+ if (dockerExecutor) {
895
+ console.log(
896
+ `[WorkerPals ${opts.workerId}] Docker mode enabled (${opts.dockerImage}, network=${opts.dockerNetworkMode})`,
897
+ );
898
+ } else {
899
+ console.log(`[WorkerPals ${opts.workerId}] Direct mode with isolated worktrees enabled`);
900
+ }
901
+ console.log(`[WorkerPals ${opts.workerId}] Executor backend: ${resolveExecutor(CONFIG)}`);
902
+ const heartbeatEveryMs = Math.max(1000, opts.heartbeatMs);
903
+ let lastHeartbeatAt = 0;
904
+
905
+ const maybeHeartbeat = async (
906
+ status: WorkerHeartbeatStatus,
907
+ currentJobId: string | null = null,
908
+ force = false,
909
+ ) => {
910
+ const now = Date.now();
911
+ if (!force && now - lastHeartbeatAt < heartbeatEveryMs) return;
912
+ await sendWorkerHeartbeat(opts, headers, status, currentJobId);
913
+ lastHeartbeatAt = now;
914
+ };
915
+
916
+ await maybeHeartbeat("idle", null, true);
917
+
918
+ while (!runtimeState.shutdownRequested) {
919
+ try {
920
+ await maybeHeartbeat("idle");
921
+ const claimRes = await fetch(`${opts.server}/jobs/claim`, {
922
+ method: "POST",
923
+ headers,
924
+ body: JSON.stringify({ workerId: opts.workerId }),
925
+ });
926
+
927
+ if (claimRes.ok) {
928
+ const data = (await claimRes.json()) as any;
929
+ const job = data.job;
930
+
931
+ if (job) {
932
+ runtimeState.currentJobId = job.id;
933
+ runtimeState.currentSessionId = job.sessionId ?? null;
934
+ console.log(`[WorkerPals] Claimed job ${job.id} (${job.kind})`);
935
+ await maybeHeartbeat("busy", job.id, true);
936
+
937
+ const busyHeartbeat = setInterval(() => {
938
+ void sendWorkerHeartbeat(opts, headers, "busy", job.id);
939
+ }, heartbeatEveryMs);
940
+
941
+ if (job.sessionId) {
942
+ await sendCommand(opts.server, job.sessionId, headers, {
943
+ type: "job_claimed",
944
+ payload: { jobId: job.id, workerId: opts.workerId },
945
+ from: `worker:${opts.workerId}`,
946
+ });
947
+ }
948
+
949
+ let stdoutSeq = 0;
950
+ let stderrSeq = 0;
951
+ let logChain: Promise<void> = Promise.resolve();
952
+ let lastCleanLog = "";
953
+ let lastCleanLogAt = 0;
954
+
955
+ const onLog = job.sessionId
956
+ ? (stream: "stdout" | "stderr", line: string) => {
957
+ const cleaned = sanitizeJobLogLine(line);
958
+ if (!cleaned) return;
959
+ // Print executor logs locally only in debug mode.
960
+ if (LOG.isDebugEnabled()) LOG.debug(`[${stream}] ${cleaned}`);
961
+
962
+ // Drop high-frequency terminal progress redraw spam; keep meaningful lines.
963
+ if (isNoisyProgressLine(cleaned)) return;
964
+
965
+ // Collapse very noisy duplicate lines emitted in tight loops.
966
+ const now = Date.now();
967
+ if (cleaned === lastCleanLog && now - lastCleanLogAt < 1_000) return;
968
+ lastCleanLog = cleaned;
969
+ lastCleanLogAt = now;
970
+ const logTs = new Date(now).toISOString();
971
+
972
+ const seq = stream === "stdout" ? ++stdoutSeq : ++stderrSeq;
973
+ logChain = logChain.then(() =>
974
+ Promise.allSettled([
975
+ sendCommand(opts.server, job.sessionId, headers, {
976
+ type: "job_log",
977
+ payload: { jobId: job.id, stream, seq, line: cleaned, ts: logTs },
978
+ from: `worker:${opts.workerId}`,
979
+ }),
980
+ fetch(`${opts.server}/jobs/${job.id}/log`, {
981
+ method: "POST",
982
+ headers,
983
+ body: JSON.stringify({ stream, seq, message: cleaned, ts: logTs }),
984
+ }),
985
+ ]).then(() => undefined),
986
+ );
987
+ }
988
+ : undefined;
989
+
990
+ let directWorktreePath: string | null = null;
991
+ let executionRepo = opts.repo;
992
+ let result: WorkerJobResult | null = null;
993
+ let recycleWorkerAfterJob = false;
994
+
995
+ try {
996
+ if (!dockerExecutor) {
997
+ directWorktreePath = await createIsolatedWorktree(
998
+ opts.repo,
999
+ job.id,
1000
+ opts.worktreeBaseRef,
1001
+ );
1002
+ executionRepo = directWorktreePath;
1003
+ }
1004
+
1005
+ const parsedParams =
1006
+ typeof job.params === "string"
1007
+ ? (JSON.parse(job.params) as Record<string, unknown>)
1008
+ : job.params;
1009
+
1010
+ const jobData = {
1011
+ id: job.id,
1012
+ taskId: job.taskId,
1013
+ kind: job.kind,
1014
+ params: parsedParams,
1015
+ sessionId: job.sessionId,
1016
+ };
1017
+
1018
+ let cooldownAfterJobMs = 0;
1019
+ const jobStartedAtMs = Date.now();
1020
+ try {
1021
+ result = await runJob(jobData, executionRepo, dockerExecutor, CONFIG, onLog);
1022
+ cooldownAfterJobMs =
1023
+ Number.isFinite(result.cooldownMs) && (result.cooldownMs ?? 0) > 0
1024
+ ? Math.floor(result.cooldownMs ?? 0)
1025
+ : 0;
1026
+ } catch (err) {
1027
+ if (err instanceof DockerExecutionExhaustedError) {
1028
+ cooldownAfterJobMs = Math.max(
1029
+ opts.failureCooldownMs,
1030
+ Number.isFinite(err.cooldownMs) ? err.cooldownMs : 0,
1031
+ );
1032
+ }
1033
+ result = {
1034
+ ok: false,
1035
+ summary: "Job execution failed before completion",
1036
+ stderr: String(err),
1037
+ ...(cooldownAfterJobMs > 0 ? { cooldownMs: cooldownAfterJobMs } : {}),
1038
+ };
1039
+ }
1040
+ if (!result) {
1041
+ result = {
1042
+ ok: false,
1043
+ summary: "Job execution failed before completion",
1044
+ stderr: "Worker result was not produced",
1045
+ };
1046
+ }
1047
+ const jobDurationMs = Math.max(0, Date.now() - jobStartedAtMs);
1048
+
1049
+ await logChain;
1050
+
1051
+ let completionCommit: CommitRef | null = null;
1052
+ if (result.ok && shouldCommit(job.kind, CONFIG)) {
1053
+ if (result.commit) {
1054
+ if (result.commit.sha !== "no-changes") {
1055
+ completionCommit = result.commit;
1056
+ } else {
1057
+ const reReviewCommit = await resolveReReviewNoChangeCommit(
1058
+ executionRepo,
1059
+ parsedParams,
1060
+ );
1061
+ if (reReviewCommit) {
1062
+ completionCommit = reReviewCommit;
1063
+ console.log(
1064
+ `[WorkerPals] Job ${job.id} produced no file changes; enqueuing re-review completion for ${reReviewCommit.branch} @ ${reReviewCommit.sha.slice(0, 8)}.`,
1065
+ );
1066
+ } else {
1067
+ console.log(`[WorkerPals] Job ${job.id} produced no file changes to commit.`);
1068
+ }
1069
+ }
1070
+ } else if (dockerExecutor) {
1071
+ result = {
1072
+ ok: false,
1073
+ summary: `Docker job ${job.id} completed without commit metadata for ${job.kind}`,
1074
+ stderr: [
1075
+ result.stderr,
1076
+ "Refusing unsafe host-side commit fallback while Docker mode is active.",
1077
+ ]
1078
+ .filter(Boolean)
1079
+ .join("\n"),
1080
+ };
1081
+ } else {
1082
+ console.log(`[WorkerPals] Job ${job.id} modified files, creating commit...`);
1083
+ const commitResult = await createJobCommit(
1084
+ executionRepo,
1085
+ opts.workerId,
1086
+ {
1087
+ id: job.id,
1088
+ taskId: job.taskId,
1089
+ kind: job.kind,
1090
+ params: parsedParams,
1091
+ sessionId: job.sessionId,
1092
+ context: "host",
1093
+ },
1094
+ CONFIG,
1095
+ );
1096
+
1097
+ if (commitResult.ok && commitResult.sha && commitResult.branch) {
1098
+ if (commitResult.sha !== "no-changes") {
1099
+ completionCommit = {
1100
+ branch: commitResult.branch,
1101
+ sha: commitResult.sha,
1102
+ };
1103
+ }
1104
+ } else if (commitResult.error) {
1105
+ console.error(`[WorkerPals] Failed to create commit: ${commitResult.error}`);
1106
+ }
1107
+ }
1108
+ }
1109
+
1110
+ if (completionCommit) {
1111
+ const enqueued = await enqueueCompletion(
1112
+ opts.server,
1113
+ headers,
1114
+ opts.workerId,
1115
+ integrationBranchName(),
1116
+ {
1117
+ id: job.id,
1118
+ taskId: job.taskId,
1119
+ kind: job.kind,
1120
+ sessionId: job.sessionId,
1121
+ params: parsedParams,
1122
+ },
1123
+ completionCommit,
1124
+ result.summary,
1125
+ );
1126
+ if (!enqueued && completionCommit.branch.startsWith("refs/pushpals/")) {
1127
+ const cleanupRef = await git(executionRepo, [
1128
+ "update-ref",
1129
+ "-d",
1130
+ completionCommit.branch,
1131
+ ]);
1132
+ if (!cleanupRef.ok) {
1133
+ console.warn(
1134
+ `[WorkerPals] Failed to clean local completion ref ${completionCommit.branch}: ${
1135
+ cleanupRef.stderr || cleanupRef.stdout
1136
+ }`,
1137
+ );
1138
+ }
1139
+ }
1140
+ }
1141
+
1142
+ if (result.ok) {
1143
+ const reviewAgent =
1144
+ parsedParams.reviewAgent && typeof parsedParams.reviewAgent === "object"
1145
+ ? (parsedParams.reviewAgent as Record<string, unknown>)
1146
+ : null;
1147
+ const jobPrUrl =
1148
+ reviewAgent &&
1149
+ typeof reviewAgent.prUrl === "string" &&
1150
+ reviewAgent.prUrl.trim().length > 0
1151
+ ? reviewAgent.prUrl.trim()
1152
+ : null;
1153
+ await fetch(`${opts.server}/jobs/${job.id}/complete`, {
1154
+ method: "POST",
1155
+ headers,
1156
+ body: JSON.stringify({
1157
+ summary: result.summary,
1158
+ durationMs: jobDurationMs,
1159
+ prUrl: jobPrUrl,
1160
+ artifacts: [
1161
+ ...(result.stdout ? [{ kind: "stdout", text: result.stdout }] : []),
1162
+ ...(result.stderr ? [{ kind: "stderr", text: result.stderr }] : []),
1163
+ ],
1164
+ }),
1165
+ });
1166
+ console.log(
1167
+ `[WorkerPals] Job ${job.id} completed in ${formatDurationMs(jobDurationMs)}: ${result.summary}`,
1168
+ );
1169
+ } else {
1170
+ await fetch(`${opts.server}/jobs/${job.id}/fail`, {
1171
+ method: "POST",
1172
+ headers,
1173
+ body: JSON.stringify({
1174
+ message: result.summary,
1175
+ detail: redactSensitiveText(result.stderr ?? ""),
1176
+ durationMs: jobDurationMs,
1177
+ }),
1178
+ });
1179
+ console.log(
1180
+ `[WorkerPals] Job ${job.id} failed in ${formatDurationMs(jobDurationMs)}: ${result.summary}`,
1181
+ );
1182
+ recycleWorkerAfterJob = shouldRecycleWorkerForCodexUnavailableFailure(
1183
+ result.summary,
1184
+ result.stderr,
1185
+ );
1186
+ if (recycleWorkerAfterJob) {
1187
+ console.error(
1188
+ `[WorkerPals] Codex backend unavailable for job ${job.id}; terminating this worker for replacement.`,
1189
+ );
1190
+ }
1191
+ }
1192
+
1193
+ if (job.sessionId) {
1194
+ const responseMode = String(parsedParams.responseMode ?? "")
1195
+ .trim()
1196
+ .toLowerCase();
1197
+ if (responseMode === "assistant_message") {
1198
+ const maxResponseCharsRaw = Number(parsedParams.maxResponseChars ?? 8000);
1199
+ const maxResponseChars =
1200
+ Number.isFinite(maxResponseCharsRaw) && maxResponseCharsRaw >= 256
1201
+ ? Math.min(maxResponseCharsRaw, 20_000)
1202
+ : 8000;
1203
+ const rawText = result.ok
1204
+ ? String(result.stdout ?? result.summary ?? "").trim()
1205
+ : `Worker failed to complete request: ${String(result.summary ?? "unknown error").trim()}`;
1206
+ const assistantText =
1207
+ rawText.length > maxResponseChars
1208
+ ? `${rawText.slice(0, maxResponseChars - 3)}...`
1209
+ : rawText;
1210
+ if (assistantText) {
1211
+ await sendCommand(opts.server, job.sessionId, headers, {
1212
+ type: "assistant_message",
1213
+ payload: { text: assistantText },
1214
+ from: `worker:${opts.workerId}`,
1215
+ });
1216
+ }
1217
+ }
1218
+
1219
+ const eventCmd = result.ok
1220
+ ? {
1221
+ type: "job_completed" as const,
1222
+ payload: {
1223
+ jobId: job.id,
1224
+ summary: result.summary,
1225
+ artifacts: result.stdout
1226
+ ? [{ kind: "log" as const, text: result.stdout }]
1227
+ : undefined,
1228
+ },
1229
+ from: `worker:${opts.workerId}`,
1230
+ }
1231
+ : {
1232
+ type: "job_failed" as const,
1233
+ payload: {
1234
+ jobId: job.id,
1235
+ message: result.summary,
1236
+ detail: redactSensitiveText(result.stderr ?? ""),
1237
+ },
1238
+ from: `worker:${opts.workerId}`,
1239
+ };
1240
+
1241
+ await sendCommand(opts.server, job.sessionId, headers, eventCmd);
1242
+ }
1243
+ } finally {
1244
+ clearInterval(busyHeartbeat);
1245
+ if (
1246
+ !recycleWorkerAfterJob &&
1247
+ job.sessionId &&
1248
+ result?.cooldownMs &&
1249
+ result.cooldownMs > 0
1250
+ ) {
1251
+ await sendCommand(opts.server, job.sessionId, headers, {
1252
+ type: "assistant_message",
1253
+ payload: {
1254
+ text: `WorkerPal is cooling down for ${formatDurationMs(result.cooldownMs)} after transient infrastructure failures.`,
1255
+ },
1256
+ from: `worker:${opts.workerId}`,
1257
+ });
1258
+ }
1259
+ if (!recycleWorkerAfterJob && result?.cooldownMs && result.cooldownMs > 0) {
1260
+ const cooldownMs = Math.max(0, Math.floor(result.cooldownMs));
1261
+ console.warn(
1262
+ `[WorkerPals] Entering cooldown for ${formatDurationMs(cooldownMs)} after retry exhaustion.`,
1263
+ );
1264
+ await maybeHeartbeat("offline", job.id, true);
1265
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, cooldownMs));
1266
+ }
1267
+ await maybeHeartbeat("idle", null, true);
1268
+ runtimeState.currentJobId = null;
1269
+ runtimeState.currentSessionId = null;
1270
+ if (directWorktreePath) {
1271
+ await removeIsolatedWorktree(opts.repo, directWorktreePath).catch((err) => {
1272
+ console.error(`[WorkerPals] Failed to remove isolated worktree: ${String(err)}`);
1273
+ });
1274
+ }
1275
+ if (recycleWorkerAfterJob) {
1276
+ runtimeState.shutdownRequested = true;
1277
+ await maybeHeartbeat("offline", null, true);
1278
+ if (dockerExecutor) {
1279
+ try {
1280
+ await dockerExecutor.shutdown();
1281
+ } catch (err) {
1282
+ console.error(`[WorkerPals] Docker shutdown cleanup failed: ${String(err)}`);
1283
+ }
1284
+ }
1285
+ process.exit(CODEX_UNAVAILABLE_WORKER_EXIT_CODE);
1286
+ }
1287
+ }
1288
+ }
1289
+ }
1290
+ } catch (err) {
1291
+ if (runtimeState.shutdownRequested) break;
1292
+ console.error(`[WorkerPals] Poll error:`, err);
1293
+ await maybeHeartbeat("error", null, true);
1294
+ }
1295
+
1296
+ if (runtimeState.shutdownRequested) break;
1297
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, opts.pollMs));
1298
+ }
1299
+ }
1300
+
1301
+ async function main(): Promise<void> {
1302
+ const opts = parseArgs();
1303
+ const llmConfig = workerLlmConfig(CONFIG);
1304
+ opts.gitToken = await resolveWorkerGitToken(opts.repo, opts.gitToken);
1305
+
1306
+ console.log(`[WorkerPals] PushPals WorkerPals Daemon (${opts.workerId})`);
1307
+ console.log(`[WorkerPals] Server: ${opts.server}`);
1308
+ console.log(`[WorkerPals] Repo: ${opts.repo}`);
1309
+ console.log(
1310
+ `[WorkerPals] Worker LLM: model=${llmConfig.model} provider=${llmConfig.provider} baseUrl=${llmConfig.baseUrl || "(unset)"}`,
1311
+ );
1312
+ opts.worktreeBaseRef = await resolveWorktreeBaseRef(opts.repo, opts.worktreeBaseRef);
1313
+ console.log(`[WorkerPals] Worktree base ref: ${opts.worktreeBaseRef}`);
1314
+
1315
+ let dockerExecutor: DockerExecutor | null = null;
1316
+
1317
+ if (opts.docker) {
1318
+ const dockerAvailable = await DockerExecutor.isDockerAvailable();
1319
+ if (!dockerAvailable) {
1320
+ const message =
1321
+ "[WorkerPals] Docker is not available. Make sure Docker is installed and running.";
1322
+ if (opts.requireDocker) {
1323
+ console.error(message);
1324
+ console.error("[WorkerPals] Exiting because --require-docker is enabled.");
1325
+ process.exit(1);
1326
+ }
1327
+ console.error(message);
1328
+ console.error("[WorkerPals] Falling back to direct mode (isolated worktrees)...");
1329
+ } else {
1330
+ dockerExecutor = new DockerExecutor({
1331
+ imageName: opts.dockerImage,
1332
+ repo: opts.repo,
1333
+ workerId: opts.workerId,
1334
+ gitToken: opts.gitToken ?? undefined,
1335
+ timeoutMs: opts.dockerTimeout,
1336
+ idleTimeoutMs: opts.dockerIdleTimeout,
1337
+ networkMode: opts.dockerNetworkMode,
1338
+ baseRef: opts.worktreeBaseRef,
1339
+ config: CONFIG,
1340
+ });
1341
+
1342
+ await dockerExecutor.cleanupOrphanedWorktrees();
1343
+
1344
+ const imageReady = await dockerExecutor.pullImage();
1345
+ if (!imageReady) {
1346
+ console.error(`[WorkerPals] Failed to prepare Docker image: ${opts.dockerImage}`);
1347
+ if (opts.requireDocker) {
1348
+ console.error("[WorkerPals] Exiting because --require-docker is enabled.");
1349
+ process.exit(1);
1350
+ }
1351
+ console.error("[WorkerPals] Falling back to direct mode (isolated worktrees)...");
1352
+ dockerExecutor = null;
1353
+ } else if (!CONFIG.workerpals.skipDockerSelfCheck) {
1354
+ console.log(
1355
+ "[WorkerPals] Running Docker startup self-check (git/worktree in container)...",
1356
+ );
1357
+ try {
1358
+ await dockerExecutor.validateWorktreeGitInterop();
1359
+ } catch (err) {
1360
+ console.error(
1361
+ `[WorkerPals] Docker startup self-check failed: ${err instanceof Error ? err.message : String(err)}`,
1362
+ );
1363
+ if (opts.requireDocker) {
1364
+ console.error("[WorkerPals] Exiting because --require-docker is enabled.");
1365
+ process.exit(1);
1366
+ }
1367
+ console.error("[WorkerPals] Falling back to direct mode (isolated worktrees)...");
1368
+ dockerExecutor = null;
1369
+ }
1370
+ }
1371
+ }
1372
+ } else if (opts.requireDocker) {
1373
+ console.error("[WorkerPals] --require-docker was provided without --docker.");
1374
+ process.exit(1);
1375
+ }
1376
+
1377
+ const runtimeState: WorkerRuntimeState = {
1378
+ currentJobId: null,
1379
+ currentSessionId: null,
1380
+ shutdownRequested: false,
1381
+ };
1382
+ const headers = buildWorkerHeaders(opts.authToken);
1383
+ let shutdownTriggered = false;
1384
+ const shutdownAndExit = (signalName: string, code: number) => {
1385
+ if (shutdownTriggered) return;
1386
+ shutdownTriggered = true;
1387
+ runtimeState.shutdownRequested = true;
1388
+ console.warn(`[WorkerPals] Shutdown signal received (${signalName}); draining active work...`);
1389
+
1390
+ const withTimeout = async (promise: Promise<unknown>, timeoutMs = 3_000) => {
1391
+ await Promise.race([
1392
+ promise.catch(() => undefined),
1393
+ new Promise((resolvePromise) => setTimeout(resolvePromise, timeoutMs)),
1394
+ ]);
1395
+ };
1396
+
1397
+ void (async () => {
1398
+ await withTimeout(
1399
+ sendWorkerHeartbeat(opts, headers, "offline", runtimeState.currentJobId ?? null),
1400
+ );
1401
+ await withTimeout(failActiveJobOnShutdown(opts, headers, runtimeState, signalName));
1402
+ if (dockerExecutor) {
1403
+ await withTimeout(
1404
+ dockerExecutor.shutdown().catch((err) => {
1405
+ console.error(`[WorkerPals] Docker shutdown cleanup failed: ${String(err)}`);
1406
+ }),
1407
+ 10_000,
1408
+ );
1409
+ }
1410
+ process.exit(code);
1411
+ })();
1412
+ };
1413
+
1414
+ process.once("SIGINT", () => shutdownAndExit("SIGINT", 130));
1415
+ process.once("SIGTERM", () => shutdownAndExit("SIGTERM", 143));
1416
+ if (process.platform === "win32") {
1417
+ process.once("SIGBREAK", () => shutdownAndExit("SIGBREAK", 131));
1418
+ }
1419
+ process.once("exit", () => {
1420
+ runtimeState.shutdownRequested = true;
1421
+ if (shutdownTriggered) return;
1422
+ shutdownTriggered = true;
1423
+ if (dockerExecutor) {
1424
+ void dockerExecutor.shutdown().catch((err) => {
1425
+ console.error(`[WorkerPals] Docker shutdown cleanup failed: ${String(err)}`);
1426
+ });
1427
+ }
1428
+ });
1429
+
1430
+ workerLoop(opts, dockerExecutor, runtimeState).catch((err) => {
1431
+ console.error("[WorkerPals] Fatal:", err);
1432
+ process.exit(1);
1433
+ });
1434
+ }
1435
+
1436
+ main();