@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.
- package/dist/pushpals-cli.js +277 -12
- package/package.json +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 +111 -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 +297 -0
- package/runtime/sandbox/packages/shared/src/communication.ts +313 -0
- package/runtime/sandbox/packages/shared/src/config.ts +2201 -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 +100 -0
- package/runtime/sandbox/packages/shared/src/local_network.ts +101 -0
- package/runtime/sandbox/packages/shared/src/localbuddy_runtime.ts +329 -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,3063 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracted job execution logic.
|
|
3
|
+
* Used by both the host Worker (direct mode) and the Docker job runner.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, unlinkSync } from "fs";
|
|
7
|
+
import { resolve } from "path";
|
|
8
|
+
import {
|
|
9
|
+
deriveAutonomyComponentArea,
|
|
10
|
+
loadPromptTemplate,
|
|
11
|
+
loadPushPalsConfig,
|
|
12
|
+
matchesGlob,
|
|
13
|
+
normalizeAutonomyComponentArea,
|
|
14
|
+
normalizeTargetPath,
|
|
15
|
+
validateScopeInvariants,
|
|
16
|
+
type AutonomyComponentArea,
|
|
17
|
+
} from "shared";
|
|
18
|
+
import { resolveExecutor, type WorkerpalsRuntimeConfig } from "./common/executor_backend.js";
|
|
19
|
+
import type { JobResult } from "./common/types.js";
|
|
20
|
+
import {
|
|
21
|
+
compactJobOutput,
|
|
22
|
+
truncate,
|
|
23
|
+
type OutputCompactionPolicy,
|
|
24
|
+
} from "./common/execution_utils.js";
|
|
25
|
+
// Re-export shared utilities for backward compatibility with external consumers.
|
|
26
|
+
export { compactJobOutput, truncate, streamLines } from "./common/execution_utils.js";
|
|
27
|
+
export { extractClarificationQuestionFromOutput } from "./backends/openhands_task_execute.js";
|
|
28
|
+
import { getBackendTaskExecutor } from "./backends/task_execute_registry.js";
|
|
29
|
+
|
|
30
|
+
const DEFAULT_CONFIG = loadPushPalsConfig();
|
|
31
|
+
|
|
32
|
+
interface TaskExecutePlanning {
|
|
33
|
+
intent: TaskExecuteIntent;
|
|
34
|
+
riskLevel: TaskExecuteRisk;
|
|
35
|
+
targetPaths?: string[];
|
|
36
|
+
scope: {
|
|
37
|
+
readAnywhere: boolean;
|
|
38
|
+
writeAllowed: boolean;
|
|
39
|
+
writeGlobs?: string[];
|
|
40
|
+
forbiddenGlobs?: string[];
|
|
41
|
+
maxFilesToEdit?: number;
|
|
42
|
+
};
|
|
43
|
+
discovery?: {
|
|
44
|
+
ripgrepQueries: string[];
|
|
45
|
+
likelyDirs?: string[];
|
|
46
|
+
keywords?: string[];
|
|
47
|
+
};
|
|
48
|
+
acceptanceCriteria: string[];
|
|
49
|
+
validationSteps: string[];
|
|
50
|
+
queuePriority: TaskExecutePriority;
|
|
51
|
+
queueWaitBudgetMs: number;
|
|
52
|
+
executionBudgetMs: number;
|
|
53
|
+
finalizationBudgetMs: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ValidationExecutionResult {
|
|
57
|
+
step: string;
|
|
58
|
+
command: string;
|
|
59
|
+
ok: boolean;
|
|
60
|
+
exitCode: number;
|
|
61
|
+
stdout: string;
|
|
62
|
+
stderr: string;
|
|
63
|
+
elapsedMs: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface DeterministicQualityResult {
|
|
67
|
+
ok: boolean;
|
|
68
|
+
skipped: boolean;
|
|
69
|
+
issues: string[];
|
|
70
|
+
changedPaths: string[];
|
|
71
|
+
changedTestPaths: string[];
|
|
72
|
+
validationRuns: ValidationExecutionResult[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface CriticReview {
|
|
76
|
+
score: number;
|
|
77
|
+
findings: string[];
|
|
78
|
+
mustFix: string[];
|
|
79
|
+
revisionGuidance: string;
|
|
80
|
+
raw: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Utilities ───────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export function shouldCommit(
|
|
86
|
+
kind: string,
|
|
87
|
+
runtimeConfig: WorkerpalsRuntimeConfig = DEFAULT_CONFIG,
|
|
88
|
+
): boolean {
|
|
89
|
+
const configured = Array.isArray(runtimeConfig.workerpals.fileModifyingJobs)
|
|
90
|
+
? runtimeConfig.workerpals.fileModifyingJobs
|
|
91
|
+
: [];
|
|
92
|
+
const fallback = ["task.execute"];
|
|
93
|
+
const jobs = configured.length > 0 ? configured : fallback;
|
|
94
|
+
return jobs.includes(kind);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function outputPolicyForRuntime(
|
|
98
|
+
runtimeConfig: WorkerpalsRuntimeConfig,
|
|
99
|
+
): Partial<OutputCompactionPolicy> {
|
|
100
|
+
return {
|
|
101
|
+
maxOutputChars: runtimeConfig.workerpals.outputMaxChars,
|
|
102
|
+
maxOutputLines: runtimeConfig.workerpals.outputMaxLines,
|
|
103
|
+
maxOutputHeadLines: runtimeConfig.workerpals.outputMaxHeadLines,
|
|
104
|
+
executorResultPrefix: runtimeConfig.workerpals.executorResultPrefix,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function toSingleLine(value: unknown, max = 240): string {
|
|
109
|
+
const text = String(value ?? "")
|
|
110
|
+
.replace(/\s+/g, " ")
|
|
111
|
+
.trim();
|
|
112
|
+
if (!text) return "";
|
|
113
|
+
return text.length > max ? `${text.slice(0, Math.max(1, max - 3))}...` : text;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function redactSensitiveText(value: string): string {
|
|
117
|
+
let out = String(value ?? "");
|
|
118
|
+
if (!out) return "";
|
|
119
|
+
// redact URL userinfo credentials: https://user:pass@host -> https://***@host
|
|
120
|
+
out = out.replace(/(https?:\/\/)[^@\s/]+@/gi, "$1***@");
|
|
121
|
+
// redact malformed/encoded scheme userinfo from legacy rewrite bugs: https%3A//user%3Apass@host
|
|
122
|
+
out = out.replace(/https%3a\/\/[^@\s/]+@/gi, "https%3A//***@");
|
|
123
|
+
// redact bearer tokens
|
|
124
|
+
out = out.replace(/\b(Bearer\s+)[A-Za-z0-9._\-:+/=]+\b/gi, "$1***");
|
|
125
|
+
// redact common VCS token shapes
|
|
126
|
+
out = out.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "gh***");
|
|
127
|
+
out = out.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "github_pat_***");
|
|
128
|
+
out = out.replace(/\bglpat-[A-Za-z0-9\-_]{20,}\b/gi, "glpat-***");
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function buildCriticRevisionIssues(
|
|
133
|
+
critic: { score: number; mustFix: string[] } | null | undefined,
|
|
134
|
+
qualityCriticMinScore: number,
|
|
135
|
+
): string[] {
|
|
136
|
+
if (!critic) return [];
|
|
137
|
+
if (critic.score >= qualityCriticMinScore) return [];
|
|
138
|
+
return [
|
|
139
|
+
`Critic score ${critic.score.toFixed(1)} is below required threshold ${qualityCriticMinScore}.`,
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resolveReviewFixCompletionBranch(
|
|
144
|
+
value: unknown,
|
|
145
|
+
fallbackBranch: string,
|
|
146
|
+
): { branch: string; overridden: boolean } {
|
|
147
|
+
if (typeof value !== "string") {
|
|
148
|
+
return { branch: fallbackBranch, overridden: false };
|
|
149
|
+
}
|
|
150
|
+
const trimmed = value.trim();
|
|
151
|
+
if (!trimmed) return { branch: fallbackBranch, overridden: false };
|
|
152
|
+
const withoutPrefix = trimmed.replace(/^refs\/heads\//, "");
|
|
153
|
+
const normalized = withoutPrefix
|
|
154
|
+
.replace(/\\/g, "/")
|
|
155
|
+
.replace(/\/+/g, "/")
|
|
156
|
+
.replace(/^\/+|\/+$/g, "");
|
|
157
|
+
if (!normalized.startsWith("agent/")) return { branch: fallbackBranch, overridden: false };
|
|
158
|
+
if (
|
|
159
|
+
normalized.includes("..") ||
|
|
160
|
+
normalized.includes("@{") ||
|
|
161
|
+
normalized.endsWith(".") ||
|
|
162
|
+
normalized.endsWith(".lock")
|
|
163
|
+
) {
|
|
164
|
+
return { branch: fallbackBranch, overridden: false };
|
|
165
|
+
}
|
|
166
|
+
if (/[~^:?*\[\]\s]/.test(normalized)) return { branch: fallbackBranch, overridden: false };
|
|
167
|
+
return { branch: normalized, overridden: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function resolveReviewNoChangeCompletionBranch(
|
|
171
|
+
params: Record<string, unknown> | null | undefined,
|
|
172
|
+
): string | null {
|
|
173
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) return null;
|
|
174
|
+
const reviewAgent =
|
|
175
|
+
params.reviewAgent &&
|
|
176
|
+
typeof params.reviewAgent === "object" &&
|
|
177
|
+
!Array.isArray(params.reviewAgent)
|
|
178
|
+
? (params.reviewAgent as Record<string, unknown>)
|
|
179
|
+
: null;
|
|
180
|
+
const reviewAgentHeadRef = reviewAgent?.prHeadRef;
|
|
181
|
+
const candidate = params.completionBranch ?? reviewAgentHeadRef;
|
|
182
|
+
const resolved = resolveReviewFixCompletionBranch(candidate, "");
|
|
183
|
+
return resolved.overridden ? resolved.branch : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function normalizeChatCompletionsEndpoint(endpoint: string): string {
|
|
187
|
+
const source = endpoint.trim().replace(/\/+$/, "");
|
|
188
|
+
if (!source) return "http://127.0.0.1:1234/v1/chat/completions";
|
|
189
|
+
if (source.endsWith("/chat/completions")) return source;
|
|
190
|
+
if (source.endsWith("/v1")) return `${source}/chat/completions`;
|
|
191
|
+
return `${source}/v1/chat/completions`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function splitArgs(raw: string): string[] {
|
|
195
|
+
const out: string[] = [];
|
|
196
|
+
let current = "";
|
|
197
|
+
let quote: '"' | "'" | null = null;
|
|
198
|
+
let escaped = false;
|
|
199
|
+
for (const ch of raw.trim()) {
|
|
200
|
+
if (escaped) {
|
|
201
|
+
current += ch;
|
|
202
|
+
escaped = false;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (ch === "\\" && quote !== "'") {
|
|
206
|
+
escaped = true;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (quote) {
|
|
210
|
+
if (ch === quote) {
|
|
211
|
+
quote = null;
|
|
212
|
+
} else {
|
|
213
|
+
current += ch;
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (ch === '"' || ch === "'") {
|
|
218
|
+
quote = ch;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (/\s/.test(ch)) {
|
|
222
|
+
if (current.length > 0) {
|
|
223
|
+
out.push(current);
|
|
224
|
+
current = "";
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
current += ch;
|
|
229
|
+
}
|
|
230
|
+
if (escaped) current += "\\";
|
|
231
|
+
if (current.length > 0) out.push(current);
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseJsonObjectLoose(text: string): Record<string, unknown> | null {
|
|
236
|
+
const trimmed = text.trim();
|
|
237
|
+
if (!trimmed) return null;
|
|
238
|
+
try {
|
|
239
|
+
const parsed = JSON.parse(trimmed);
|
|
240
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
241
|
+
return parsed as Record<string, unknown>;
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
// fall through
|
|
245
|
+
}
|
|
246
|
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)?.[1];
|
|
247
|
+
if (fenced) {
|
|
248
|
+
try {
|
|
249
|
+
const parsed = JSON.parse(fenced);
|
|
250
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
251
|
+
return parsed as Record<string, unknown>;
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// fall through
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const firstBrace = trimmed.indexOf("{");
|
|
258
|
+
const lastBrace = trimmed.lastIndexOf("}");
|
|
259
|
+
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
260
|
+
try {
|
|
261
|
+
const parsed = JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
|
|
262
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
263
|
+
return parsed as Record<string, unknown>;
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// fall through
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const COMMIT_MSG_MAX_DIFF_CHARS = 120_000;
|
|
273
|
+
|
|
274
|
+
const SHELL_CONTROL_TOKENS = new Set(["&&", "||", ";", "|"]);
|
|
275
|
+
|
|
276
|
+
export function tokenizeValidationCommandArgv(command: string): string[] | null {
|
|
277
|
+
const trimmed = command.trim();
|
|
278
|
+
if (!trimmed) return null;
|
|
279
|
+
|
|
280
|
+
const out: string[] = [];
|
|
281
|
+
let current = "";
|
|
282
|
+
let quote: "'" | '"' | null = null;
|
|
283
|
+
|
|
284
|
+
const pushCurrent = () => {
|
|
285
|
+
if (!current) return;
|
|
286
|
+
out.push(current);
|
|
287
|
+
current = "";
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
for (const ch of trimmed) {
|
|
291
|
+
if (quote) {
|
|
292
|
+
if (ch === quote) {
|
|
293
|
+
quote = null;
|
|
294
|
+
} else {
|
|
295
|
+
current += ch;
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (ch === "'" || ch === '"') {
|
|
301
|
+
quote = ch;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (/\s/.test(ch)) {
|
|
305
|
+
pushCurrent();
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
current += ch;
|
|
309
|
+
}
|
|
310
|
+
if (quote) return null;
|
|
311
|
+
pushCurrent();
|
|
312
|
+
if (out.length === 0) return null;
|
|
313
|
+
if (out.some((token) => SHELL_CONTROL_TOKENS.has(token))) return null;
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function runValidationCommand(
|
|
318
|
+
repo: string,
|
|
319
|
+
command: string,
|
|
320
|
+
timeoutMs: number,
|
|
321
|
+
outputPolicy: Partial<OutputCompactionPolicy>,
|
|
322
|
+
): Promise<ValidationExecutionResult> {
|
|
323
|
+
const argv = tokenizeValidationCommandArgv(command);
|
|
324
|
+
if (!argv) {
|
|
325
|
+
return {
|
|
326
|
+
step: command,
|
|
327
|
+
command,
|
|
328
|
+
ok: false,
|
|
329
|
+
exitCode: 2,
|
|
330
|
+
stdout: "",
|
|
331
|
+
stderr:
|
|
332
|
+
"Validation command could not be parsed safely. Use a plain command without shell chaining/pipes.",
|
|
333
|
+
elapsedMs: 1,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const startedAt = Date.now();
|
|
337
|
+
const proc = Bun.spawn(argv, {
|
|
338
|
+
cwd: repo,
|
|
339
|
+
stdout: "pipe",
|
|
340
|
+
stderr: "pipe",
|
|
341
|
+
});
|
|
342
|
+
let timedOut = false;
|
|
343
|
+
const timer = setTimeout(
|
|
344
|
+
() => {
|
|
345
|
+
timedOut = true;
|
|
346
|
+
try {
|
|
347
|
+
proc.kill();
|
|
348
|
+
} catch {
|
|
349
|
+
// ignore
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
Math.max(1_000, timeoutMs),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
356
|
+
new Response(proc.stdout).text(),
|
|
357
|
+
new Response(proc.stderr).text(),
|
|
358
|
+
proc.exited,
|
|
359
|
+
]);
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
step: command,
|
|
364
|
+
command,
|
|
365
|
+
ok: !timedOut && exitCode === 0,
|
|
366
|
+
exitCode: timedOut ? 124 : exitCode,
|
|
367
|
+
stdout: compactJobOutput(stdout.trim(), outputPolicy),
|
|
368
|
+
stderr: compactJobOutput(stderr.trim(), outputPolicy),
|
|
369
|
+
elapsedMs: Math.max(1, Date.now() - startedAt),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function stripAnsiControlSequences(value: string): string {
|
|
374
|
+
return value.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function parseChangedPathsFromStatus(statusOutput: string): string[] {
|
|
378
|
+
const out: string[] = [];
|
|
379
|
+
const seen = new Set<string>();
|
|
380
|
+
const addPath = (rawPath: string) => {
|
|
381
|
+
let path = rawPath;
|
|
382
|
+
if (path.includes(" -> ")) {
|
|
383
|
+
path = path.split(" -> ", 2)[1] ?? path;
|
|
384
|
+
}
|
|
385
|
+
path = path.trim();
|
|
386
|
+
if (!path || seen.has(path)) return;
|
|
387
|
+
seen.add(path);
|
|
388
|
+
out.push(path);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const normalizedOutput = stripAnsiControlSequences(statusOutput);
|
|
392
|
+
if (normalizedOutput.includes("\u0000")) {
|
|
393
|
+
const entries = normalizedOutput.split("\u0000");
|
|
394
|
+
for (let i = 0; i < entries.length; i++) {
|
|
395
|
+
const raw = (entries[i] ?? "").replace(/\r$/, "");
|
|
396
|
+
if (!raw.trim()) continue;
|
|
397
|
+
const porcelain = raw.match(/^(.{2}) (.*)$/);
|
|
398
|
+
if (!porcelain) {
|
|
399
|
+
addPath(raw);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const status = porcelain[1] ?? "";
|
|
403
|
+
let path = porcelain[2] ?? "";
|
|
404
|
+
if ((status.includes("R") || status.includes("C")) && i + 1 < entries.length) {
|
|
405
|
+
const renamedTo = entries[i + 1] ?? "";
|
|
406
|
+
if (renamedTo) {
|
|
407
|
+
path = renamedTo;
|
|
408
|
+
i += 1;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
addPath(path);
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const line of normalizedOutput.split(/\r?\n/)) {
|
|
417
|
+
const raw = line.replace(/\r$/, "");
|
|
418
|
+
if (!raw.trim()) continue;
|
|
419
|
+
// git status --porcelain output is "<XY><space><path>".
|
|
420
|
+
// Be tolerant of callers that accidentally trimmed leading space on the first line.
|
|
421
|
+
let path = "";
|
|
422
|
+
const porcelain = raw.match(/^.. (.+)$/);
|
|
423
|
+
if (porcelain?.[1]) {
|
|
424
|
+
path = porcelain[1];
|
|
425
|
+
} else {
|
|
426
|
+
const degraded = raw.match(/^. (.+)$/);
|
|
427
|
+
if (degraded?.[1]) {
|
|
428
|
+
path = degraded[1];
|
|
429
|
+
} else {
|
|
430
|
+
const loose = raw.match(/^[A-Z?]{1,2}\s+(.+)$/i);
|
|
431
|
+
path = loose?.[1] ?? raw;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
addPath(path);
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function isLikelyTestPath(path: string): boolean {
|
|
440
|
+
const normalized = path.replace(/\\/g, "/").toLowerCase();
|
|
441
|
+
return (
|
|
442
|
+
normalized.includes("/tests/") ||
|
|
443
|
+
normalized.includes("/test/") ||
|
|
444
|
+
normalized.includes("__tests__/") ||
|
|
445
|
+
/\.test\.[a-z0-9]+$/i.test(normalized) ||
|
|
446
|
+
/\.spec\.[a-z0-9]+$/i.test(normalized)
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function extractRunnableValidationCommand(step: string): string | null {
|
|
451
|
+
const trimmed = step.trim();
|
|
452
|
+
if (!trimmed) return null;
|
|
453
|
+
|
|
454
|
+
const fenced = trimmed.match(/`([^`]+)`/)?.[1]?.trim();
|
|
455
|
+
if (fenced) return fenced;
|
|
456
|
+
|
|
457
|
+
const lower = trimmed.toLowerCase();
|
|
458
|
+
const maybeStripped = lower.startsWith("run ")
|
|
459
|
+
? trimmed.slice(4).trim()
|
|
460
|
+
: lower.startsWith("execute ")
|
|
461
|
+
? trimmed.slice(8).trim()
|
|
462
|
+
: trimmed;
|
|
463
|
+
const firstToken = maybeStripped.split(/\s+/, 1)[0]?.toLowerCase() ?? "";
|
|
464
|
+
const runnable = new Set(["bun", "npm", "pnpm", "yarn", "pytest", "python", "uv", "coverage"]);
|
|
465
|
+
if (runnable.has(firstToken)) return maybeStripped;
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function inferFallbackValidationCommandsForTestTask(
|
|
470
|
+
instruction: string,
|
|
471
|
+
targetPath: string | undefined,
|
|
472
|
+
planning: TaskExecutePlanning,
|
|
473
|
+
changedTestPaths: string[],
|
|
474
|
+
): string[] {
|
|
475
|
+
const candidates: string[] = [];
|
|
476
|
+
const seen = new Set<string>();
|
|
477
|
+
const add = (command: string) => {
|
|
478
|
+
const trimmed = command.trim();
|
|
479
|
+
if (!trimmed) return;
|
|
480
|
+
const key = trimmed.toLowerCase();
|
|
481
|
+
if (seen.has(key)) return;
|
|
482
|
+
seen.add(key);
|
|
483
|
+
candidates.push(trimmed);
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const lowerInstruction = instruction.toLowerCase();
|
|
487
|
+
const pythonSignal =
|
|
488
|
+
/\b(pytest|python)\b/.test(lowerInstruction) ||
|
|
489
|
+
changedTestPaths.some((entry) => entry.toLowerCase().endsWith(".py"));
|
|
490
|
+
|
|
491
|
+
const normalizedTarget = (targetPath ?? "").replace(/\\/g, "/").trim();
|
|
492
|
+
if (normalizedTarget && isLikelyTestPath(normalizedTarget)) {
|
|
493
|
+
add(pythonSignal ? `pytest ${normalizedTarget}` : `bun test ${normalizedTarget}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (changedTestPaths.length > 0) {
|
|
497
|
+
const focused = changedTestPaths.slice(0, 4).join(" ");
|
|
498
|
+
add(pythonSignal ? `pytest ${focused}` : `bun test ${focused}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const scopeHints = [
|
|
502
|
+
targetPath ?? "",
|
|
503
|
+
...(planning.targetPaths ?? []),
|
|
504
|
+
...(planning.scope.writeGlobs ?? []),
|
|
505
|
+
...(planning.discovery?.likelyDirs ?? []),
|
|
506
|
+
]
|
|
507
|
+
.map((entry) => entry.replace(/\\/g, "/").trim())
|
|
508
|
+
.filter(Boolean);
|
|
509
|
+
const appRoot = scopeHints
|
|
510
|
+
.map((entry) => {
|
|
511
|
+
const match = entry.match(/^apps\/[^/]+/i);
|
|
512
|
+
return match?.[0] ?? "";
|
|
513
|
+
})
|
|
514
|
+
.find(Boolean);
|
|
515
|
+
if (appRoot) {
|
|
516
|
+
add(pythonSignal ? `pytest ${appRoot}` : `bun --cwd ${appRoot} test`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Prefer scoped validation; only fall back to full-suite test runs when no scope is available.
|
|
520
|
+
if (candidates.length === 0) {
|
|
521
|
+
add(pythonSignal ? "pytest" : "bun test");
|
|
522
|
+
}
|
|
523
|
+
return candidates.slice(0, 4);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function isTestFocusedTask(
|
|
527
|
+
instruction: string,
|
|
528
|
+
planning: TaskExecutePlanning,
|
|
529
|
+
targetPath?: string,
|
|
530
|
+
): boolean {
|
|
531
|
+
const lowerInstruction = instruction.toLowerCase();
|
|
532
|
+
if (
|
|
533
|
+
/\b(test|tests|coverage|unit test|integration test|unittest|pytest)\b/.test(lowerInstruction)
|
|
534
|
+
) {
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
if (targetPath && isLikelyTestPath(targetPath)) return true;
|
|
538
|
+
const pathHints = [
|
|
539
|
+
...(planning.scope.writeGlobs ?? []),
|
|
540
|
+
...(planning.discovery?.likelyDirs ?? []),
|
|
541
|
+
];
|
|
542
|
+
if (pathHints.some((entry) => isLikelyTestPath(entry))) return true;
|
|
543
|
+
if (
|
|
544
|
+
planning.validationSteps.some((entry) =>
|
|
545
|
+
/\b(test|tests|coverage|pytest|vitest|jest|bun test)\b/i.test(entry),
|
|
546
|
+
)
|
|
547
|
+
) {
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
if (
|
|
551
|
+
planning.acceptanceCriteria.some((entry) =>
|
|
552
|
+
/\b(test|tests|coverage|unit|integration|negative|invalid|valid)\b/i.test(entry),
|
|
553
|
+
)
|
|
554
|
+
) {
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function hasBalancedPositiveNegativeAssertions(paths: string[], repo: string): boolean {
|
|
561
|
+
const negativeSignal =
|
|
562
|
+
/\b(invalid|negative|error|throw|reject|null|undefined|non[- ]?existent|toThrow|toBeNull|toBeUndefined|<\s*0|<=\s*0)\b/i;
|
|
563
|
+
let positiveAssertions = 0;
|
|
564
|
+
let negativeAssertions = 0;
|
|
565
|
+
|
|
566
|
+
for (const rel of paths) {
|
|
567
|
+
const fullPath = resolve(repo, rel);
|
|
568
|
+
let content = "";
|
|
569
|
+
try {
|
|
570
|
+
content = readFileSync(fullPath, "utf-8");
|
|
571
|
+
} catch {
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
for (const line of content.split(/\r?\n/)) {
|
|
575
|
+
if (!/\b(expect\(|assert\s+)/.test(line)) continue;
|
|
576
|
+
if (negativeSignal.test(line)) negativeAssertions += 1;
|
|
577
|
+
else positiveAssertions += 1;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return positiveAssertions > 0 && negativeAssertions > 0;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function runDeterministicQualityGate(
|
|
585
|
+
repo: string,
|
|
586
|
+
params: Record<string, unknown>,
|
|
587
|
+
runtimeConfig: WorkerpalsRuntimeConfig,
|
|
588
|
+
onLog?: (stream: "stdout" | "stderr", line: string) => void,
|
|
589
|
+
): Promise<DeterministicQualityResult> {
|
|
590
|
+
const instruction = String(params.instruction ?? "");
|
|
591
|
+
const targetPath = String(params.targetPath ?? params.path ?? "").trim() || undefined;
|
|
592
|
+
const planning = params.planning as TaskExecutePlanning;
|
|
593
|
+
const isTestTask = isTestFocusedTask(instruction, planning, targetPath);
|
|
594
|
+
if (!isTestTask) {
|
|
595
|
+
return {
|
|
596
|
+
ok: true,
|
|
597
|
+
skipped: true,
|
|
598
|
+
issues: [],
|
|
599
|
+
changedPaths: [],
|
|
600
|
+
changedTestPaths: [],
|
|
601
|
+
validationRuns: [],
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const statusResult = await git(repo, ["status", "--porcelain"]);
|
|
606
|
+
const changedPaths = statusResult.ok ? parseChangedPathsFromStatus(statusResult.stdout) : [];
|
|
607
|
+
const changedTestPaths = changedPaths.filter((path) => isLikelyTestPath(path));
|
|
608
|
+
const issues: string[] = [];
|
|
609
|
+
if (changedTestPaths.length === 0) {
|
|
610
|
+
issues.push("No relevant test file was modified for this test-focused task.");
|
|
611
|
+
}
|
|
612
|
+
if (
|
|
613
|
+
changedTestPaths.length > 0 &&
|
|
614
|
+
!hasBalancedPositiveNegativeAssertions(changedTestPaths, repo)
|
|
615
|
+
) {
|
|
616
|
+
issues.push(
|
|
617
|
+
"Changed test files do not show both positive and negative assertion coverage (expected both).",
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const runnableSteps = planning.validationSteps
|
|
622
|
+
.map((step) => extractRunnableValidationCommand(step))
|
|
623
|
+
.filter((entry): entry is string => Boolean(entry))
|
|
624
|
+
.slice(0, 4);
|
|
625
|
+
const fallbackValidationSteps =
|
|
626
|
+
runnableSteps.length === 0
|
|
627
|
+
? inferFallbackValidationCommandsForTestTask(
|
|
628
|
+
instruction,
|
|
629
|
+
targetPath,
|
|
630
|
+
planning,
|
|
631
|
+
changedTestPaths,
|
|
632
|
+
)
|
|
633
|
+
: [];
|
|
634
|
+
const commandsToRun = runnableSteps.length > 0 ? runnableSteps : fallbackValidationSteps;
|
|
635
|
+
const validationRuns: ValidationExecutionResult[] = [];
|
|
636
|
+
const outputPolicy = outputPolicyForRuntime(runtimeConfig);
|
|
637
|
+
const qualityValidationStepTimeoutMs = (() => {
|
|
638
|
+
const value = Number(runtimeConfig.workerpals.qualityValidationStepTimeoutMs);
|
|
639
|
+
if (!Number.isFinite(value)) return 180_000;
|
|
640
|
+
return Math.max(1_000, Math.min(7_200_000, Math.floor(value)));
|
|
641
|
+
})();
|
|
642
|
+
if (commandsToRun.length === 0) {
|
|
643
|
+
issues.push(
|
|
644
|
+
"No runnable validation command was provided in planning.validationSteps (expected at least one test command).",
|
|
645
|
+
);
|
|
646
|
+
} else {
|
|
647
|
+
if (runnableSteps.length === 0) {
|
|
648
|
+
onLog?.(
|
|
649
|
+
"stdout",
|
|
650
|
+
`[QualityGate] No runnable planning.validationSteps found; using fallback validation command(s): ${commandsToRun.join(" | ")}`,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
for (const command of commandsToRun) {
|
|
654
|
+
onLog?.("stdout", `[QualityGate] Quality gate validation: running "${command}"`);
|
|
655
|
+
const run = await runValidationCommand(
|
|
656
|
+
repo,
|
|
657
|
+
command,
|
|
658
|
+
qualityValidationStepTimeoutMs,
|
|
659
|
+
outputPolicy,
|
|
660
|
+
);
|
|
661
|
+
validationRuns.push(run);
|
|
662
|
+
const runSummary = `[QualityGate] Quality gate validation ${run.ok ? "passed" : "failed"} (${run.elapsedMs}ms, exit ${run.exitCode}): ${command}`;
|
|
663
|
+
onLog?.(run.ok ? "stdout" : "stderr", runSummary);
|
|
664
|
+
}
|
|
665
|
+
// exit 127 = command not found: separate tool-availability issues from real test failures.
|
|
666
|
+
const notFoundRuns = validationRuns.filter((run) => run.exitCode === 127);
|
|
667
|
+
const executedRuns = validationRuns.filter((run) => run.exitCode !== 127);
|
|
668
|
+
if (notFoundRuns.length > 0) {
|
|
669
|
+
const cmds = notFoundRuns.map((run) => run.command).join(", ");
|
|
670
|
+
onLog?.(
|
|
671
|
+
"stderr",
|
|
672
|
+
`[QualityGate] Some validation commands not found (exit 127 — wrong tool?): ${cmds}. This project uses Bun: prefer "bun test".`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
if (executedRuns.length > 0 && executedRuns.every((run) => !run.ok)) {
|
|
676
|
+
issues.push("Validation commands were executed but none passed.");
|
|
677
|
+
} else if (executedRuns.length === 0 && notFoundRuns.length > 0) {
|
|
678
|
+
issues.push(
|
|
679
|
+
'No validation command could be run (command not found). Use "bun test" or another available test runner.',
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
if (
|
|
683
|
+
!validationRuns.some((run) => /\b(test|pytest|coverage|vitest|jest)\b/i.test(run.command))
|
|
684
|
+
) {
|
|
685
|
+
issues.push("Validation steps did not execute a recognizable test command.");
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
ok: issues.length === 0,
|
|
691
|
+
skipped: false,
|
|
692
|
+
issues,
|
|
693
|
+
changedPaths,
|
|
694
|
+
changedTestPaths,
|
|
695
|
+
validationRuns,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function runTaskCriticReview(
|
|
700
|
+
repo: string,
|
|
701
|
+
params: Record<string, unknown>,
|
|
702
|
+
quality: DeterministicQualityResult,
|
|
703
|
+
runtimeConfig: WorkerpalsRuntimeConfig,
|
|
704
|
+
onLog?: (stream: "stdout" | "stderr", line: string) => void,
|
|
705
|
+
): Promise<CriticReview | null> {
|
|
706
|
+
const endpoint = normalizeChatCompletionsEndpoint(runtimeConfig.workerpals.llm.endpoint);
|
|
707
|
+
const model = runtimeConfig.workerpals.llm.model.trim();
|
|
708
|
+
if (!endpoint || !model) return null;
|
|
709
|
+
|
|
710
|
+
const changedForDiff = quality.changedPaths.slice(0, 8);
|
|
711
|
+
let diffText = "";
|
|
712
|
+
if (changedForDiff.length > 0) {
|
|
713
|
+
const diffResult = await git(repo, ["diff", "--", ...changedForDiff]);
|
|
714
|
+
diffText = diffResult.ok ? diffResult.stdout : diffResult.stderr;
|
|
715
|
+
}
|
|
716
|
+
const qualityCriticMaxDiffChars = (() => {
|
|
717
|
+
const value = Number(runtimeConfig.workerpals.qualityCriticMaxDiffChars);
|
|
718
|
+
if (!Number.isFinite(value)) return 16_000;
|
|
719
|
+
return Math.max(256, Math.min(524_288, Math.floor(value)));
|
|
720
|
+
})();
|
|
721
|
+
const qualityCriticMaxValidationOutputChars = (() => {
|
|
722
|
+
const value = Number(runtimeConfig.workerpals.qualityCriticMaxValidationOutputChars);
|
|
723
|
+
if (!Number.isFinite(value)) return 8_000;
|
|
724
|
+
return Math.max(256, Math.min(524_288, Math.floor(value)));
|
|
725
|
+
})();
|
|
726
|
+
const qualityCriticTimeoutMs = (() => {
|
|
727
|
+
const value = Number(runtimeConfig.workerpals.qualityCriticTimeoutMs);
|
|
728
|
+
if (!Number.isFinite(value)) return 45_000;
|
|
729
|
+
return Math.max(1_000, Math.min(7_200_000, Math.floor(value)));
|
|
730
|
+
})();
|
|
731
|
+
diffText = compactJobOutput(diffText, outputPolicyForRuntime(runtimeConfig)).slice(
|
|
732
|
+
0,
|
|
733
|
+
qualityCriticMaxDiffChars,
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
const validationSummary = quality.validationRuns
|
|
737
|
+
.map((run) => {
|
|
738
|
+
const output = [run.stdout, run.stderr]
|
|
739
|
+
.filter(Boolean)
|
|
740
|
+
.join("\n")
|
|
741
|
+
.slice(0, qualityCriticMaxValidationOutputChars);
|
|
742
|
+
return [
|
|
743
|
+
`Command: ${run.command}`,
|
|
744
|
+
`Result: ${run.ok ? "pass" : "fail"} (exit ${run.exitCode}, ${run.elapsedMs}ms)`,
|
|
745
|
+
output ? `Output:\n${output}` : "",
|
|
746
|
+
]
|
|
747
|
+
.filter(Boolean)
|
|
748
|
+
.join("\n");
|
|
749
|
+
})
|
|
750
|
+
.join("\n\n---\n\n");
|
|
751
|
+
|
|
752
|
+
const planning = params.planning as TaskExecutePlanning;
|
|
753
|
+
const instruction = String(params.instruction ?? "").trim();
|
|
754
|
+
const acceptanceCriteriaText =
|
|
755
|
+
planning.acceptanceCriteria.map((entry) => `- ${entry}`).join("\n") || "- (none)";
|
|
756
|
+
const validationStepsText =
|
|
757
|
+
planning.validationSteps.map((entry) => `- ${entry}`).join("\n") || "- (none)";
|
|
758
|
+
const changedPathsText =
|
|
759
|
+
quality.changedPaths.map((entry) => `- ${entry}`).join("\n") || "- (none)";
|
|
760
|
+
const criticSystem = loadPromptTemplate("workerpals/task_quality_critic_system_prompt.md").trim();
|
|
761
|
+
const criticUser = loadPromptTemplate("workerpals/task_quality_critic_user_prompt.md", {
|
|
762
|
+
instruction,
|
|
763
|
+
acceptance_criteria: acceptanceCriteriaText,
|
|
764
|
+
validation_steps: validationStepsText,
|
|
765
|
+
changed_paths: changedPathsText,
|
|
766
|
+
diff_excerpt: diffText || "(empty diff excerpt)",
|
|
767
|
+
validation_evidence: validationSummary || "(no validation output)",
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
const apiKey = runtimeConfig.workerpals.llm.apiKey.trim() || "local";
|
|
771
|
+
const headers: Record<string, string> = {
|
|
772
|
+
"Content-Type": "application/json",
|
|
773
|
+
};
|
|
774
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
775
|
+
const bodyBase = {
|
|
776
|
+
model,
|
|
777
|
+
messages: [
|
|
778
|
+
{ role: "system", content: criticSystem },
|
|
779
|
+
{ role: "user", content: criticUser },
|
|
780
|
+
],
|
|
781
|
+
temperature: 0,
|
|
782
|
+
max_tokens: 700,
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
const runCriticRequest = async (responseFormat: Record<string, unknown> | null) => {
|
|
786
|
+
const controller = new AbortController();
|
|
787
|
+
const timer = setTimeout(() => controller.abort(), qualityCriticTimeoutMs);
|
|
788
|
+
try {
|
|
789
|
+
const response = await fetch(endpoint, {
|
|
790
|
+
method: "POST",
|
|
791
|
+
headers,
|
|
792
|
+
body: JSON.stringify(
|
|
793
|
+
responseFormat ? { ...bodyBase, response_format: responseFormat } : bodyBase,
|
|
794
|
+
),
|
|
795
|
+
signal: controller.signal,
|
|
796
|
+
});
|
|
797
|
+
const text = await response.text();
|
|
798
|
+
return { response, text };
|
|
799
|
+
} finally {
|
|
800
|
+
clearTimeout(timer);
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
try {
|
|
805
|
+
let request = await runCriticRequest({ type: "json_object" });
|
|
806
|
+
if (!request.response.ok && request.response.status === 400) {
|
|
807
|
+
const lowered = request.text.toLowerCase();
|
|
808
|
+
if (lowered.includes("response_format")) {
|
|
809
|
+
onLog?.(
|
|
810
|
+
"stdout",
|
|
811
|
+
"[QualityGate] Critic fallback: response_format json_object unsupported; retrying without strict response_format.",
|
|
812
|
+
);
|
|
813
|
+
request = await runCriticRequest(null);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (!request.response.ok) {
|
|
817
|
+
onLog?.(
|
|
818
|
+
"stderr",
|
|
819
|
+
`[QualityGate] Critic review request failed (${request.response.status}): ${toSingleLine(request.text, 240)}`,
|
|
820
|
+
);
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const payload = parseJsonObjectLoose(request.text) ?? JSON.parse(request.text);
|
|
825
|
+
const choices = Array.isArray((payload as Record<string, unknown>).choices)
|
|
826
|
+
? ((payload as Record<string, unknown>).choices as Array<Record<string, unknown>>)
|
|
827
|
+
: [];
|
|
828
|
+
const content = String(
|
|
829
|
+
(choices[0]?.message as Record<string, unknown> | undefined)?.content ?? "",
|
|
830
|
+
).trim();
|
|
831
|
+
const reviewObj = parseJsonObjectLoose(content);
|
|
832
|
+
if (!reviewObj) {
|
|
833
|
+
onLog?.(
|
|
834
|
+
"stderr",
|
|
835
|
+
`[QualityGate] Critic produced non-JSON content; skipping critic gate. Raw: ${toSingleLine(
|
|
836
|
+
content,
|
|
837
|
+
220,
|
|
838
|
+
)}`,
|
|
839
|
+
);
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const scoreRaw = Number(reviewObj.score);
|
|
844
|
+
const findings = Array.isArray(reviewObj.findings)
|
|
845
|
+
? reviewObj.findings.map((entry) => String(entry).trim()).filter(Boolean)
|
|
846
|
+
: [];
|
|
847
|
+
const mustFix = Array.isArray(reviewObj.must_fix)
|
|
848
|
+
? reviewObj.must_fix.map((entry) => String(entry).trim()).filter(Boolean)
|
|
849
|
+
: [];
|
|
850
|
+
const revisionGuidance = String(reviewObj.revision_guidance ?? "")
|
|
851
|
+
.trim()
|
|
852
|
+
.slice(0, 2000);
|
|
853
|
+
const score = Number.isFinite(scoreRaw) ? Math.max(0, Math.min(10, scoreRaw)) : 0;
|
|
854
|
+
return {
|
|
855
|
+
score,
|
|
856
|
+
findings,
|
|
857
|
+
mustFix,
|
|
858
|
+
revisionGuidance,
|
|
859
|
+
raw: compactJobOutput(content, outputPolicyForRuntime(runtimeConfig)),
|
|
860
|
+
};
|
|
861
|
+
} catch (err) {
|
|
862
|
+
onLog?.(
|
|
863
|
+
"stderr",
|
|
864
|
+
`[QualityGate] Critic review unavailable: ${toSingleLine(err, 220)} (continuing without critic gate).`,
|
|
865
|
+
);
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function buildQualityRevisionHint(
|
|
871
|
+
issues: string[],
|
|
872
|
+
critic: CriticReview | null,
|
|
873
|
+
planning: TaskExecutePlanning,
|
|
874
|
+
): string {
|
|
875
|
+
const lines: string[] = [];
|
|
876
|
+
lines.push("Quality revision required before completion.");
|
|
877
|
+
if (issues.length > 0) {
|
|
878
|
+
lines.push("Deterministic quality issues:");
|
|
879
|
+
for (const issue of issues) lines.push(`- ${issue}`);
|
|
880
|
+
}
|
|
881
|
+
if (critic) {
|
|
882
|
+
lines.push(`Critic score: ${critic.score.toFixed(1)} / 10`);
|
|
883
|
+
if (critic.mustFix.length > 0) {
|
|
884
|
+
lines.push("Critic must-fix findings:");
|
|
885
|
+
for (const issue of critic.mustFix) lines.push(`- ${issue}`);
|
|
886
|
+
}
|
|
887
|
+
if (critic.revisionGuidance) {
|
|
888
|
+
lines.push(`Critic revision guidance: ${critic.revisionGuidance}`);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (planning.acceptanceCriteria.length > 0) {
|
|
892
|
+
lines.push("Required acceptance criteria:");
|
|
893
|
+
for (const criterion of planning.acceptanceCriteria) {
|
|
894
|
+
lines.push(`- ${criterion}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (planning.validationSteps.length > 0) {
|
|
898
|
+
lines.push("Required validation steps:");
|
|
899
|
+
for (const step of planning.validationSteps) lines.push(`- ${step}`);
|
|
900
|
+
}
|
|
901
|
+
lines.push("Apply a minimal corrective patch, run focused validation, then finish.");
|
|
902
|
+
return lines.join("\n").slice(0, 6000);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function inferTargetPathFromInstruction(text: string): string | null {
|
|
906
|
+
const patterns = [
|
|
907
|
+
/file\s+(?:called|named)\s+["'`]?([^"'`\s]+)["'`]?/i,
|
|
908
|
+
/create\s+(?:a\s+)?file\s+["'`]?([^"'`\s]+)["'`]?/i,
|
|
909
|
+
/write\s+(?:to|into)\s+["'`]?([^"'`\s]+)["'`]?/i,
|
|
910
|
+
];
|
|
911
|
+
for (const pattern of patterns) {
|
|
912
|
+
const match = text.match(pattern);
|
|
913
|
+
if (!match) continue;
|
|
914
|
+
const raw = (match[1] ?? "").trim().replace(/[.,!?;:]+$/, "");
|
|
915
|
+
if (!raw) continue;
|
|
916
|
+
if (raw.includes("/") || raw.includes("\\") || raw.includes(".")) return raw;
|
|
917
|
+
}
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function normalizeStagePath(value: unknown): string | null {
|
|
922
|
+
if (typeof value !== "string") return null;
|
|
923
|
+
let path = value.trim();
|
|
924
|
+
if (!path) return null;
|
|
925
|
+
path = path.replace(/\\/g, "/");
|
|
926
|
+
|
|
927
|
+
// Convert common workspace-absolute prefixes to repo-relative paths.
|
|
928
|
+
if (path === "/repo" || path === "/workspace") return ".";
|
|
929
|
+
if (path.startsWith("/repo/")) path = path.slice("/repo/".length);
|
|
930
|
+
else if (path.startsWith("/workspace/")) path = path.slice("/workspace/".length);
|
|
931
|
+
else if (path.startsWith("/")) return null;
|
|
932
|
+
if (/^[A-Za-z]:[\\/]/.test(path)) return null;
|
|
933
|
+
|
|
934
|
+
path = path
|
|
935
|
+
.replace(/^\.\/+/, "")
|
|
936
|
+
.replace(/\/+/g, "/")
|
|
937
|
+
.trim();
|
|
938
|
+
if (!path || path === ".") return ".";
|
|
939
|
+
if (path.startsWith(":(")) return null;
|
|
940
|
+
|
|
941
|
+
const segments = path.split("/");
|
|
942
|
+
for (const segment of segments) {
|
|
943
|
+
if (!segment || segment === ".") continue;
|
|
944
|
+
if (segment === "..") return null;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return path.length > 0 ? path : null;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function toStringArray(value: unknown): string[] {
|
|
951
|
+
if (!Array.isArray(value)) return [];
|
|
952
|
+
return value
|
|
953
|
+
.map((entry) => normalizeStagePath(entry))
|
|
954
|
+
.filter((entry): entry is string => Boolean(entry));
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function normalizeChangedPathForCommit(value: unknown): string | null {
|
|
958
|
+
if (typeof value !== "string") return null;
|
|
959
|
+
let path = value.trim();
|
|
960
|
+
if (!path) return null;
|
|
961
|
+
|
|
962
|
+
if (
|
|
963
|
+
(path.startsWith('"') && path.endsWith('"')) ||
|
|
964
|
+
(path.startsWith("'") && path.endsWith("'"))
|
|
965
|
+
) {
|
|
966
|
+
path = path.slice(1, -1).trim();
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Git may emit escaped spaces in some contexts.
|
|
970
|
+
path = path.replace(/\\ /g, " ").replace(/\\/g, "/");
|
|
971
|
+
|
|
972
|
+
if (path === "." || path === "/repo" || path === "/workspace") return null;
|
|
973
|
+
if (path.startsWith("/repo/")) path = path.slice("/repo/".length);
|
|
974
|
+
else if (path.startsWith("/workspace/")) path = path.slice("/workspace/".length);
|
|
975
|
+
else if (path.startsWith("/")) return null;
|
|
976
|
+
if (/^[A-Za-z]:[\\/]/.test(path)) return null;
|
|
977
|
+
|
|
978
|
+
path = path
|
|
979
|
+
.replace(/^\.\/+/, "")
|
|
980
|
+
.replace(/\/+/g, "/")
|
|
981
|
+
.trim();
|
|
982
|
+
if (!path || path === ".") return null;
|
|
983
|
+
|
|
984
|
+
const segments = path.split("/");
|
|
985
|
+
for (const segment of segments) {
|
|
986
|
+
if (!segment || segment === ".") continue;
|
|
987
|
+
if (segment === "..") return null;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
return path;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
export function parseChangedPathsFromNameOnlyOutput(output: string): string[] {
|
|
994
|
+
const seen = new Set<string>();
|
|
995
|
+
const out: string[] = [];
|
|
996
|
+
for (const raw of output.split(/\r?\n/)) {
|
|
997
|
+
const path = normalizeChangedPathForCommit(raw);
|
|
998
|
+
if (!path || seen.has(path)) continue;
|
|
999
|
+
seen.add(path);
|
|
1000
|
+
out.push(path);
|
|
1001
|
+
}
|
|
1002
|
+
return out;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function summarizeRecentJobsForDoc(value: unknown, limit = 6): string[] {
|
|
1006
|
+
if (!Array.isArray(value)) return [];
|
|
1007
|
+
const out: string[] = [];
|
|
1008
|
+
for (const row of value) {
|
|
1009
|
+
if (!row || typeof row !== "object") continue;
|
|
1010
|
+
const job = row as Record<string, unknown>;
|
|
1011
|
+
const kind = String(job.kind ?? "").trim();
|
|
1012
|
+
const status = String(job.status ?? "").trim();
|
|
1013
|
+
const summary = String(job.summary ?? "")
|
|
1014
|
+
.replace(/\s+/g, " ")
|
|
1015
|
+
.trim();
|
|
1016
|
+
const error = String(job.error ?? "")
|
|
1017
|
+
.replace(/\s+/g, " ")
|
|
1018
|
+
.trim();
|
|
1019
|
+
if (!kind && !status && !summary && !error) continue;
|
|
1020
|
+
const tail = summary || error;
|
|
1021
|
+
const entry = tail ? `- ${kind} [${status}]: ${tail}` : `- ${kind} [${status}]`;
|
|
1022
|
+
out.push(entry.slice(0, 220));
|
|
1023
|
+
if (out.length >= limit) break;
|
|
1024
|
+
}
|
|
1025
|
+
return out;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
async function buildArchitectureDocument(
|
|
1029
|
+
repo: string,
|
|
1030
|
+
instruction: string,
|
|
1031
|
+
recentJobs: unknown,
|
|
1032
|
+
): Promise<string> {
|
|
1033
|
+
const { readdirSync, readFileSync, statSync } = await import("fs");
|
|
1034
|
+
const { join } = await import("path");
|
|
1035
|
+
|
|
1036
|
+
const ignore = new Set([
|
|
1037
|
+
".git",
|
|
1038
|
+
"node_modules",
|
|
1039
|
+
"outputs",
|
|
1040
|
+
".worktrees",
|
|
1041
|
+
"workspace",
|
|
1042
|
+
".venv",
|
|
1043
|
+
"dist",
|
|
1044
|
+
"build",
|
|
1045
|
+
]);
|
|
1046
|
+
|
|
1047
|
+
const list = (dir: string, depth: number, prefix = ""): string[] => {
|
|
1048
|
+
if (depth < 0) return [];
|
|
1049
|
+
let entries: string[];
|
|
1050
|
+
try {
|
|
1051
|
+
entries = readdirSync(dir).sort() as string[];
|
|
1052
|
+
} catch {
|
|
1053
|
+
return [];
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const lines: string[] = [];
|
|
1057
|
+
for (const name of entries) {
|
|
1058
|
+
if (name.startsWith(".") && name !== ".env.example") continue;
|
|
1059
|
+
if (ignore.has(name)) continue;
|
|
1060
|
+
const full = join(dir, name);
|
|
1061
|
+
let isDir = false;
|
|
1062
|
+
try {
|
|
1063
|
+
isDir = statSync(full).isDirectory();
|
|
1064
|
+
} catch {
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
lines.push(`${prefix}- ${name}${isDir ? "/" : ""}`);
|
|
1068
|
+
if (isDir && depth > 0 && lines.length < 120) {
|
|
1069
|
+
lines.push(...list(full, depth - 1, `${prefix} `));
|
|
1070
|
+
}
|
|
1071
|
+
if (lines.length >= 120) break;
|
|
1072
|
+
}
|
|
1073
|
+
return lines;
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
const readmePath = join(repo, "README.md");
|
|
1077
|
+
let readmeExcerpt = "";
|
|
1078
|
+
try {
|
|
1079
|
+
readmeExcerpt = readFileSync(readmePath, "utf-8").slice(0, 2400).trim();
|
|
1080
|
+
} catch {
|
|
1081
|
+
readmeExcerpt = "";
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const lines: string[] = [];
|
|
1085
|
+
lines.push("# Repository Architecture");
|
|
1086
|
+
lines.push("");
|
|
1087
|
+
lines.push(`Requested task: ${instruction}`);
|
|
1088
|
+
lines.push("");
|
|
1089
|
+
lines.push("## Top-level Structure");
|
|
1090
|
+
lines.push(...list(repo, 1));
|
|
1091
|
+
if (readmeExcerpt) {
|
|
1092
|
+
lines.push("");
|
|
1093
|
+
lines.push("## README Excerpt");
|
|
1094
|
+
lines.push(readmeExcerpt);
|
|
1095
|
+
}
|
|
1096
|
+
const jobSummaries = summarizeRecentJobsForDoc(recentJobs);
|
|
1097
|
+
if (jobSummaries.length > 0) {
|
|
1098
|
+
lines.push("");
|
|
1099
|
+
lines.push("## Recent Worker Job Context");
|
|
1100
|
+
lines.push(...jobSummaries);
|
|
1101
|
+
}
|
|
1102
|
+
lines.push("");
|
|
1103
|
+
lines.push(
|
|
1104
|
+
"Generated by worker task.execute from repository state. Review and refine as needed.",
|
|
1105
|
+
);
|
|
1106
|
+
|
|
1107
|
+
return lines.join("\n").trim() + "\n";
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** Execute a git command and return stdout */
|
|
1111
|
+
export async function git(
|
|
1112
|
+
cwd: string,
|
|
1113
|
+
args: string[],
|
|
1114
|
+
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
1115
|
+
try {
|
|
1116
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
1117
|
+
cwd,
|
|
1118
|
+
stdout: "pipe",
|
|
1119
|
+
stderr: "pipe",
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1123
|
+
new Response(proc.stdout).text(),
|
|
1124
|
+
new Response(proc.stderr).text(),
|
|
1125
|
+
proc.exited,
|
|
1126
|
+
]);
|
|
1127
|
+
|
|
1128
|
+
// Preserve leading spaces in stdout for porcelain parsers.
|
|
1129
|
+
return { ok: exitCode === 0, stdout: stdout.trimEnd(), stderr: stderr.trim() };
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
return { ok: false, stdout: "", stderr: String(err) };
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ─── Git commit creation ─────────────────────────────────────────────────────
|
|
1136
|
+
|
|
1137
|
+
/** Create commit for job result and return commit info */
|
|
1138
|
+
export async function createJobCommit(
|
|
1139
|
+
repo: string,
|
|
1140
|
+
workerId: string,
|
|
1141
|
+
job: {
|
|
1142
|
+
id: string;
|
|
1143
|
+
taskId: string;
|
|
1144
|
+
kind: string;
|
|
1145
|
+
params?: Record<string, unknown>;
|
|
1146
|
+
sessionId?: string;
|
|
1147
|
+
context?: "host" | "docker";
|
|
1148
|
+
},
|
|
1149
|
+
runtimeConfig: WorkerpalsRuntimeConfig = DEFAULT_CONFIG,
|
|
1150
|
+
): Promise<{ ok: boolean; branch?: string; sha?: string; error?: string }> {
|
|
1151
|
+
const defaultPublicBranchName = `agent/${workerId}/${job.id}`;
|
|
1152
|
+
const reviewAgentHeadRef =
|
|
1153
|
+
job.params?.reviewAgent &&
|
|
1154
|
+
typeof job.params.reviewAgent === "object" &&
|
|
1155
|
+
!Array.isArray(job.params.reviewAgent)
|
|
1156
|
+
? (job.params.reviewAgent as Record<string, unknown>).prHeadRef
|
|
1157
|
+
: undefined;
|
|
1158
|
+
const resolvedPublicBranch = resolveReviewFixCompletionBranch(
|
|
1159
|
+
job.params?.completionBranch ?? reviewAgentHeadRef,
|
|
1160
|
+
defaultPublicBranchName,
|
|
1161
|
+
);
|
|
1162
|
+
const publicBranchName = resolvedPublicBranch.branch;
|
|
1163
|
+
const requirePush = runtimeConfig.workerpals.requirePush || resolvedPublicBranch.overridden;
|
|
1164
|
+
const pushAgentBranch =
|
|
1165
|
+
requirePush || runtimeConfig.workerpals.pushAgentBranch || resolvedPublicBranch.overridden;
|
|
1166
|
+
// Keep worker refs out of refs/heads so user-visible branch lists stay clean.
|
|
1167
|
+
const hiddenCommitRef = `refs/pushpals/agent/${workerId}/${job.id}`;
|
|
1168
|
+
let completionRef = hiddenCommitRef;
|
|
1169
|
+
let hiddenRefCreated = false;
|
|
1170
|
+
|
|
1171
|
+
try {
|
|
1172
|
+
let result: { ok: boolean; stdout: string; stderr: string };
|
|
1173
|
+
|
|
1174
|
+
// Stage only the paths implied by this job. This prevents runtime metadata
|
|
1175
|
+
// (e.g. workspace/bash_events/*) from being accidentally committed.
|
|
1176
|
+
const stageArgs = buildStageCommand(job.kind, job.params);
|
|
1177
|
+
if (!stageArgs) {
|
|
1178
|
+
return {
|
|
1179
|
+
ok: false,
|
|
1180
|
+
error: `Unable to determine files to stage for job kind: ${job.kind}`,
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
result = await git(repo, stageArgs);
|
|
1184
|
+
if (!result.ok) {
|
|
1185
|
+
const stageErr = result.stderr || result.stdout;
|
|
1186
|
+
if (
|
|
1187
|
+
/pathspec .* did not match any files/i.test(stageErr) ||
|
|
1188
|
+
/invalid path/i.test(stageErr) ||
|
|
1189
|
+
/outside repository/i.test(stageErr)
|
|
1190
|
+
) {
|
|
1191
|
+
console.warn(
|
|
1192
|
+
`[WorkerPals] Stage target invalid/missing for ${job.kind}; retrying with fallback "git add -A".`,
|
|
1193
|
+
);
|
|
1194
|
+
result = await git(repo, [
|
|
1195
|
+
"add",
|
|
1196
|
+
"-A",
|
|
1197
|
+
"--",
|
|
1198
|
+
".",
|
|
1199
|
+
":(exclude)workspace/**",
|
|
1200
|
+
":(exclude)outputs/**",
|
|
1201
|
+
]);
|
|
1202
|
+
}
|
|
1203
|
+
if (!result.ok) {
|
|
1204
|
+
return { ok: false, error: `Failed to stage changes: ${result.stderr || result.stdout}` };
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Check if there are changes to commit
|
|
1209
|
+
result = await git(repo, ["diff", "--cached", "--quiet"]);
|
|
1210
|
+
if (result.ok) {
|
|
1211
|
+
// No changes to commit (diff exited 0)
|
|
1212
|
+
console.log(`[WorkerPals] No changes to commit for job ${job.id}`);
|
|
1213
|
+
return { ok: true, branch: hiddenCommitRef, sha: "no-changes" };
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Generate commit message from actual staged diff; fall back to deterministic.
|
|
1217
|
+
const cachedDiff = await git(repo, ["diff", "--cached"]);
|
|
1218
|
+
const diff = cachedDiff.ok ? cachedDiff.stdout : "";
|
|
1219
|
+
const cachedNameOnly = await git(repo, ["diff", "--cached", "--name-only"]);
|
|
1220
|
+
const changedPaths = cachedNameOnly.ok
|
|
1221
|
+
? parseChangedPathsFromNameOnlyOutput(cachedNameOnly.stdout)
|
|
1222
|
+
: [];
|
|
1223
|
+
const jobPlanning = job.params?.planning as Record<string, unknown> | undefined;
|
|
1224
|
+
const jobValidationSteps = toNonEmptyStringArray(
|
|
1225
|
+
jobPlanning?.validationSteps ?? job.params?.validationSteps,
|
|
1226
|
+
);
|
|
1227
|
+
const llmCommitMsg = await generateCommitMessageFromDiff(
|
|
1228
|
+
diff,
|
|
1229
|
+
{
|
|
1230
|
+
instruction: String(job.params?.instruction ?? ""),
|
|
1231
|
+
type: normalizeCommitType(job.kind, job.params),
|
|
1232
|
+
area: inferCommitArea(job.kind, job.params, changedPaths),
|
|
1233
|
+
validationSteps: jobValidationSteps,
|
|
1234
|
+
},
|
|
1235
|
+
repo,
|
|
1236
|
+
runtimeConfig,
|
|
1237
|
+
).catch(() => null);
|
|
1238
|
+
if (!llmCommitMsg) {
|
|
1239
|
+
console.warn(
|
|
1240
|
+
`[WorkerPals] Commit message generator unavailable for job ${job.id}; using deterministic fallback.`,
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
const commitMsg = llmCommitMsg ?? buildWorkerCommitMessage(workerId, job, changedPaths);
|
|
1244
|
+
|
|
1245
|
+
// Commit changes
|
|
1246
|
+
result = await git(repo, ["commit", "-m", commitMsg]);
|
|
1247
|
+
if (!result.ok) {
|
|
1248
|
+
return { ok: false, error: `Failed to commit: ${result.stderr}` };
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// Get commit SHA
|
|
1252
|
+
result = await git(repo, ["rev-parse", "HEAD"]);
|
|
1253
|
+
if (!result.ok) {
|
|
1254
|
+
return { ok: false, error: `Failed to get commit SHA: ${result.stderr}` };
|
|
1255
|
+
}
|
|
1256
|
+
let sha = result.stdout;
|
|
1257
|
+
|
|
1258
|
+
// Persist commit under an internal ref so it remains reachable after worktree cleanup.
|
|
1259
|
+
result = await git(repo, ["update-ref", hiddenCommitRef, sha]);
|
|
1260
|
+
if (!result.ok) {
|
|
1261
|
+
return { ok: false, error: `Failed to store worker commit ref: ${result.stderr}` };
|
|
1262
|
+
}
|
|
1263
|
+
hiddenRefCreated = true;
|
|
1264
|
+
|
|
1265
|
+
// Push branch to origin (optional; disabled by default for shared-.git workflows)
|
|
1266
|
+
if (pushAgentBranch) {
|
|
1267
|
+
const maxPushAttempts = 3;
|
|
1268
|
+
let pushed = false;
|
|
1269
|
+
let pushError = "";
|
|
1270
|
+
for (let attempt = 1; attempt <= maxPushAttempts; attempt++) {
|
|
1271
|
+
const sync = await syncHiddenRefWithRemoteBranchByRebase(
|
|
1272
|
+
repo,
|
|
1273
|
+
hiddenCommitRef,
|
|
1274
|
+
publicBranchName,
|
|
1275
|
+
job.id,
|
|
1276
|
+
);
|
|
1277
|
+
if (!sync.ok) {
|
|
1278
|
+
pushError = `Failed to sync branch before push: ${redactSensitiveText(sync.error)}`;
|
|
1279
|
+
break;
|
|
1280
|
+
}
|
|
1281
|
+
sha = sync.sha;
|
|
1282
|
+
|
|
1283
|
+
result = await git(repo, [
|
|
1284
|
+
"push",
|
|
1285
|
+
"origin",
|
|
1286
|
+
`${hiddenCommitRef}:refs/heads/${publicBranchName}`,
|
|
1287
|
+
]);
|
|
1288
|
+
if (result.ok) {
|
|
1289
|
+
completionRef = publicBranchName;
|
|
1290
|
+
pushed = true;
|
|
1291
|
+
break;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
pushError = `Failed to push branch: ${redactSensitiveText(result.stderr || result.stdout)}`;
|
|
1295
|
+
if (attempt < maxPushAttempts && isNonFastForwardPushOutput(pushError)) {
|
|
1296
|
+
console.warn(
|
|
1297
|
+
`[WorkerPals] Push rejected as non-fast-forward for ${publicBranchName}; retrying after git pull --rebase (attempt ${attempt + 1}/${maxPushAttempts}).`,
|
|
1298
|
+
);
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
break;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
if (!pushed) {
|
|
1305
|
+
if (requirePush) {
|
|
1306
|
+
if (hiddenRefCreated) {
|
|
1307
|
+
await git(repo, ["update-ref", "-d", hiddenCommitRef]);
|
|
1308
|
+
}
|
|
1309
|
+
return { ok: false, error: pushError };
|
|
1310
|
+
}
|
|
1311
|
+
console.warn(
|
|
1312
|
+
`[WorkerPals] ${pushError}. Continuing with local commit ref only (set WORKERPALS_REQUIRE_PUSH=1 to enforce push).`,
|
|
1313
|
+
);
|
|
1314
|
+
return { ok: true, branch: completionRef, sha };
|
|
1315
|
+
}
|
|
1316
|
+
} else {
|
|
1317
|
+
console.log(
|
|
1318
|
+
`[WorkerPals] Skipping push for ${publicBranchName} (WORKERPALS_PUSH_AGENT_BRANCH is disabled).`,
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
console.log(`[WorkerPals] Created commit ${sha} on ref ${completionRef}`);
|
|
1323
|
+
return { ok: true, branch: completionRef, sha };
|
|
1324
|
+
} catch (err) {
|
|
1325
|
+
if (hiddenRefCreated) {
|
|
1326
|
+
await git(repo, ["update-ref", "-d", hiddenCommitRef]);
|
|
1327
|
+
}
|
|
1328
|
+
return { ok: false, error: String(err) };
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function toPath(value: unknown): string | null {
|
|
1333
|
+
return normalizeStagePath(value);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function dedupePaths(paths: Array<string | null>): string[] {
|
|
1337
|
+
const seen = new Set<string>();
|
|
1338
|
+
const out: string[] = [];
|
|
1339
|
+
for (const path of paths) {
|
|
1340
|
+
if (!path || seen.has(path)) continue;
|
|
1341
|
+
seen.add(path);
|
|
1342
|
+
out.push(path);
|
|
1343
|
+
}
|
|
1344
|
+
return out;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function planningPathHints(value: unknown): string[] {
|
|
1348
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
1349
|
+
const planning = value as Record<string, unknown>;
|
|
1350
|
+
const hints: string[] = [];
|
|
1351
|
+
|
|
1352
|
+
const scope =
|
|
1353
|
+
planning.scope && typeof planning.scope === "object" && !Array.isArray(planning.scope)
|
|
1354
|
+
? (planning.scope as Record<string, unknown>)
|
|
1355
|
+
: null;
|
|
1356
|
+
if (scope) {
|
|
1357
|
+
hints.push(...toStringArray(scope.writeGlobs));
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const discovery =
|
|
1361
|
+
planning.discovery &&
|
|
1362
|
+
typeof planning.discovery === "object" &&
|
|
1363
|
+
!Array.isArray(planning.discovery)
|
|
1364
|
+
? (planning.discovery as Record<string, unknown>)
|
|
1365
|
+
: null;
|
|
1366
|
+
if (discovery) {
|
|
1367
|
+
hints.push(...toStringArray(discovery.likelyDirs));
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
return hints.slice(0, 12);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function buildStageTargets(kind: string, params?: Record<string, unknown>): string[] {
|
|
1374
|
+
const p = params ?? {};
|
|
1375
|
+
switch (kind) {
|
|
1376
|
+
case "task.execute": {
|
|
1377
|
+
const paths = toStringArray(p.paths);
|
|
1378
|
+
const planHints = planningPathHints(p.planning);
|
|
1379
|
+
const inferred = toPath(inferTargetPathFromInstruction(String(p.instruction ?? "")));
|
|
1380
|
+
return dedupePaths([...paths, ...planHints, toPath(p.targetPath), toPath(p.path), inferred]);
|
|
1381
|
+
}
|
|
1382
|
+
default:
|
|
1383
|
+
return [];
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function buildStageCommand(kind: string, params?: Record<string, unknown>): string[] | null {
|
|
1388
|
+
const targets = buildStageTargets(kind, params);
|
|
1389
|
+
if (targets.length === 0) {
|
|
1390
|
+
if (kind === "task.execute") {
|
|
1391
|
+
return ["add", "-A", "--", ".", ":(exclude)workspace/**", ":(exclude)outputs/**"];
|
|
1392
|
+
}
|
|
1393
|
+
return null;
|
|
1394
|
+
}
|
|
1395
|
+
return ["add", "-A", "--", ...targets];
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function sanitizeCommitValue(value: unknown, max = 140): string {
|
|
1399
|
+
const s = String(value ?? "")
|
|
1400
|
+
.replace(/\s+/g, " ")
|
|
1401
|
+
.trim();
|
|
1402
|
+
if (!s) return "";
|
|
1403
|
+
return s.length > max ? `${s.slice(0, max - 3)}...` : s;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function normalizeCommitType(kind: string, params?: Record<string, unknown>): string {
|
|
1407
|
+
const raw = String(params?.commitType ?? params?.changeType ?? params?.type ?? "")
|
|
1408
|
+
.trim()
|
|
1409
|
+
.toLowerCase();
|
|
1410
|
+
|
|
1411
|
+
const mapped =
|
|
1412
|
+
raw === "bugfix" || raw === "bug" || raw === "fix"
|
|
1413
|
+
? "fix"
|
|
1414
|
+
: raw === "feature" || raw === "feat" || raw === "new"
|
|
1415
|
+
? "feat"
|
|
1416
|
+
: raw === "docs" || raw === "doc"
|
|
1417
|
+
? "docs"
|
|
1418
|
+
: raw === "refactor"
|
|
1419
|
+
? "refactor"
|
|
1420
|
+
: raw === "chore"
|
|
1421
|
+
? "chore"
|
|
1422
|
+
: "";
|
|
1423
|
+
if (mapped) return mapped;
|
|
1424
|
+
|
|
1425
|
+
switch (kind) {
|
|
1426
|
+
case "file.patch":
|
|
1427
|
+
return "fix";
|
|
1428
|
+
case "file.delete":
|
|
1429
|
+
case "file.rename":
|
|
1430
|
+
case "file.copy":
|
|
1431
|
+
case "file.append":
|
|
1432
|
+
case "file.mkdir":
|
|
1433
|
+
return "refactor";
|
|
1434
|
+
default:
|
|
1435
|
+
return "feat";
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function normalizeCommitArea(raw: string): string {
|
|
1440
|
+
const cleaned = raw
|
|
1441
|
+
.trim()
|
|
1442
|
+
.toLowerCase()
|
|
1443
|
+
.replace(/\s+/g, "_")
|
|
1444
|
+
.replace(/-+/g, "_")
|
|
1445
|
+
.replace(/[^a-z0-9_]/g, "");
|
|
1446
|
+
return cleaned || "worker";
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function inferCommitArea(
|
|
1450
|
+
kind: string,
|
|
1451
|
+
params?: Record<string, unknown>,
|
|
1452
|
+
changedPaths: string[] = [],
|
|
1453
|
+
): string {
|
|
1454
|
+
const explicit = String(params?.area ?? params?.scope ?? params?.component ?? "").trim();
|
|
1455
|
+
if (explicit) return normalizeCommitArea(explicit);
|
|
1456
|
+
|
|
1457
|
+
const targets =
|
|
1458
|
+
changedPaths.length > 0
|
|
1459
|
+
? changedPaths
|
|
1460
|
+
: buildStageTargets(kind, params).filter((p) => p !== ".");
|
|
1461
|
+
const pick = (prefix: string): boolean =>
|
|
1462
|
+
targets.some((path) => path.toLowerCase().startsWith(prefix.toLowerCase()));
|
|
1463
|
+
|
|
1464
|
+
if (pick("scripts/start.ts") || pick(".env") || pick(".env.example")) return "startup";
|
|
1465
|
+
if (pick("apps/remotebuddy/")) return "remote_agent";
|
|
1466
|
+
if (pick("apps/localbuddy/")) return "local_agent";
|
|
1467
|
+
if (pick("apps/workerpals/")) return "worker";
|
|
1468
|
+
if (pick("apps/source_control_manager/")) return "source_control_manager";
|
|
1469
|
+
if (pick("apps/client/")) return "client";
|
|
1470
|
+
if (pick("apps/server/")) return "server";
|
|
1471
|
+
if (pick("README.md") || pick("docs/")) return "docs";
|
|
1472
|
+
return "worker";
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function summarizeScope(
|
|
1476
|
+
kind: string,
|
|
1477
|
+
params?: Record<string, unknown>,
|
|
1478
|
+
changedPaths: string[] = [],
|
|
1479
|
+
): string {
|
|
1480
|
+
const targets =
|
|
1481
|
+
changedPaths.length > 0
|
|
1482
|
+
? changedPaths
|
|
1483
|
+
: buildStageTargets(kind, params).filter((p) => p !== ".");
|
|
1484
|
+
if (targets.length === 0) return "repository-level changes";
|
|
1485
|
+
const visible = targets.slice(0, 3).join(", ");
|
|
1486
|
+
return targets.length > 3 ? `${visible}, +${targets.length - 3} more` : visible;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function isDocPath(path: string): boolean {
|
|
1490
|
+
const lower = path.toLowerCase();
|
|
1491
|
+
return (
|
|
1492
|
+
lower.startsWith("docs/") ||
|
|
1493
|
+
lower.startsWith("wiki/") ||
|
|
1494
|
+
lower === "readme.md" ||
|
|
1495
|
+
lower.endsWith(".md")
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function isTestPath(path: string): boolean {
|
|
1500
|
+
return /(?:^|[/\\])tests?[/\\]|\.test\.[a-z0-9]+$|\.spec\.[a-z0-9]+$/i.test(path);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function humanizeCommitArea(area: string): string {
|
|
1504
|
+
switch (area) {
|
|
1505
|
+
case "local_agent":
|
|
1506
|
+
return "localbuddy";
|
|
1507
|
+
case "remote_agent":
|
|
1508
|
+
return "remotebuddy";
|
|
1509
|
+
case "source_control_manager":
|
|
1510
|
+
return "source control manager";
|
|
1511
|
+
default:
|
|
1512
|
+
return area.replace(/_/g, " ");
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function deriveSummary(
|
|
1517
|
+
action: string,
|
|
1518
|
+
params?: Record<string, unknown>,
|
|
1519
|
+
changedPaths: string[] = [],
|
|
1520
|
+
areaHint = "worker",
|
|
1521
|
+
): string {
|
|
1522
|
+
const explicit = sanitizeCommitValue(params?.commitSummary, 72);
|
|
1523
|
+
if (explicit) return explicit;
|
|
1524
|
+
|
|
1525
|
+
if (changedPaths.length > 0) {
|
|
1526
|
+
const label = humanizeCommitArea(areaHint);
|
|
1527
|
+
const testCount = changedPaths.filter(isTestPath).length;
|
|
1528
|
+
const docCount = changedPaths.filter(isDocPath).length;
|
|
1529
|
+
const codeCount = changedPaths.length - testCount - docCount;
|
|
1530
|
+
|
|
1531
|
+
if (testCount > 0 && codeCount === 0 && docCount === 0) {
|
|
1532
|
+
return sanitizeCommitValue(`expand ${label} test coverage`, 72);
|
|
1533
|
+
}
|
|
1534
|
+
if (docCount > 0 && codeCount === 0 && testCount === 0) {
|
|
1535
|
+
return sanitizeCommitValue(`update ${label} documentation`, 72);
|
|
1536
|
+
}
|
|
1537
|
+
if (testCount > 0 && codeCount > 0) {
|
|
1538
|
+
return sanitizeCommitValue(`update ${label} implementation and test coverage`, 72);
|
|
1539
|
+
}
|
|
1540
|
+
if (codeCount > 0) {
|
|
1541
|
+
return sanitizeCommitValue(`update ${label} implementation`, 72);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const raw = sanitizeCommitValue(action, 72);
|
|
1546
|
+
if (!raw) return "apply requested repository update";
|
|
1547
|
+
return raw;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/** Returns true for acceptance criteria that are generic boilerplate with no commit signal. */
|
|
1551
|
+
function isBoilerplateCriterion(criterion: string): boolean {
|
|
1552
|
+
return /produce a correct and helpful result|complete the requested task|accomplish the (?:stated )?goal|provide a (?:correct|good|helpful) (?:solution|result|answer)|the task (?:is|should be) completed|successfully complete(?:d)? the task/i.test(
|
|
1553
|
+
criterion,
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function buildChangedPathImplementationPoints(changedPaths: string[]): string {
|
|
1558
|
+
if (changedPaths.length === 0) return "";
|
|
1559
|
+
const lines: string[] = [];
|
|
1560
|
+
for (const path of changedPaths.slice(0, 6)) {
|
|
1561
|
+
if (isTestPath(path)) {
|
|
1562
|
+
lines.push(`- add or update tests in ${sanitizeCommitValue(path, 220)}`);
|
|
1563
|
+
} else if (isDocPath(path)) {
|
|
1564
|
+
lines.push(`- update documentation in ${sanitizeCommitValue(path, 220)}`);
|
|
1565
|
+
} else {
|
|
1566
|
+
lines.push(`- update ${sanitizeCommitValue(path, 220)}`);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
if (changedPaths.length > 6) {
|
|
1570
|
+
lines.push(`- update +${changedPaths.length - 6} additional file(s)`);
|
|
1571
|
+
}
|
|
1572
|
+
return lines.join("\n");
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function buildImplementationPoints(
|
|
1576
|
+
kind: string,
|
|
1577
|
+
params?: Record<string, unknown>,
|
|
1578
|
+
changedPaths: string[] = [],
|
|
1579
|
+
): string {
|
|
1580
|
+
// 1. Explicit commit points take highest priority (set by dispatcher or worker).
|
|
1581
|
+
const explicitPoints = toNonEmptyStringArray(
|
|
1582
|
+
params?.commitPoints ?? params?.changeDetails ?? params?.implementationPoints,
|
|
1583
|
+
);
|
|
1584
|
+
if (explicitPoints.length > 0) {
|
|
1585
|
+
return explicitPoints
|
|
1586
|
+
.slice(0, 8)
|
|
1587
|
+
.map((point) => `- ${sanitizeCommitValue(point, 220)}`)
|
|
1588
|
+
.join("\n");
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// 2. Use acceptance criteria from planning as implementation bullets, but only
|
|
1592
|
+
// when they describe specific outcomes (not generic boilerplate phrases).
|
|
1593
|
+
const planning =
|
|
1594
|
+
params && typeof params.planning === "object" && !Array.isArray(params.planning)
|
|
1595
|
+
? (params.planning as Record<string, unknown>)
|
|
1596
|
+
: undefined;
|
|
1597
|
+
const criteria = toNonEmptyStringArray(
|
|
1598
|
+
planning?.acceptanceCriteria ?? planning?.acceptance_criteria,
|
|
1599
|
+
).filter((criterion) => !isBoilerplateCriterion(criterion));
|
|
1600
|
+
if (criteria.length > 0) {
|
|
1601
|
+
return criteria
|
|
1602
|
+
.slice(0, 6)
|
|
1603
|
+
.map((criterion) => `- ${sanitizeCommitValue(criterion, 220)}`)
|
|
1604
|
+
.join("\n");
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// 3. Use actual changed file paths when available.
|
|
1608
|
+
const fromChangedPaths = buildChangedPathImplementationPoints(changedPaths);
|
|
1609
|
+
if (fromChangedPaths) return fromChangedPaths;
|
|
1610
|
+
|
|
1611
|
+
// 4. Fall back to staged target hints.
|
|
1612
|
+
const targets = buildStageTargets(kind, params).filter((target) => target !== ".");
|
|
1613
|
+
if (targets.length === 0) return "";
|
|
1614
|
+
const lines: string[] = [];
|
|
1615
|
+
for (const target of targets.slice(0, 5)) {
|
|
1616
|
+
lines.push(`- update ${sanitizeCommitValue(target, 220)}`);
|
|
1617
|
+
}
|
|
1618
|
+
if (targets.length > 5) {
|
|
1619
|
+
lines.push(`- update +${targets.length - 5} additional file(s)`);
|
|
1620
|
+
}
|
|
1621
|
+
return lines.join("\n");
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
function parseBooleanFlag(value: unknown): boolean {
|
|
1625
|
+
if (typeof value === "boolean") return value;
|
|
1626
|
+
if (typeof value === "number") return value !== 0;
|
|
1627
|
+
if (typeof value !== "string") return false;
|
|
1628
|
+
const normalized = value.trim().toLowerCase();
|
|
1629
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function toNonEmptyStringArray(value: unknown): string[] {
|
|
1633
|
+
if (!Array.isArray(value)) return [];
|
|
1634
|
+
return value.map((entry) => sanitizeCommitValue(entry, 240)).filter((entry) => entry.length > 0);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/** Returns true only for validation steps that invoke a recognizable test runner. */
|
|
1638
|
+
export function isTestLikeValidationStep(step: string): boolean {
|
|
1639
|
+
const classify = (candidate: string): boolean => {
|
|
1640
|
+
const argv = tokenizeValidationCommandArgv(candidate);
|
|
1641
|
+
if (!argv || argv.length === 0) return false;
|
|
1642
|
+
const tool = argv[0].toLowerCase();
|
|
1643
|
+
const hasToken = (token: string) => argv.some((entry) => entry.toLowerCase() === token);
|
|
1644
|
+
|
|
1645
|
+
switch (tool) {
|
|
1646
|
+
case "bun":
|
|
1647
|
+
case "npm":
|
|
1648
|
+
case "pnpm":
|
|
1649
|
+
case "yarn": {
|
|
1650
|
+
// "bun test", "npm test", "yarn test"
|
|
1651
|
+
if (hasToken("test")) return true;
|
|
1652
|
+
const sub = argv[1]?.toLowerCase() ?? "";
|
|
1653
|
+
// "bun run test:root", "npm run test:unit", "pnpm run test:integration"
|
|
1654
|
+
if (sub === "run" && argv[2]?.toLowerCase().startsWith("test")) return true;
|
|
1655
|
+
// "yarn test:integration" — second token itself starts with "test"
|
|
1656
|
+
if (sub.startsWith("test")) return true;
|
|
1657
|
+
// "bun ./tests/file.ts" or "bun ./path/to/foo.test.ts" — direct execution
|
|
1658
|
+
if (tool === "bun") {
|
|
1659
|
+
return argv
|
|
1660
|
+
.slice(1)
|
|
1661
|
+
.some((arg) => /(?:^|[/\\])tests?[/\\]|\.test\.[a-z]+$|\.spec\.[a-z]+$/i.test(arg));
|
|
1662
|
+
}
|
|
1663
|
+
return false;
|
|
1664
|
+
}
|
|
1665
|
+
case "pytest":
|
|
1666
|
+
case "vitest":
|
|
1667
|
+
case "jest":
|
|
1668
|
+
return true;
|
|
1669
|
+
case "python":
|
|
1670
|
+
return (
|
|
1671
|
+
argv.length >= 3 && argv[1].toLowerCase() === "-m" && argv[2].toLowerCase() === "pytest"
|
|
1672
|
+
);
|
|
1673
|
+
case "coverage":
|
|
1674
|
+
return hasToken("pytest");
|
|
1675
|
+
default:
|
|
1676
|
+
return false;
|
|
1677
|
+
}
|
|
1678
|
+
};
|
|
1679
|
+
|
|
1680
|
+
if (classify(step)) return true;
|
|
1681
|
+
// Also check commands wrapped in backticks (e.g. "Run `bun --cwd apps/localbuddy test`").
|
|
1682
|
+
const fenced = step.match(/`([^`]+)`/)?.[1]?.trim() ?? "";
|
|
1683
|
+
return fenced ? classify(fenced) : false;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function buildCommitTestsBlock(params?: Record<string, unknown>): string {
|
|
1687
|
+
const planning =
|
|
1688
|
+
params && typeof params.planning === "object" && !Array.isArray(params.planning)
|
|
1689
|
+
? (params.planning as Record<string, unknown>)
|
|
1690
|
+
: undefined;
|
|
1691
|
+
|
|
1692
|
+
const candidates = [
|
|
1693
|
+
...toNonEmptyStringArray(params?.validationSteps),
|
|
1694
|
+
...toNonEmptyStringArray(params?.validation_steps),
|
|
1695
|
+
...toNonEmptyStringArray(planning?.validationSteps),
|
|
1696
|
+
...toNonEmptyStringArray(planning?.validation_steps),
|
|
1697
|
+
];
|
|
1698
|
+
|
|
1699
|
+
const seen = new Set<string>();
|
|
1700
|
+
const unique = candidates
|
|
1701
|
+
.filter((entry) => {
|
|
1702
|
+
if (seen.has(entry)) return false;
|
|
1703
|
+
seen.add(entry);
|
|
1704
|
+
return true;
|
|
1705
|
+
})
|
|
1706
|
+
.filter(isTestLikeValidationStep);
|
|
1707
|
+
|
|
1708
|
+
if (unique.length === 0) return "- not run (no test commands provided)";
|
|
1709
|
+
return unique.map((entry) => `- ${entry}`).join("\n");
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
function shouldIncludeCommitMeta(params?: Record<string, unknown>): boolean {
|
|
1713
|
+
return (
|
|
1714
|
+
parseBooleanFlag(params?.commitIncludeMeta) ||
|
|
1715
|
+
parseBooleanFlag(params?.includeCommitMeta) ||
|
|
1716
|
+
parseBooleanFlag(params?.commit_meta)
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
function buildCommitMetaBlock(
|
|
1721
|
+
kind: string,
|
|
1722
|
+
params: Record<string, unknown> | undefined,
|
|
1723
|
+
replacements: {
|
|
1724
|
+
worker_id: string;
|
|
1725
|
+
task_id: string;
|
|
1726
|
+
job_id: string;
|
|
1727
|
+
context: string;
|
|
1728
|
+
session_line: string;
|
|
1729
|
+
},
|
|
1730
|
+
changedPaths: string[] = [],
|
|
1731
|
+
): string {
|
|
1732
|
+
const lines = [
|
|
1733
|
+
"Meta:",
|
|
1734
|
+
`- scope: ${sanitizeCommitValue(summarizeScope(kind, params, changedPaths), 220)}`,
|
|
1735
|
+
`- job kind: ${sanitizeCommitValue(kind, 64)}`,
|
|
1736
|
+
`- traceability: worker ${replacements.worker_id}, task ${replacements.task_id}, job ${replacements.job_id}`,
|
|
1737
|
+
`- execution context: ${replacements.context}`,
|
|
1738
|
+
];
|
|
1739
|
+
if (replacements.session_line) lines.push(replacements.session_line);
|
|
1740
|
+
return `\n\n${lines.join("\n")}`;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function summarizeJobAction(kind: string, params?: Record<string, unknown>): string {
|
|
1744
|
+
const p = params ?? {};
|
|
1745
|
+
const get = (key: string): string => sanitizeCommitValue(p[key]);
|
|
1746
|
+
|
|
1747
|
+
switch (kind) {
|
|
1748
|
+
case "file.write":
|
|
1749
|
+
return `write ${get("path") || "<path>"}`;
|
|
1750
|
+
case "file.patch":
|
|
1751
|
+
return `patch ${get("path") || "<path>"}`;
|
|
1752
|
+
case "file.append":
|
|
1753
|
+
return `append ${get("path") || "<path>"}`;
|
|
1754
|
+
case "file.rename":
|
|
1755
|
+
return `rename ${get("from") || "<from>"} -> ${get("to") || "<to>"}`;
|
|
1756
|
+
case "file.copy":
|
|
1757
|
+
return `copy ${get("from") || "<from>"} -> ${get("to") || "<to>"}`;
|
|
1758
|
+
case "file.delete":
|
|
1759
|
+
return `delete ${get("path") || "<path>"}`;
|
|
1760
|
+
case "file.mkdir":
|
|
1761
|
+
return `mkdir ${get("path") || "<path>"}`;
|
|
1762
|
+
case "shell.exec":
|
|
1763
|
+
return `exec ${get("command") || "<command>"}`;
|
|
1764
|
+
case "bun.test":
|
|
1765
|
+
return get("filter") ? `test filter=${get("filter")}` : "run bun test";
|
|
1766
|
+
case "bun.lint":
|
|
1767
|
+
return "run bun lint";
|
|
1768
|
+
case "web.fetch":
|
|
1769
|
+
return `fetch ${get("url") || "<url>"}`;
|
|
1770
|
+
case "web.search":
|
|
1771
|
+
return `search ${get("query") || "<query>"}`;
|
|
1772
|
+
case "task.execute":
|
|
1773
|
+
return `execute ${get("targetPath") || get("path") || inferTargetPathFromInstruction(get("instruction")) || "task"}`;
|
|
1774
|
+
default:
|
|
1775
|
+
return kind;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function combinedGitOutput(result: { stdout: string; stderr: string }): string {
|
|
1780
|
+
return [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
export function isNonFastForwardPushOutput(text: string): boolean {
|
|
1784
|
+
const normalized = text.toLowerCase();
|
|
1785
|
+
return (
|
|
1786
|
+
normalized.includes("non-fast-forward") ||
|
|
1787
|
+
normalized.includes("fetch first") ||
|
|
1788
|
+
normalized.includes("failed to push some refs") ||
|
|
1789
|
+
normalized.includes("updates were rejected because") ||
|
|
1790
|
+
normalized.includes("tip is behind its remote counterpart")
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
export function isRebaseConflictOutput(text: string): boolean {
|
|
1795
|
+
const normalized = text.toLowerCase();
|
|
1796
|
+
return (
|
|
1797
|
+
normalized.includes("conflict") ||
|
|
1798
|
+
normalized.includes("resolve all conflicts manually") ||
|
|
1799
|
+
normalized.includes("could not apply") ||
|
|
1800
|
+
normalized.includes("fix conflicts and then run")
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
export function isRebaseEditorPromptOutput(text: string): boolean {
|
|
1805
|
+
const normalized = text.toLowerCase();
|
|
1806
|
+
return (
|
|
1807
|
+
normalized.includes("terminal is dumb, but editor unset") ||
|
|
1808
|
+
normalized.includes("please supply the message using either -m or -f option") ||
|
|
1809
|
+
normalized.includes("waiting for your editor to close the file")
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
export function isPullRebaseDirtyWorkingTreeOutput(text: string): boolean {
|
|
1814
|
+
const normalized = text.toLowerCase();
|
|
1815
|
+
return (
|
|
1816
|
+
normalized.includes("cannot pull with rebase: you have unstaged changes") ||
|
|
1817
|
+
normalized.includes("cannot rebase: you have unstaged changes") ||
|
|
1818
|
+
normalized.includes("please commit or stash them")
|
|
1819
|
+
);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
async function currentRefSha(repo: string, ref: string): Promise<string | null> {
|
|
1823
|
+
const result = await git(repo, ["rev-parse", ref]);
|
|
1824
|
+
if (!result.ok) return null;
|
|
1825
|
+
return result.stdout.trim() || null;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
async function autoResolveRebaseConflicts(
|
|
1829
|
+
repo: string,
|
|
1830
|
+
maxPasses = 8,
|
|
1831
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
1832
|
+
for (let pass = 1; pass <= maxPasses; pass++) {
|
|
1833
|
+
const unresolved = await git(repo, ["diff", "--name-only", "--diff-filter=U"]);
|
|
1834
|
+
if (!unresolved.ok) {
|
|
1835
|
+
return {
|
|
1836
|
+
ok: false,
|
|
1837
|
+
error: `Failed to inspect rebase conflicts: ${combinedGitOutput(unresolved)}`,
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
const unresolvedPaths = parseChangedPathsFromNameOnlyOutput(unresolved.stdout);
|
|
1841
|
+
if (unresolvedPaths.length > 0) {
|
|
1842
|
+
console.warn(
|
|
1843
|
+
`[WorkerPals] Rebase conflict detected (${unresolvedPaths.length} file(s)); auto-resolving in favor of worker changes (pass ${pass}/${maxPasses}).`,
|
|
1844
|
+
);
|
|
1845
|
+
for (const path of unresolvedPaths) {
|
|
1846
|
+
// In rebase conflicts, --theirs preserves the worker commit currently being replayed.
|
|
1847
|
+
let checkout = await git(repo, ["checkout", "--theirs", "--", path]);
|
|
1848
|
+
if (!checkout.ok) {
|
|
1849
|
+
checkout = await git(repo, ["checkout", "--ours", "--", path]);
|
|
1850
|
+
if (!checkout.ok) {
|
|
1851
|
+
return {
|
|
1852
|
+
ok: false,
|
|
1853
|
+
error: `Failed to resolve rebase conflict for ${path}: ${combinedGitOutput(checkout)}`,
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
// Stage resolved tracked files before continuing rebase, without pulling in unrelated untracked artifacts.
|
|
1859
|
+
const addAll = await git(repo, ["add", "--update", "--", "."]);
|
|
1860
|
+
if (!addAll.ok) {
|
|
1861
|
+
return {
|
|
1862
|
+
ok: false,
|
|
1863
|
+
error: `Failed to stage resolved rebase conflicts: ${combinedGitOutput(addAll)}`,
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
let rebaseContinue = await git(repo, ["-c", "core.editor=true", "rebase", "--continue"]);
|
|
1869
|
+
let continueOutput = combinedGitOutput(rebaseContinue);
|
|
1870
|
+
if (!rebaseContinue.ok && isRebaseEditorPromptOutput(continueOutput)) {
|
|
1871
|
+
// Ensure rebase continuation stays non-interactive in worker environments.
|
|
1872
|
+
rebaseContinue = await git(repo, ["-c", "core.editor=true", "rebase", "--continue"]);
|
|
1873
|
+
continueOutput = combinedGitOutput(rebaseContinue);
|
|
1874
|
+
}
|
|
1875
|
+
if (rebaseContinue.ok) {
|
|
1876
|
+
continue;
|
|
1877
|
+
}
|
|
1878
|
+
if (/no rebase in progress/i.test(continueOutput)) {
|
|
1879
|
+
return { ok: true };
|
|
1880
|
+
}
|
|
1881
|
+
if (/no changes - did you forget to use 'git add'|nothing to commit/i.test(continueOutput)) {
|
|
1882
|
+
const rebaseSkip = await git(repo, ["rebase", "--skip"]);
|
|
1883
|
+
if (rebaseSkip.ok) {
|
|
1884
|
+
continue;
|
|
1885
|
+
}
|
|
1886
|
+
const skipOutput = combinedGitOutput(rebaseSkip);
|
|
1887
|
+
if (isRebaseConflictOutput(skipOutput)) {
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
return { ok: false, error: `Failed to skip empty rebase commit: ${skipOutput}` };
|
|
1891
|
+
}
|
|
1892
|
+
if (isRebaseConflictOutput(continueOutput)) {
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1895
|
+
return { ok: false, error: `Failed to continue rebase: ${continueOutput}` };
|
|
1896
|
+
}
|
|
1897
|
+
return {
|
|
1898
|
+
ok: false,
|
|
1899
|
+
error: `Rebase conflict auto-resolution exceeded ${maxPasses} passes; manual intervention required.`,
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
export async function syncHiddenRefWithRemoteBranchByRebase(
|
|
1904
|
+
repo: string,
|
|
1905
|
+
hiddenCommitRef: string,
|
|
1906
|
+
publicBranchName: string,
|
|
1907
|
+
jobId: string,
|
|
1908
|
+
): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
|
|
1909
|
+
const pullRebaseNonInteractive = () =>
|
|
1910
|
+
git(repo, [
|
|
1911
|
+
"-c",
|
|
1912
|
+
"core.editor=true",
|
|
1913
|
+
"-c",
|
|
1914
|
+
"rebase.autoStash=true",
|
|
1915
|
+
"pull",
|
|
1916
|
+
"--rebase",
|
|
1917
|
+
"origin",
|
|
1918
|
+
publicBranchName,
|
|
1919
|
+
]);
|
|
1920
|
+
|
|
1921
|
+
const remoteHead = await git(repo, [
|
|
1922
|
+
"ls-remote",
|
|
1923
|
+
"--heads",
|
|
1924
|
+
"origin",
|
|
1925
|
+
`refs/heads/${publicBranchName}`,
|
|
1926
|
+
]);
|
|
1927
|
+
if (!remoteHead.ok) {
|
|
1928
|
+
return {
|
|
1929
|
+
ok: false,
|
|
1930
|
+
error: `Failed to inspect remote branch ${publicBranchName}: ${combinedGitOutput(remoteHead)}`,
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
const remoteExists = remoteHead.stdout.trim().length > 0;
|
|
1934
|
+
if (!remoteExists) {
|
|
1935
|
+
const sha = await currentRefSha(repo, hiddenCommitRef);
|
|
1936
|
+
if (!sha) return { ok: false, error: `Failed to resolve commit SHA for ${hiddenCommitRef}.` };
|
|
1937
|
+
return { ok: true, sha };
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const tempBranch = `_pushpals/rebase-${jobId.slice(0, 8)}-${Date.now().toString(36)}`;
|
|
1941
|
+
let branchCheckedOut = false;
|
|
1942
|
+
try {
|
|
1943
|
+
const checkout = await git(repo, ["checkout", "-B", tempBranch, hiddenCommitRef]);
|
|
1944
|
+
if (!checkout.ok) {
|
|
1945
|
+
return {
|
|
1946
|
+
ok: false,
|
|
1947
|
+
error: `Failed to prepare temporary rebase branch ${tempBranch}: ${combinedGitOutput(checkout)}`,
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
branchCheckedOut = true;
|
|
1951
|
+
|
|
1952
|
+
const maxPullRebaseAttempts = 5;
|
|
1953
|
+
let syncedWithRemote = false;
|
|
1954
|
+
for (let attempt = 1; attempt <= maxPullRebaseAttempts; attempt++) {
|
|
1955
|
+
let pullRebase = await pullRebaseNonInteractive();
|
|
1956
|
+
if (!pullRebase.ok && isPullRebaseDirtyWorkingTreeOutput(combinedGitOutput(pullRebase))) {
|
|
1957
|
+
// Recover from dirty index/worktree left by previous attempts and retry non-interactively.
|
|
1958
|
+
const reset = await git(repo, ["reset", "--hard", "HEAD"]);
|
|
1959
|
+
if (!reset.ok) {
|
|
1960
|
+
return {
|
|
1961
|
+
ok: false,
|
|
1962
|
+
error: `Failed to clean working tree before retrying pull --rebase: ${combinedGitOutput(reset)}`,
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
pullRebase = await pullRebaseNonInteractive();
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
if (pullRebase.ok) {
|
|
1969
|
+
syncedWithRemote = true;
|
|
1970
|
+
break;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
const pullOutput = combinedGitOutput(pullRebase);
|
|
1974
|
+
if (!isRebaseConflictOutput(pullOutput)) {
|
|
1975
|
+
return {
|
|
1976
|
+
ok: false,
|
|
1977
|
+
error: `git pull --rebase failed for ${publicBranchName}: ${pullOutput}`,
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
const resolved = await autoResolveRebaseConflicts(repo);
|
|
1981
|
+
if (!resolved.ok) {
|
|
1982
|
+
await git(repo, ["rebase", "--abort"]);
|
|
1983
|
+
return {
|
|
1984
|
+
ok: false,
|
|
1985
|
+
error: `Rebase conflict resolution failed for ${publicBranchName}: ${resolved.error}`,
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
if (attempt < maxPullRebaseAttempts) {
|
|
1989
|
+
console.warn(
|
|
1990
|
+
`[WorkerPals] Rebase conflicts resolved for ${publicBranchName}; re-running git pull --rebase (attempt ${attempt + 1}/${maxPullRebaseAttempts}).`,
|
|
1991
|
+
);
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
if (!syncedWithRemote) {
|
|
1995
|
+
return {
|
|
1996
|
+
ok: false,
|
|
1997
|
+
error: `Failed to sync ${publicBranchName} after ${maxPullRebaseAttempts} pull --rebase attempt(s).`,
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
const rebasedSha = await currentRefSha(repo, "HEAD");
|
|
2002
|
+
if (!rebasedSha) {
|
|
2003
|
+
return { ok: false, error: "Failed to resolve rebased commit SHA after pull --rebase." };
|
|
2004
|
+
}
|
|
2005
|
+
const updateHiddenRef = await git(repo, ["update-ref", hiddenCommitRef, rebasedSha]);
|
|
2006
|
+
if (!updateHiddenRef.ok) {
|
|
2007
|
+
return {
|
|
2008
|
+
ok: false,
|
|
2009
|
+
error: `Failed to update hidden commit ref after rebase: ${combinedGitOutput(updateHiddenRef)}`,
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
return { ok: true, sha: rebasedSha };
|
|
2013
|
+
} finally {
|
|
2014
|
+
if (branchCheckedOut) {
|
|
2015
|
+
await git(repo, ["checkout", "--detach", hiddenCommitRef]);
|
|
2016
|
+
await git(repo, ["branch", "-D", tempBranch]);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
export function shouldUseCodexCliForExecutor(executor: string): boolean {
|
|
2022
|
+
return executor.trim().toLowerCase() === "openai_codex";
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
function normalizeCodexReasoningEffort(value: unknown): "low" | "medium" | "high" {
|
|
2026
|
+
const normalized = String(value ?? "")
|
|
2027
|
+
.trim()
|
|
2028
|
+
.toLowerCase();
|
|
2029
|
+
if (normalized === "low" || normalized === "medium" || normalized === "high") {
|
|
2030
|
+
return normalized;
|
|
2031
|
+
}
|
|
2032
|
+
return "high";
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
async function generateCommitMessageFromDiff(
|
|
2036
|
+
diff: string,
|
|
2037
|
+
opts: { instruction: string; type: string; area: string; validationSteps: string[] },
|
|
2038
|
+
repo: string,
|
|
2039
|
+
runtimeConfig: WorkerpalsRuntimeConfig,
|
|
2040
|
+
): Promise<string | null> {
|
|
2041
|
+
const prompt = buildCommitMessageGeneratorPrompt(diff, opts);
|
|
2042
|
+
if (!prompt) return null;
|
|
2043
|
+
if (shouldUseCodexCliForExecutor(resolveExecutor(runtimeConfig))) {
|
|
2044
|
+
return generateCommitMessageFromDiffViaCodex(prompt, opts, repo, runtimeConfig);
|
|
2045
|
+
}
|
|
2046
|
+
return generateCommitMessageFromDiffViaHttp(prompt, opts, runtimeConfig);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
type CommitMessagePrompt = {
|
|
2050
|
+
systemPrompt: string;
|
|
2051
|
+
userMessage: string;
|
|
2052
|
+
};
|
|
2053
|
+
|
|
2054
|
+
function buildCommitMessageGeneratorPrompt(
|
|
2055
|
+
diff: string,
|
|
2056
|
+
opts: { instruction: string; type: string; area: string; validationSteps: string[] },
|
|
2057
|
+
): CommitMessagePrompt | null {
|
|
2058
|
+
if (!diff.trim()) return null;
|
|
2059
|
+
let systemPrompt: string;
|
|
2060
|
+
try {
|
|
2061
|
+
systemPrompt = loadPromptTemplate("workerpals/commit_message_prompt.md", {
|
|
2062
|
+
type: opts.type,
|
|
2063
|
+
area: opts.area,
|
|
2064
|
+
}).trim();
|
|
2065
|
+
if (!systemPrompt || systemPrompt.includes("{{")) return null;
|
|
2066
|
+
} catch {
|
|
2067
|
+
return null;
|
|
2068
|
+
}
|
|
2069
|
+
const userMessage = buildCommitMessageGeneratorUserMessage(
|
|
2070
|
+
opts.instruction,
|
|
2071
|
+
opts.validationSteps,
|
|
2072
|
+
diff,
|
|
2073
|
+
);
|
|
2074
|
+
return { systemPrompt, userMessage };
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
async function generateCommitMessageFromDiffViaCodex(
|
|
2078
|
+
prompt: CommitMessagePrompt,
|
|
2079
|
+
opts: { type: string; area: string },
|
|
2080
|
+
repo: string,
|
|
2081
|
+
runtimeConfig: WorkerpalsRuntimeConfig,
|
|
2082
|
+
): Promise<string | null> {
|
|
2083
|
+
const codexPrefix = await resolveCodexCommandPrefix(repo, runtimeConfig.workerpals.llm.codexBin);
|
|
2084
|
+
if (!codexPrefix) return null;
|
|
2085
|
+
const model = runtimeConfig.workerpals.llm.model.trim();
|
|
2086
|
+
const timeoutMs = (() => {
|
|
2087
|
+
const value = Number(runtimeConfig.workerpals.llm.codexTimeoutMs);
|
|
2088
|
+
if (!Number.isFinite(value)) return 120_000;
|
|
2089
|
+
return Math.max(10_000, Math.min(600_000, Math.floor(value)));
|
|
2090
|
+
})();
|
|
2091
|
+
const reasoningEffort = normalizeCodexReasoningEffort(
|
|
2092
|
+
runtimeConfig.workerpals.llm.reasoningEffort,
|
|
2093
|
+
);
|
|
2094
|
+
const tmpOutputPath = resolve(
|
|
2095
|
+
Bun.env.TEMP || Bun.env.TMP || Bun.env.TMPDIR || "/tmp",
|
|
2096
|
+
`pushpals-commit-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`,
|
|
2097
|
+
);
|
|
2098
|
+
const cmd = [
|
|
2099
|
+
...codexPrefix,
|
|
2100
|
+
"-c",
|
|
2101
|
+
`model_reasoning_effort="${reasoningEffort}"`,
|
|
2102
|
+
"-a",
|
|
2103
|
+
"never",
|
|
2104
|
+
"-s",
|
|
2105
|
+
"read-only",
|
|
2106
|
+
"exec",
|
|
2107
|
+
"--color",
|
|
2108
|
+
"never",
|
|
2109
|
+
"--output-last-message",
|
|
2110
|
+
tmpOutputPath,
|
|
2111
|
+
];
|
|
2112
|
+
if (model) cmd.push("-m", model);
|
|
2113
|
+
cmd.push("-");
|
|
2114
|
+
|
|
2115
|
+
try {
|
|
2116
|
+
const stdinText = `${prompt.systemPrompt}\n\n${prompt.userMessage}`;
|
|
2117
|
+
const proc = Bun.spawn(cmd, {
|
|
2118
|
+
cwd: repo,
|
|
2119
|
+
stdout: "pipe",
|
|
2120
|
+
stderr: "pipe",
|
|
2121
|
+
stdin: new Blob([stdinText]),
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
let timedOut = false;
|
|
2125
|
+
const timer = setTimeout(() => {
|
|
2126
|
+
timedOut = true;
|
|
2127
|
+
try {
|
|
2128
|
+
proc.kill();
|
|
2129
|
+
} catch {
|
|
2130
|
+
// ignore
|
|
2131
|
+
}
|
|
2132
|
+
}, timeoutMs);
|
|
2133
|
+
|
|
2134
|
+
const exitCode = await proc.exited;
|
|
2135
|
+
clearTimeout(timer);
|
|
2136
|
+
if (timedOut || exitCode !== 0) return null;
|
|
2137
|
+
|
|
2138
|
+
let content = "";
|
|
2139
|
+
try {
|
|
2140
|
+
content = readFileSync(tmpOutputPath, "utf8").trim();
|
|
2141
|
+
} catch {
|
|
2142
|
+
content = "";
|
|
2143
|
+
}
|
|
2144
|
+
if (!content) {
|
|
2145
|
+
content = (await new Response(proc.stdout).text()).trim();
|
|
2146
|
+
}
|
|
2147
|
+
if (!content) return null;
|
|
2148
|
+
const clean = sanitizeGeneratedCommitMessage(content, opts.type, opts.area);
|
|
2149
|
+
return clean;
|
|
2150
|
+
} catch {
|
|
2151
|
+
return null;
|
|
2152
|
+
} finally {
|
|
2153
|
+
try {
|
|
2154
|
+
unlinkSync(tmpOutputPath);
|
|
2155
|
+
} catch {
|
|
2156
|
+
// ignore
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
async function generateCommitMessageFromDiffViaHttp(
|
|
2162
|
+
prompt: CommitMessagePrompt,
|
|
2163
|
+
opts: { type: string; area: string },
|
|
2164
|
+
runtimeConfig: WorkerpalsRuntimeConfig,
|
|
2165
|
+
): Promise<string | null> {
|
|
2166
|
+
const endpoint = normalizeChatCompletionsEndpoint(runtimeConfig.workerpals.llm.endpoint);
|
|
2167
|
+
const model = runtimeConfig.workerpals.llm.model.trim();
|
|
2168
|
+
if (!endpoint || !model) return null;
|
|
2169
|
+
|
|
2170
|
+
const apiKey = runtimeConfig.workerpals.llm.apiKey.trim() || "local";
|
|
2171
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
2172
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
2173
|
+
|
|
2174
|
+
const controller = new AbortController();
|
|
2175
|
+
const timer = setTimeout(() => controller.abort(), 30_000);
|
|
2176
|
+
try {
|
|
2177
|
+
const response = await fetch(endpoint, {
|
|
2178
|
+
method: "POST",
|
|
2179
|
+
headers,
|
|
2180
|
+
body: JSON.stringify({
|
|
2181
|
+
model,
|
|
2182
|
+
messages: [
|
|
2183
|
+
{ role: "system", content: prompt.systemPrompt },
|
|
2184
|
+
{ role: "user", content: prompt.userMessage },
|
|
2185
|
+
],
|
|
2186
|
+
temperature: 0,
|
|
2187
|
+
max_tokens: 500,
|
|
2188
|
+
}),
|
|
2189
|
+
signal: controller.signal,
|
|
2190
|
+
});
|
|
2191
|
+
clearTimeout(timer);
|
|
2192
|
+
if (!response.ok) return null;
|
|
2193
|
+
const payload = parseJsonObjectLoose(await response.text());
|
|
2194
|
+
if (!payload) return null;
|
|
2195
|
+
const choices = Array.isArray(payload.choices)
|
|
2196
|
+
? (payload.choices as Array<Record<string, unknown>>)
|
|
2197
|
+
: [];
|
|
2198
|
+
const content = String(
|
|
2199
|
+
(choices[0]?.message as Record<string, unknown> | undefined)?.content ?? "",
|
|
2200
|
+
).trim();
|
|
2201
|
+
if (!content) return null;
|
|
2202
|
+
const clean = sanitizeGeneratedCommitMessage(content, opts.type, opts.area);
|
|
2203
|
+
if (!clean) return null;
|
|
2204
|
+
return clean;
|
|
2205
|
+
} catch {
|
|
2206
|
+
clearTimeout(timer);
|
|
2207
|
+
return null;
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
export function buildCommitMessageGeneratorUserMessage(
|
|
2212
|
+
instruction: string,
|
|
2213
|
+
validationSteps: string[],
|
|
2214
|
+
diff: string,
|
|
2215
|
+
): string {
|
|
2216
|
+
const testLines =
|
|
2217
|
+
validationSteps
|
|
2218
|
+
.filter(isTestLikeValidationStep)
|
|
2219
|
+
.map((step) => `- ${step}`)
|
|
2220
|
+
.join("\n") || "- (none)";
|
|
2221
|
+
return loadPromptTemplate("workerpals/commit_message_user_prompt.md", {
|
|
2222
|
+
diff_excerpt: diff.slice(0, COMMIT_MSG_MAX_DIFF_CHARS),
|
|
2223
|
+
test_lines: testLines,
|
|
2224
|
+
instruction_excerpt: instruction.slice(0, 400),
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/**
|
|
2229
|
+
* Returns true for bullet text that reads like planning/acceptance criteria
|
|
2230
|
+
* rather than a concrete description of what the diff changed.
|
|
2231
|
+
*/
|
|
2232
|
+
function isPlanningLanguageBullet(bullet: string): boolean {
|
|
2233
|
+
return /^at least\b|^all existing\b|^no unrelated\b|\bshould be\b|\bmust be\b|\bwill (pass|work|run|be)\b|\bare (added|modified|changed|updated|created)\b/i.test(
|
|
2234
|
+
bullet,
|
|
2235
|
+
);
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
export function sanitizeGeneratedCommitMessage(
|
|
2239
|
+
content: string,
|
|
2240
|
+
type: string,
|
|
2241
|
+
area: string,
|
|
2242
|
+
): string | null {
|
|
2243
|
+
// Strip accidental markdown fences.
|
|
2244
|
+
const clean = content
|
|
2245
|
+
.replace(/^```[^\n]*\n?/m, "")
|
|
2246
|
+
.replace(/\n?```\s*$/m, "")
|
|
2247
|
+
.trim();
|
|
2248
|
+
// Sanity check: must open with the expected conventional commit prefix.
|
|
2249
|
+
if (!clean.startsWith(`${type}(${area})`)) return null;
|
|
2250
|
+
// Reject if the majority of bullet points look like planning/acceptance criteria
|
|
2251
|
+
// rather than concrete code-change descriptions derived from the diff.
|
|
2252
|
+
const lines = clean.split("\n");
|
|
2253
|
+
const testsSectionIndex = lines.findIndex((line) => /^Tests:\s*$/i.test(line.trim()));
|
|
2254
|
+
const implementationLines = testsSectionIndex >= 0 ? lines.slice(0, testsSectionIndex) : lines;
|
|
2255
|
+
const bullets = implementationLines
|
|
2256
|
+
.filter((line) => /^\s*-\s+\S/.test(line) && !/^Tests:/i.test(line.trim()))
|
|
2257
|
+
.map((line) => line.replace(/^\s*-\s+/, "").trim());
|
|
2258
|
+
const planningCount = bullets.filter(isPlanningLanguageBullet).length;
|
|
2259
|
+
if (bullets.length > 0 && planningCount / bullets.length >= 0.67) return null;
|
|
2260
|
+
return clean;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
export function buildWorkerCommitMessage(
|
|
2264
|
+
workerId: string,
|
|
2265
|
+
job: {
|
|
2266
|
+
id: string;
|
|
2267
|
+
taskId: string;
|
|
2268
|
+
kind: string;
|
|
2269
|
+
params?: Record<string, unknown>;
|
|
2270
|
+
sessionId?: string;
|
|
2271
|
+
context?: "host" | "docker";
|
|
2272
|
+
},
|
|
2273
|
+
changedPaths: string[] = [],
|
|
2274
|
+
): string {
|
|
2275
|
+
const normalizedChangedPaths = parseChangedPathsFromNameOnlyOutput(changedPaths.join("\n"));
|
|
2276
|
+
const action = summarizeJobAction(job.kind, job.params);
|
|
2277
|
+
const type = normalizeCommitType(job.kind, job.params);
|
|
2278
|
+
const area = inferCommitArea(job.kind, job.params, normalizedChangedPaths);
|
|
2279
|
+
const summary = deriveSummary(action, job.params, normalizedChangedPaths, area);
|
|
2280
|
+
const implementationPoints =
|
|
2281
|
+
buildImplementationPoints(job.kind, job.params, normalizedChangedPaths) ||
|
|
2282
|
+
`- ${sanitizeCommitValue(action, 220) || "apply requested repository update"}`;
|
|
2283
|
+
const testsBlock = buildCommitTestsBlock(job.params);
|
|
2284
|
+
const lines: string[] = [
|
|
2285
|
+
`${sanitizeCommitValue(type, 16)}(${sanitizeCommitValue(area, 48)}): ${sanitizeCommitValue(summary, 72)}`,
|
|
2286
|
+
"",
|
|
2287
|
+
implementationPoints,
|
|
2288
|
+
"",
|
|
2289
|
+
"Tests:",
|
|
2290
|
+
testsBlock,
|
|
2291
|
+
];
|
|
2292
|
+
if (shouldIncludeCommitMeta(job.params)) {
|
|
2293
|
+
const contextValue = sanitizeCommitValue(job.context ?? "host", 32);
|
|
2294
|
+
const sessionValue = sanitizeCommitValue(job.sessionId ?? "", 128);
|
|
2295
|
+
lines.push(
|
|
2296
|
+
buildCommitMetaBlock(
|
|
2297
|
+
job.kind,
|
|
2298
|
+
job.params,
|
|
2299
|
+
{
|
|
2300
|
+
worker_id: sanitizeCommitValue(workerId, 64),
|
|
2301
|
+
task_id: sanitizeCommitValue(job.taskId, 128),
|
|
2302
|
+
job_id: sanitizeCommitValue(job.id, 128),
|
|
2303
|
+
context: contextValue || "host",
|
|
2304
|
+
session_line: sessionValue ? `- session: ${sessionValue}` : "",
|
|
2305
|
+
},
|
|
2306
|
+
normalizedChangedPaths,
|
|
2307
|
+
),
|
|
2308
|
+
);
|
|
2309
|
+
}
|
|
2310
|
+
return lines.join("\n");
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// ─── Job execution ───────────────────────────────────────────────────────────
|
|
2314
|
+
|
|
2315
|
+
export type { JobResult } from "./common/types.js";
|
|
2316
|
+
|
|
2317
|
+
const SUPPORTED_JOB_KINDS = new Set(["warmup.execute", "task.execute"]);
|
|
2318
|
+
|
|
2319
|
+
type TaskExecutePriority = "interactive" | "normal" | "background";
|
|
2320
|
+
type TaskExecuteIntent = "chat" | "status" | "code_change" | "analysis" | "other";
|
|
2321
|
+
type TaskExecuteRisk = "low" | "medium" | "high";
|
|
2322
|
+
|
|
2323
|
+
function isStringArray(value: unknown): value is string[] {
|
|
2324
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
function hasInvalidRepoPathHint(values: string[]): boolean {
|
|
2328
|
+
return values.some((entry) => normalizeStagePath(entry) === null);
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
function asAutonomyComponentArea(value: unknown): AutonomyComponentArea | null {
|
|
2332
|
+
return normalizeAutonomyComponentArea(value);
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function taskExecuteOrigin(params: Record<string, unknown>): "autonomy" | "user" {
|
|
2336
|
+
const explicit = String(params.origin ?? "")
|
|
2337
|
+
.trim()
|
|
2338
|
+
.toLowerCase();
|
|
2339
|
+
if (explicit === "autonomy") return "autonomy";
|
|
2340
|
+
const autonomy = params.autonomy;
|
|
2341
|
+
if (autonomy && typeof autonomy === "object" && !Array.isArray(autonomy)) {
|
|
2342
|
+
const nested = String((autonomy as Record<string, unknown>).origin ?? "")
|
|
2343
|
+
.trim()
|
|
2344
|
+
.toLowerCase();
|
|
2345
|
+
if (nested === "autonomy") return "autonomy";
|
|
2346
|
+
}
|
|
2347
|
+
return "user";
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
async function collectWriteScopeWarnings(
|
|
2351
|
+
repo: string,
|
|
2352
|
+
planning: TaskExecutePlanning,
|
|
2353
|
+
): Promise<{ warnings: string[] }> {
|
|
2354
|
+
const writeGlobs = toStringArray(planning.scope.writeGlobs ?? []);
|
|
2355
|
+
if (writeGlobs.length === 0) return { warnings: [] };
|
|
2356
|
+
|
|
2357
|
+
const statusResult = await git(repo, ["status", "--porcelain"]);
|
|
2358
|
+
if (!statusResult.ok) {
|
|
2359
|
+
return { warnings: ["Unable to evaluate changed paths for scope suggestion check."] };
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
const changedPaths = parseChangedPathsFromStatus(statusResult.stdout)
|
|
2363
|
+
.map((entry) => normalizeStagePath(entry))
|
|
2364
|
+
.filter((entry): entry is string => Boolean(entry) && entry !== ".");
|
|
2365
|
+
if (changedPaths.length === 0) return { warnings: [] };
|
|
2366
|
+
|
|
2367
|
+
const forbidden = toStringArray(planning.scope.forbiddenGlobs ?? []);
|
|
2368
|
+
const warnings: string[] = [];
|
|
2369
|
+
const outOfScope = changedPaths.filter(
|
|
2370
|
+
(path) => !writeGlobs.some((glob) => matchesGlob(path, glob)),
|
|
2371
|
+
);
|
|
2372
|
+
if (outOfScope.length > 0) {
|
|
2373
|
+
warnings.push(`Scope suggestion: modified paths outside writeGlobs: ${outOfScope.join(", ")}`);
|
|
2374
|
+
}
|
|
2375
|
+
const forbiddenTouched = changedPaths.filter((path) =>
|
|
2376
|
+
forbidden.some((glob) => matchesGlob(path, glob)),
|
|
2377
|
+
);
|
|
2378
|
+
if (forbiddenTouched.length > 0) {
|
|
2379
|
+
warnings.push(
|
|
2380
|
+
`Scope suggestion: modified paths matching forbiddenGlobs: ${forbiddenTouched.join(", ")}`,
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
2383
|
+
return { warnings };
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function sanitizeTaskExecutePlanningPathHints(value: unknown): unknown {
|
|
2387
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
2388
|
+
const planning = value as Record<string, unknown>;
|
|
2389
|
+
const out: Record<string, unknown> = { ...planning };
|
|
2390
|
+
|
|
2391
|
+
if (planning.scope && typeof planning.scope === "object" && !Array.isArray(planning.scope)) {
|
|
2392
|
+
const scope = planning.scope as Record<string, unknown>;
|
|
2393
|
+
const normalizedScope: Record<string, unknown> = { ...scope };
|
|
2394
|
+
if (isStringArray(scope.writeGlobs)) {
|
|
2395
|
+
normalizedScope.writeGlobs = toStringArray(scope.writeGlobs);
|
|
2396
|
+
}
|
|
2397
|
+
if (isStringArray(scope.forbiddenGlobs)) {
|
|
2398
|
+
normalizedScope.forbiddenGlobs = toStringArray(scope.forbiddenGlobs);
|
|
2399
|
+
}
|
|
2400
|
+
out.scope = normalizedScope;
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
if (
|
|
2404
|
+
planning.discovery &&
|
|
2405
|
+
typeof planning.discovery === "object" &&
|
|
2406
|
+
!Array.isArray(planning.discovery)
|
|
2407
|
+
) {
|
|
2408
|
+
const discovery = planning.discovery as Record<string, unknown>;
|
|
2409
|
+
const normalizedDiscovery: Record<string, unknown> = { ...discovery };
|
|
2410
|
+
if (isStringArray(discovery.likelyDirs)) {
|
|
2411
|
+
normalizedDiscovery.likelyDirs = toStringArray(discovery.likelyDirs);
|
|
2412
|
+
}
|
|
2413
|
+
out.discovery = normalizedDiscovery;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
return out;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
function validateTaskExecutePlanning(
|
|
2420
|
+
value: unknown,
|
|
2421
|
+
options?: {
|
|
2422
|
+
origin?: "autonomy" | "user";
|
|
2423
|
+
autonomyComponentArea?: unknown;
|
|
2424
|
+
},
|
|
2425
|
+
): { ok: true } | { ok: false; message: string } {
|
|
2426
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2427
|
+
return { ok: false, message: "task.execute requires params.planning object" };
|
|
2428
|
+
}
|
|
2429
|
+
const planning = value as Record<string, unknown>;
|
|
2430
|
+
const origin = options?.origin === "autonomy" ? "autonomy" : "user";
|
|
2431
|
+
|
|
2432
|
+
const intent = String(planning.intent ?? "");
|
|
2433
|
+
const riskLevel = String(planning.riskLevel ?? "");
|
|
2434
|
+
const queuePriority = String(planning.queuePriority ?? "");
|
|
2435
|
+
const queueWaitBudgetMs = Number(planning.queueWaitBudgetMs);
|
|
2436
|
+
const executionBudgetMs = Number(planning.executionBudgetMs);
|
|
2437
|
+
const finalizationBudgetMs = Number(planning.finalizationBudgetMs);
|
|
2438
|
+
|
|
2439
|
+
const validIntents: TaskExecuteIntent[] = ["chat", "status", "code_change", "analysis", "other"];
|
|
2440
|
+
const validRisks: TaskExecuteRisk[] = ["low", "medium", "high"];
|
|
2441
|
+
const validPriorities: TaskExecutePriority[] = ["interactive", "normal", "background"];
|
|
2442
|
+
|
|
2443
|
+
if (!validIntents.includes(intent as TaskExecuteIntent)) {
|
|
2444
|
+
return { ok: false, message: "task.execute planning.intent is invalid" };
|
|
2445
|
+
}
|
|
2446
|
+
if (!validRisks.includes(riskLevel as TaskExecuteRisk)) {
|
|
2447
|
+
return { ok: false, message: "task.execute planning.riskLevel is invalid" };
|
|
2448
|
+
}
|
|
2449
|
+
if (!validPriorities.includes(queuePriority as TaskExecutePriority)) {
|
|
2450
|
+
return { ok: false, message: "task.execute planning.queuePriority is invalid" };
|
|
2451
|
+
}
|
|
2452
|
+
if (!planning.scope || typeof planning.scope !== "object" || Array.isArray(planning.scope)) {
|
|
2453
|
+
return { ok: false, message: "task.execute planning.scope must be an object" };
|
|
2454
|
+
}
|
|
2455
|
+
const scope = planning.scope as Record<string, unknown>;
|
|
2456
|
+
if (typeof scope.readAnywhere !== "boolean") {
|
|
2457
|
+
return { ok: false, message: "task.execute planning.scope.readAnywhere must be boolean" };
|
|
2458
|
+
}
|
|
2459
|
+
if (typeof scope.writeAllowed !== "boolean") {
|
|
2460
|
+
return { ok: false, message: "task.execute planning.scope.writeAllowed must be boolean" };
|
|
2461
|
+
}
|
|
2462
|
+
if (scope.writeGlobs !== undefined && !isStringArray(scope.writeGlobs)) {
|
|
2463
|
+
return { ok: false, message: "task.execute planning.scope.writeGlobs must be a string array" };
|
|
2464
|
+
}
|
|
2465
|
+
if (isStringArray(scope.writeGlobs) && hasInvalidRepoPathHint(scope.writeGlobs)) {
|
|
2466
|
+
return {
|
|
2467
|
+
ok: false,
|
|
2468
|
+
message: "task.execute planning.scope.writeGlobs must contain repo-relative path hints only",
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
if (scope.forbiddenGlobs !== undefined && !isStringArray(scope.forbiddenGlobs)) {
|
|
2472
|
+
return {
|
|
2473
|
+
ok: false,
|
|
2474
|
+
message: "task.execute planning.scope.forbiddenGlobs must be a string array",
|
|
2475
|
+
};
|
|
2476
|
+
}
|
|
2477
|
+
if (isStringArray(scope.forbiddenGlobs) && hasInvalidRepoPathHint(scope.forbiddenGlobs)) {
|
|
2478
|
+
return {
|
|
2479
|
+
ok: false,
|
|
2480
|
+
message:
|
|
2481
|
+
"task.execute planning.scope.forbiddenGlobs must contain repo-relative path hints only",
|
|
2482
|
+
};
|
|
2483
|
+
}
|
|
2484
|
+
if (
|
|
2485
|
+
scope.maxFilesToEdit !== undefined &&
|
|
2486
|
+
(!Number.isFinite(Number(scope.maxFilesToEdit)) || Number(scope.maxFilesToEdit) <= 0)
|
|
2487
|
+
) {
|
|
2488
|
+
return { ok: false, message: "task.execute planning.scope.maxFilesToEdit must be > 0" };
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
if (planning.targetPaths !== undefined && !isStringArray(planning.targetPaths)) {
|
|
2492
|
+
return { ok: false, message: "task.execute planning.targetPaths must be a string array" };
|
|
2493
|
+
}
|
|
2494
|
+
if (isStringArray(planning.targetPaths)) {
|
|
2495
|
+
const normalizedTargetPaths = planning.targetPaths
|
|
2496
|
+
.map((entry) => normalizeTargetPath(entry))
|
|
2497
|
+
.filter((entry): entry is string => Boolean(entry));
|
|
2498
|
+
if (normalizedTargetPaths.length !== planning.targetPaths.length) {
|
|
2499
|
+
return {
|
|
2500
|
+
ok: false,
|
|
2501
|
+
message: "task.execute planning.targetPaths must contain literal repo-relative paths",
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
const normalizedWriteGlobs = isStringArray(scope.writeGlobs)
|
|
2505
|
+
? toStringArray(scope.writeGlobs)
|
|
2506
|
+
: [];
|
|
2507
|
+
if (origin === "autonomy") {
|
|
2508
|
+
const declaredComponentArea = asAutonomyComponentArea(options?.autonomyComponentArea);
|
|
2509
|
+
const inferredComponentArea = deriveAutonomyComponentArea(
|
|
2510
|
+
normalizedTargetPaths,
|
|
2511
|
+
normalizedWriteGlobs,
|
|
2512
|
+
);
|
|
2513
|
+
const componentArea = declaredComponentArea ?? inferredComponentArea;
|
|
2514
|
+
if (!componentArea) {
|
|
2515
|
+
return {
|
|
2516
|
+
ok: false,
|
|
2517
|
+
message: "task.execute planning.targetPaths must resolve to a repo-relative componentArea",
|
|
2518
|
+
};
|
|
2519
|
+
}
|
|
2520
|
+
if (
|
|
2521
|
+
declaredComponentArea &&
|
|
2522
|
+
inferredComponentArea &&
|
|
2523
|
+
declaredComponentArea !== inferredComponentArea
|
|
2524
|
+
) {
|
|
2525
|
+
return {
|
|
2526
|
+
ok: false,
|
|
2527
|
+
message: "task.execute planning.targetPaths do not match autonomy componentArea",
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
const validatedScope = validateScopeInvariants(
|
|
2531
|
+
componentArea,
|
|
2532
|
+
normalizedTargetPaths,
|
|
2533
|
+
normalizedWriteGlobs,
|
|
2534
|
+
{ requireWriteGlobs: false },
|
|
2535
|
+
);
|
|
2536
|
+
if (!validatedScope.ok) {
|
|
2537
|
+
return {
|
|
2538
|
+
ok: false,
|
|
2539
|
+
message: `task.execute scope invariants failed: ${validatedScope.errors.join("; ")}`,
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
} else if (normalizedWriteGlobs.length > 0) {
|
|
2543
|
+
const uncoveredPaths = normalizedTargetPaths.filter(
|
|
2544
|
+
(targetPath) => !normalizedWriteGlobs.some((glob) => matchesGlob(targetPath, glob)),
|
|
2545
|
+
);
|
|
2546
|
+
if (uncoveredPaths.length > 0) {
|
|
2547
|
+
return {
|
|
2548
|
+
ok: false,
|
|
2549
|
+
message: `task.execute planning.targetPaths must be covered by planning.scope.writeGlobs: ${uncoveredPaths.join(", ")}`,
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
if (planning.discovery !== undefined) {
|
|
2556
|
+
if (
|
|
2557
|
+
!planning.discovery ||
|
|
2558
|
+
typeof planning.discovery !== "object" ||
|
|
2559
|
+
Array.isArray(planning.discovery)
|
|
2560
|
+
) {
|
|
2561
|
+
return { ok: false, message: "task.execute planning.discovery must be an object" };
|
|
2562
|
+
}
|
|
2563
|
+
const discovery = planning.discovery as Record<string, unknown>;
|
|
2564
|
+
if (!isStringArray(discovery.ripgrepQueries)) {
|
|
2565
|
+
return {
|
|
2566
|
+
ok: false,
|
|
2567
|
+
message: "task.execute planning.discovery.ripgrepQueries must be a string array",
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
if (discovery.likelyDirs !== undefined && !isStringArray(discovery.likelyDirs)) {
|
|
2571
|
+
return {
|
|
2572
|
+
ok: false,
|
|
2573
|
+
message: "task.execute planning.discovery.likelyDirs must be a string array",
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
if (isStringArray(discovery.likelyDirs) && hasInvalidRepoPathHint(discovery.likelyDirs)) {
|
|
2577
|
+
return {
|
|
2578
|
+
ok: false,
|
|
2579
|
+
message: "task.execute planning.discovery.likelyDirs must be repo-relative path hints",
|
|
2580
|
+
};
|
|
2581
|
+
}
|
|
2582
|
+
if (discovery.keywords !== undefined && !isStringArray(discovery.keywords)) {
|
|
2583
|
+
return {
|
|
2584
|
+
ok: false,
|
|
2585
|
+
message: "task.execute planning.discovery.keywords must be a string array",
|
|
2586
|
+
};
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
if (!isStringArray(planning.acceptanceCriteria)) {
|
|
2591
|
+
return {
|
|
2592
|
+
ok: false,
|
|
2593
|
+
message: "task.execute planning.acceptanceCriteria must be a string array",
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
if (!isStringArray(planning.validationSteps)) {
|
|
2597
|
+
return { ok: false, message: "task.execute planning.validationSteps must be a string array" };
|
|
2598
|
+
}
|
|
2599
|
+
if ((planning.acceptanceCriteria as string[]).length === 0) {
|
|
2600
|
+
return {
|
|
2601
|
+
ok: false,
|
|
2602
|
+
message:
|
|
2603
|
+
"task.execute planning.acceptanceCriteria must include at least one acceptance criterion",
|
|
2604
|
+
};
|
|
2605
|
+
}
|
|
2606
|
+
if ((planning.validationSteps as string[]).length === 0) {
|
|
2607
|
+
return {
|
|
2608
|
+
ok: false,
|
|
2609
|
+
message: "task.execute planning.validationSteps must include at least one validation step",
|
|
2610
|
+
};
|
|
2611
|
+
}
|
|
2612
|
+
if (!Number.isFinite(queueWaitBudgetMs) || queueWaitBudgetMs <= 0) {
|
|
2613
|
+
return { ok: false, message: "task.execute planning.queueWaitBudgetMs must be > 0" };
|
|
2614
|
+
}
|
|
2615
|
+
if (!Number.isFinite(executionBudgetMs) || executionBudgetMs <= 0) {
|
|
2616
|
+
return { ok: false, message: "task.execute planning.executionBudgetMs must be > 0" };
|
|
2617
|
+
}
|
|
2618
|
+
if (!Number.isFinite(finalizationBudgetMs) || finalizationBudgetMs <= 0) {
|
|
2619
|
+
return { ok: false, message: "task.execute planning.finalizationBudgetMs must be > 0" };
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
return { ok: true };
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
// ─── Codex-based critic (used when executor === "openai_codex") ───────────────
|
|
2626
|
+
|
|
2627
|
+
const cachedCodexCommandPrefix = new Map<string, string[]>();
|
|
2628
|
+
|
|
2629
|
+
async function canExecuteCodexCommandCandidate(
|
|
2630
|
+
repo: string,
|
|
2631
|
+
candidate: string[],
|
|
2632
|
+
): Promise<boolean> {
|
|
2633
|
+
if (candidate.length === 0) return false;
|
|
2634
|
+
try {
|
|
2635
|
+
const proc = Bun.spawn([...candidate, "--version"], {
|
|
2636
|
+
cwd: repo,
|
|
2637
|
+
stdout: "pipe",
|
|
2638
|
+
stderr: "pipe",
|
|
2639
|
+
});
|
|
2640
|
+
let timedOut = false;
|
|
2641
|
+
const timer = setTimeout(() => {
|
|
2642
|
+
timedOut = true;
|
|
2643
|
+
try {
|
|
2644
|
+
proc.kill();
|
|
2645
|
+
} catch {
|
|
2646
|
+
// ignore
|
|
2647
|
+
}
|
|
2648
|
+
}, 15_000);
|
|
2649
|
+
const exitCode = await proc.exited;
|
|
2650
|
+
clearTimeout(timer);
|
|
2651
|
+
return !timedOut && exitCode === 0;
|
|
2652
|
+
} catch {
|
|
2653
|
+
return false;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
async function resolveCodexCommandPrefix(
|
|
2658
|
+
repo: string,
|
|
2659
|
+
configuredCommand = "",
|
|
2660
|
+
): Promise<string[] | null> {
|
|
2661
|
+
const cacheKey = `${repo}\u0000${configuredCommand.trim()}`;
|
|
2662
|
+
const cached = cachedCodexCommandPrefix.get(cacheKey);
|
|
2663
|
+
if (cached) return [...cached];
|
|
2664
|
+
|
|
2665
|
+
const candidates: string[][] = [];
|
|
2666
|
+
const configured = splitArgs(configuredCommand);
|
|
2667
|
+
if (configured.length > 0) candidates.push(configured);
|
|
2668
|
+
candidates.push(["bun", "x", "--yes", "@openai/codex"]);
|
|
2669
|
+
candidates.push(["bunx", "--yes", "@openai/codex"]);
|
|
2670
|
+
candidates.push(["codex"]);
|
|
2671
|
+
|
|
2672
|
+
for (const candidate of candidates) {
|
|
2673
|
+
if (await canExecuteCodexCommandCandidate(repo, candidate)) {
|
|
2674
|
+
cachedCodexCommandPrefix.set(cacheKey, [...candidate]);
|
|
2675
|
+
return candidate;
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
return null;
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
async function runCodexCriticReview(
|
|
2682
|
+
repo: string,
|
|
2683
|
+
params: Record<string, unknown>,
|
|
2684
|
+
quality: DeterministicQualityResult,
|
|
2685
|
+
runtimeConfig: WorkerpalsRuntimeConfig,
|
|
2686
|
+
onLog?: (stream: "stdout" | "stderr", line: string) => void,
|
|
2687
|
+
): Promise<CriticReview | null> {
|
|
2688
|
+
const codexPrefix = await resolveCodexCommandPrefix(repo, runtimeConfig.workerpals.llm.codexBin);
|
|
2689
|
+
if (!codexPrefix) {
|
|
2690
|
+
onLog?.(
|
|
2691
|
+
"stderr",
|
|
2692
|
+
"[QualityGate] Codex critic: unable to resolve Codex CLI command (workerpals.llm.codex_bin/PATH); skipping.",
|
|
2693
|
+
);
|
|
2694
|
+
return null;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
const instruction = String(params.instruction ?? "").trim();
|
|
2698
|
+
const planning = params.planning as TaskExecutePlanning;
|
|
2699
|
+
|
|
2700
|
+
const changedForDiff = quality.changedPaths.slice(0, 8);
|
|
2701
|
+
let diffText = "";
|
|
2702
|
+
const qualityCriticMaxDiffChars = (() => {
|
|
2703
|
+
const value = Number(runtimeConfig.workerpals.qualityCriticMaxDiffChars);
|
|
2704
|
+
if (!Number.isFinite(value)) return 16_000;
|
|
2705
|
+
return Math.max(256, Math.min(524_288, Math.floor(value)));
|
|
2706
|
+
})();
|
|
2707
|
+
const qualityCriticMaxValidationOutputChars = (() => {
|
|
2708
|
+
const value = Number(runtimeConfig.workerpals.qualityCriticMaxValidationOutputChars);
|
|
2709
|
+
if (!Number.isFinite(value)) return 8_000;
|
|
2710
|
+
return Math.max(256, Math.min(524_288, Math.floor(value)));
|
|
2711
|
+
})();
|
|
2712
|
+
const qualityCriticTimeoutMs = (() => {
|
|
2713
|
+
const value = Number(runtimeConfig.workerpals.qualityCriticTimeoutMs);
|
|
2714
|
+
if (!Number.isFinite(value)) return 45_000;
|
|
2715
|
+
return Math.max(1_000, Math.min(7_200_000, Math.floor(value)));
|
|
2716
|
+
})();
|
|
2717
|
+
if (changedForDiff.length > 0) {
|
|
2718
|
+
const diffResult = await git(repo, ["diff", "--", ...changedForDiff]);
|
|
2719
|
+
diffText = (diffResult.ok ? diffResult.stdout : diffResult.stderr).slice(
|
|
2720
|
+
0,
|
|
2721
|
+
qualityCriticMaxDiffChars,
|
|
2722
|
+
);
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
const validationSummary = quality.validationRuns
|
|
2726
|
+
.map((run) => {
|
|
2727
|
+
const output = [run.stdout, run.stderr]
|
|
2728
|
+
.filter(Boolean)
|
|
2729
|
+
.join("\n")
|
|
2730
|
+
.slice(0, qualityCriticMaxValidationOutputChars);
|
|
2731
|
+
return [
|
|
2732
|
+
`Command: ${run.command}`,
|
|
2733
|
+
`Result: ${run.ok ? "pass" : "fail"} (exit ${run.exitCode})`,
|
|
2734
|
+
output,
|
|
2735
|
+
]
|
|
2736
|
+
.filter(Boolean)
|
|
2737
|
+
.join("\n");
|
|
2738
|
+
})
|
|
2739
|
+
.join("\n---\n");
|
|
2740
|
+
|
|
2741
|
+
const criticInstruction = loadPromptTemplate(
|
|
2742
|
+
"workerpals/codex_quality_critic_instruction_prompt.md",
|
|
2743
|
+
{
|
|
2744
|
+
instruction,
|
|
2745
|
+
acceptance_criteria:
|
|
2746
|
+
planning.acceptanceCriteria.map((c) => `- ${c}`).join("\n") || "- (none)",
|
|
2747
|
+
changed_paths: quality.changedPaths.join(", ") || "(none)",
|
|
2748
|
+
diff_section: diffText ? `Diff:\n${diffText}` : "Diff: (empty - no changes detected)",
|
|
2749
|
+
validation_section: validationSummary
|
|
2750
|
+
? `Validation:\n${validationSummary}`
|
|
2751
|
+
: "Validation: (none)",
|
|
2752
|
+
},
|
|
2753
|
+
);
|
|
2754
|
+
|
|
2755
|
+
const tmpOutputPath = `/tmp/pushpals-critic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`;
|
|
2756
|
+
const cmd = [
|
|
2757
|
+
...codexPrefix,
|
|
2758
|
+
"-c",
|
|
2759
|
+
'model_reasoning_effort="low"',
|
|
2760
|
+
"-a",
|
|
2761
|
+
"never",
|
|
2762
|
+
"exec",
|
|
2763
|
+
"-s",
|
|
2764
|
+
"read-only",
|
|
2765
|
+
"--output-last-message",
|
|
2766
|
+
tmpOutputPath,
|
|
2767
|
+
"-",
|
|
2768
|
+
];
|
|
2769
|
+
|
|
2770
|
+
try {
|
|
2771
|
+
const proc = Bun.spawn(cmd, {
|
|
2772
|
+
cwd: repo,
|
|
2773
|
+
stdout: "pipe",
|
|
2774
|
+
stderr: "pipe",
|
|
2775
|
+
stdin: new Blob([criticInstruction]),
|
|
2776
|
+
});
|
|
2777
|
+
|
|
2778
|
+
let timedOut = false;
|
|
2779
|
+
const timer = setTimeout(() => {
|
|
2780
|
+
timedOut = true;
|
|
2781
|
+
try {
|
|
2782
|
+
proc.kill();
|
|
2783
|
+
} catch {
|
|
2784
|
+
/* ignore */
|
|
2785
|
+
}
|
|
2786
|
+
}, qualityCriticTimeoutMs);
|
|
2787
|
+
|
|
2788
|
+
const exitCode = await proc.exited;
|
|
2789
|
+
clearTimeout(timer);
|
|
2790
|
+
|
|
2791
|
+
if (timedOut) {
|
|
2792
|
+
onLog?.("stderr", "[QualityGate] Codex critic timed out; skipping.");
|
|
2793
|
+
return null;
|
|
2794
|
+
}
|
|
2795
|
+
if (exitCode !== 0) {
|
|
2796
|
+
const stderrText = await new Response(proc.stderr).text();
|
|
2797
|
+
onLog?.(
|
|
2798
|
+
"stderr",
|
|
2799
|
+
`[QualityGate] Codex critic exited ${exitCode}: ${toSingleLine(stderrText, 220)}`,
|
|
2800
|
+
);
|
|
2801
|
+
return null;
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
let lastMessage = "";
|
|
2805
|
+
try {
|
|
2806
|
+
lastMessage = (await Bun.file(tmpOutputPath).text()).trim();
|
|
2807
|
+
} catch {
|
|
2808
|
+
/* file may not exist if codex produced no output */
|
|
2809
|
+
}
|
|
2810
|
+
try {
|
|
2811
|
+
unlinkSync(tmpOutputPath);
|
|
2812
|
+
} catch {
|
|
2813
|
+
/* ignore */
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
if (!lastMessage) {
|
|
2817
|
+
onLog?.("stderr", "[QualityGate] Codex critic: no output message captured; skipping.");
|
|
2818
|
+
return null;
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
const reviewObj = parseJsonObjectLoose(lastMessage);
|
|
2822
|
+
if (!reviewObj) {
|
|
2823
|
+
onLog?.(
|
|
2824
|
+
"stderr",
|
|
2825
|
+
`[QualityGate] Codex critic returned non-JSON: ${toSingleLine(lastMessage, 220)}`,
|
|
2826
|
+
);
|
|
2827
|
+
return null;
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
const scoreRaw = Number(reviewObj.score);
|
|
2831
|
+
const score = Number.isFinite(scoreRaw) ? Math.max(0, Math.min(10, scoreRaw)) : 0;
|
|
2832
|
+
const findings = Array.isArray(reviewObj.findings)
|
|
2833
|
+
? reviewObj.findings.map((f) => String(f).trim()).filter(Boolean)
|
|
2834
|
+
: [];
|
|
2835
|
+
const mustFix = Array.isArray(reviewObj.must_fix)
|
|
2836
|
+
? reviewObj.must_fix.map((f) => String(f).trim()).filter(Boolean)
|
|
2837
|
+
: [];
|
|
2838
|
+
const revisionGuidance = String(reviewObj.revision_guidance ?? "")
|
|
2839
|
+
.trim()
|
|
2840
|
+
.slice(0, 2000);
|
|
2841
|
+
onLog?.("stdout", `[QualityGate] Codex critic score: ${score}/10`);
|
|
2842
|
+
return {
|
|
2843
|
+
score,
|
|
2844
|
+
findings,
|
|
2845
|
+
mustFix,
|
|
2846
|
+
revisionGuidance,
|
|
2847
|
+
raw: compactJobOutput(lastMessage, outputPolicyForRuntime(runtimeConfig)),
|
|
2848
|
+
};
|
|
2849
|
+
} catch (err) {
|
|
2850
|
+
onLog?.("stderr", `[QualityGate] Codex critic error: ${toSingleLine(err, 220)} (skipping).`);
|
|
2851
|
+
return null;
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
export async function executeJob(
|
|
2856
|
+
kind: string,
|
|
2857
|
+
params: Record<string, unknown>,
|
|
2858
|
+
repo: string,
|
|
2859
|
+
onLog?: (stream: "stdout" | "stderr", line: string) => void,
|
|
2860
|
+
runtimeConfig: WorkerpalsRuntimeConfig = DEFAULT_CONFIG,
|
|
2861
|
+
): Promise<JobResult> {
|
|
2862
|
+
if (!SUPPORTED_JOB_KINDS.has(kind)) {
|
|
2863
|
+
return {
|
|
2864
|
+
ok: false,
|
|
2865
|
+
summary: `Unsupported job kind "${kind}". WorkerPals accepts only ${[...SUPPORTED_JOB_KINDS].join(" or ")}.`,
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
if (kind === "warmup.execute") {
|
|
2870
|
+
return {
|
|
2871
|
+
ok: true,
|
|
2872
|
+
summary: "Startup warmup completed (no-op, no commit).",
|
|
2873
|
+
stdout: "warmup.execute completed",
|
|
2874
|
+
exitCode: 0,
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
const schemaVersion = Number(params.schemaVersion);
|
|
2879
|
+
if (!Number.isFinite(schemaVersion) || Math.floor(schemaVersion) !== 2) {
|
|
2880
|
+
return {
|
|
2881
|
+
ok: false,
|
|
2882
|
+
summary: "task.execute requires params.schemaVersion=2",
|
|
2883
|
+
exitCode: 2,
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
const origin = taskExecuteOrigin(params);
|
|
2888
|
+
const autonomyScope =
|
|
2889
|
+
params.autonomy && typeof params.autonomy === "object" && !Array.isArray(params.autonomy)
|
|
2890
|
+
? (params.autonomy as Record<string, unknown>)
|
|
2891
|
+
: null;
|
|
2892
|
+
const planningValidation = validateTaskExecutePlanning(params.planning, {
|
|
2893
|
+
origin,
|
|
2894
|
+
autonomyComponentArea: autonomyScope?.componentArea ?? autonomyScope?.component_area,
|
|
2895
|
+
});
|
|
2896
|
+
if (!planningValidation.ok) {
|
|
2897
|
+
return {
|
|
2898
|
+
ok: false,
|
|
2899
|
+
summary: planningValidation.message,
|
|
2900
|
+
exitCode: 2,
|
|
2901
|
+
};
|
|
2902
|
+
}
|
|
2903
|
+
const sanitizedPlanning = sanitizeTaskExecutePlanningPathHints(params.planning);
|
|
2904
|
+
const planning = sanitizedPlanning as TaskExecutePlanning;
|
|
2905
|
+
if (origin === "autonomy" && toStringArray(planning.scope.writeGlobs ?? []).length === 0) {
|
|
2906
|
+
onLog?.(
|
|
2907
|
+
"stdout",
|
|
2908
|
+
"[TaskExecute] Scope suggestion: planning.scope.writeGlobs is empty for autonomy-origin task.",
|
|
2909
|
+
);
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
const instruction = String(params.instruction ?? "").trim();
|
|
2913
|
+
if (!instruction) {
|
|
2914
|
+
return {
|
|
2915
|
+
ok: false,
|
|
2916
|
+
summary: "task.execute requires an 'instruction' param",
|
|
2917
|
+
};
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
const normalizedParams: Record<string, unknown> = {
|
|
2921
|
+
...params,
|
|
2922
|
+
planning: sanitizedPlanning,
|
|
2923
|
+
instruction,
|
|
2924
|
+
};
|
|
2925
|
+
const executionBudgetMs = Number(planning.executionBudgetMs);
|
|
2926
|
+
const finalizationBudgetMs = Number(planning.finalizationBudgetMs);
|
|
2927
|
+
const qualityMaxAutoRevisions = Math.max(
|
|
2928
|
+
0,
|
|
2929
|
+
Math.min(
|
|
2930
|
+
10,
|
|
2931
|
+
Number.isFinite(Number(runtimeConfig.workerpals.qualityMaxAutoRevisions))
|
|
2932
|
+
? Math.floor(Number(runtimeConfig.workerpals.qualityMaxAutoRevisions))
|
|
2933
|
+
: 4,
|
|
2934
|
+
),
|
|
2935
|
+
);
|
|
2936
|
+
const qualitySoftPassOnExhausted =
|
|
2937
|
+
typeof runtimeConfig.workerpals.qualitySoftPassOnExhausted === "boolean"
|
|
2938
|
+
? runtimeConfig.workerpals.qualitySoftPassOnExhausted
|
|
2939
|
+
: true;
|
|
2940
|
+
const qualityCriticMinScore = (() => {
|
|
2941
|
+
const value = Number(runtimeConfig.workerpals.qualityCriticMinScore);
|
|
2942
|
+
if (!Number.isFinite(value)) return 8;
|
|
2943
|
+
return Math.max(0, Math.min(10, value));
|
|
2944
|
+
})();
|
|
2945
|
+
|
|
2946
|
+
onLog?.(
|
|
2947
|
+
"stdout",
|
|
2948
|
+
`[QualityGate] Policy: max_auto_revisions=${qualityMaxAutoRevisions}, soft_pass_on_exhausted=${qualitySoftPassOnExhausted ? "true" : "false"}, critic_min_score=${qualityCriticMinScore}`,
|
|
2949
|
+
);
|
|
2950
|
+
|
|
2951
|
+
let revisionAttempt = 0;
|
|
2952
|
+
let revisionHint = "";
|
|
2953
|
+
while (revisionAttempt <= qualityMaxAutoRevisions) {
|
|
2954
|
+
const attemptParams: Record<string, unknown> = { ...normalizedParams };
|
|
2955
|
+
if (revisionHint) {
|
|
2956
|
+
attemptParams.qualityRevisionHint = revisionHint;
|
|
2957
|
+
attemptParams.qualityRevisionAttempt = revisionAttempt;
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
const executor = resolveExecutor(runtimeConfig);
|
|
2961
|
+
const executeBudgets = { executionBudgetMs, finalizationBudgetMs };
|
|
2962
|
+
const runExecutor = getBackendTaskExecutor(executor);
|
|
2963
|
+
if (!runExecutor) {
|
|
2964
|
+
return {
|
|
2965
|
+
ok: false,
|
|
2966
|
+
summary: `No task executor registered for backend "${executor}"`,
|
|
2967
|
+
exitCode: 1,
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
const result = await runExecutor(
|
|
2971
|
+
kind,
|
|
2972
|
+
attemptParams,
|
|
2973
|
+
repo,
|
|
2974
|
+
runtimeConfig,
|
|
2975
|
+
onLog,
|
|
2976
|
+
executeBudgets,
|
|
2977
|
+
);
|
|
2978
|
+
if (!result.ok) return result;
|
|
2979
|
+
|
|
2980
|
+
const scopeCheck = await collectWriteScopeWarnings(repo, planning);
|
|
2981
|
+
for (const warning of scopeCheck.warnings) {
|
|
2982
|
+
onLog?.("stdout", `[TaskExecute] ${warning}`);
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
const quality = await runDeterministicQualityGate(repo, attemptParams, runtimeConfig, onLog);
|
|
2986
|
+
const critic = quality.skipped
|
|
2987
|
+
? null
|
|
2988
|
+
: executor === "openai_codex"
|
|
2989
|
+
? await runCodexCriticReview(repo, attemptParams, quality, runtimeConfig, onLog)
|
|
2990
|
+
: await runTaskCriticReview(repo, attemptParams, quality, runtimeConfig, onLog);
|
|
2991
|
+
const criticRequiresRevision = Boolean(critic && critic.score < qualityCriticMinScore);
|
|
2992
|
+
|
|
2993
|
+
if (!criticRequiresRevision) {
|
|
2994
|
+
if (critic) {
|
|
2995
|
+
onLog?.(
|
|
2996
|
+
"stdout",
|
|
2997
|
+
`[QualityGate] Critic review score ${critic.score.toFixed(1)}/10 (threshold ${qualityCriticMinScore}).`,
|
|
2998
|
+
);
|
|
2999
|
+
}
|
|
3000
|
+
return result;
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
const issues: string[] = [];
|
|
3004
|
+
if (criticRequiresRevision && critic) {
|
|
3005
|
+
issues.push(...buildCriticRevisionIssues(critic, qualityCriticMinScore));
|
|
3006
|
+
}
|
|
3007
|
+
const issueSummary = issues.map((entry) => toSingleLine(entry, 180)).join(" | ");
|
|
3008
|
+
if (revisionAttempt >= qualityMaxAutoRevisions) {
|
|
3009
|
+
if (qualitySoftPassOnExhausted) {
|
|
3010
|
+
const diagnostics = truncate(
|
|
3011
|
+
[result.stderr ?? "", critic ? `Critic raw: ${critic.raw}` : ""]
|
|
3012
|
+
.filter(Boolean)
|
|
3013
|
+
.join("\n"),
|
|
3014
|
+
outputPolicyForRuntime(runtimeConfig),
|
|
3015
|
+
);
|
|
3016
|
+
onLog?.(
|
|
3017
|
+
"stderr",
|
|
3018
|
+
`[QualityGate] Soft-pass after ${revisionAttempt} auto-revision attempt(s): ${toSingleLine(
|
|
3019
|
+
issueSummary,
|
|
3020
|
+
260,
|
|
3021
|
+
)}`,
|
|
3022
|
+
);
|
|
3023
|
+
return {
|
|
3024
|
+
...result,
|
|
3025
|
+
summary: `${result.summary} (quality gate soft-pass after ${revisionAttempt} auto-revision attempt(s))`,
|
|
3026
|
+
stderr: diagnostics,
|
|
3027
|
+
exitCode: typeof result.exitCode === "number" ? result.exitCode : 0,
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
return {
|
|
3031
|
+
ok: false,
|
|
3032
|
+
summary: `Quality gate failed after ${revisionAttempt} auto-revision attempt(s): ${toSingleLine(
|
|
3033
|
+
issueSummary,
|
|
3034
|
+
240,
|
|
3035
|
+
)}`,
|
|
3036
|
+
stdout: result.stdout,
|
|
3037
|
+
stderr: truncate(
|
|
3038
|
+
[result.stderr ?? "", critic ? `Critic raw: ${critic.raw}` : ""]
|
|
3039
|
+
.filter(Boolean)
|
|
3040
|
+
.join("\n"),
|
|
3041
|
+
outputPolicyForRuntime(runtimeConfig),
|
|
3042
|
+
),
|
|
3043
|
+
exitCode: 4,
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
revisionAttempt += 1;
|
|
3048
|
+
revisionHint = buildQualityRevisionHint(issues, critic, planning);
|
|
3049
|
+
onLog?.(
|
|
3050
|
+
"stderr",
|
|
3051
|
+
`[QualityGate] Quality gate requested revision ${revisionAttempt}/${qualityMaxAutoRevisions}: ${toSingleLine(
|
|
3052
|
+
issueSummary,
|
|
3053
|
+
260,
|
|
3054
|
+
)}`,
|
|
3055
|
+
);
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
return {
|
|
3059
|
+
ok: false,
|
|
3060
|
+
summary: "Quality revision loop ended unexpectedly.",
|
|
3061
|
+
exitCode: 4,
|
|
3062
|
+
};
|
|
3063
|
+
}
|