@pushpalsdev/cli 1.0.64 → 1.0.65
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/package.json +1 -1
- package/runtime/sandbox/.pushpals-remotebuddy-fallback.js +36 -2
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/openai_codex_executor.py +184 -1
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/test_openai_codex_runtime_config.py +58 -3
- package/runtime/sandbox/apps/workerpals/src/execute_job.ts +25 -7
- package/runtime/sandbox/packages/shared/src/autonomy_policy.ts +34 -3
package/package.json
CHANGED
|
@@ -335,6 +335,8 @@ function commonRepoAncestor(paths) {
|
|
|
335
335
|
const normalized = paths.map((entry) => normalizeRepoRelativePath(entry)).filter((entry) => Boolean(entry));
|
|
336
336
|
if (normalized.length === 0)
|
|
337
337
|
return null;
|
|
338
|
+
if (normalized.length === 1)
|
|
339
|
+
return normalized[0] ?? null;
|
|
338
340
|
const segments = normalized.map((entry) => entry.split("/"));
|
|
339
341
|
const shared = [];
|
|
340
342
|
const first = segments[0] ?? [];
|
|
@@ -349,7 +351,7 @@ function commonRepoAncestor(paths) {
|
|
|
349
351
|
break;
|
|
350
352
|
}
|
|
351
353
|
if (shared.length === 0)
|
|
352
|
-
return
|
|
354
|
+
return null;
|
|
353
355
|
return shared.join("/");
|
|
354
356
|
}
|
|
355
357
|
function normalizeAutonomyComponentArea(value) {
|
|
@@ -368,6 +370,33 @@ function deriveAutonomyComponentArea(targetPathsInput, writeGlobsInput) {
|
|
|
368
370
|
return null;
|
|
369
371
|
return commonRepoAncestor(targetSeeds);
|
|
370
372
|
}
|
|
373
|
+
function collectScopeSeedPaths(targetPathsInput, writeGlobsInput) {
|
|
374
|
+
const seeds = new Set;
|
|
375
|
+
if (Array.isArray(writeGlobsInput)) {
|
|
376
|
+
for (const raw of writeGlobsInput) {
|
|
377
|
+
const normalized = normalizeWriteGlob(raw);
|
|
378
|
+
if (!normalized)
|
|
379
|
+
continue;
|
|
380
|
+
const prefix = literalPrefix(normalized);
|
|
381
|
+
if (!prefix)
|
|
382
|
+
continue;
|
|
383
|
+
const seed = scopeSeedPath(prefix);
|
|
384
|
+
if (seed)
|
|
385
|
+
seeds.add(seed);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (Array.isArray(targetPathsInput)) {
|
|
389
|
+
for (const raw of targetPathsInput) {
|
|
390
|
+
const normalized = normalizeTargetPath(raw);
|
|
391
|
+
if (!normalized)
|
|
392
|
+
continue;
|
|
393
|
+
const seed = scopeSeedPath(normalized);
|
|
394
|
+
if (seed)
|
|
395
|
+
seeds.add(seed);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return [...seeds];
|
|
399
|
+
}
|
|
371
400
|
function componentRootPrefix(area) {
|
|
372
401
|
const normalized = normalizeAutonomyComponentArea(area);
|
|
373
402
|
if (!normalized)
|
|
@@ -556,7 +585,12 @@ function hasForbiddenBroadGlob(glob) {
|
|
|
556
585
|
}
|
|
557
586
|
function validateScopeInvariants(componentArea, targetPathsInput, writeGlobsInput, options) {
|
|
558
587
|
const errors = [];
|
|
588
|
+
const scopeSeeds = collectScopeSeedPaths(targetPathsInput, writeGlobsInput);
|
|
559
589
|
const normalizedComponentArea = normalizeAutonomyComponentArea(componentArea) ?? deriveAutonomyComponentArea(targetPathsInput, writeGlobsInput);
|
|
590
|
+
const allowMultipleComponentRoots = options?.allowMultipleComponentRoots === true;
|
|
591
|
+
if (!normalizedComponentArea && scopeSeeds.length > 1 && !allowMultipleComponentRoots) {
|
|
592
|
+
errors.push(`scope spans multiple component roots: ${scopeSeeds.slice(0, 6).join(", ")}`);
|
|
593
|
+
}
|
|
560
594
|
const rootPrefix = normalizedComponentArea ? componentRootPrefix(normalizedComponentArea) : "";
|
|
561
595
|
const normalizedTargetPaths = [];
|
|
562
596
|
const targetSeen = new Set;
|
|
@@ -620,7 +654,7 @@ function validateScopeInvariants(componentArea, targetPathsInput, writeGlobsInpu
|
|
|
620
654
|
errors.push(`target_path not covered by write_globs: ${targetPath}`);
|
|
621
655
|
}
|
|
622
656
|
}
|
|
623
|
-
if (!normalizedComponentArea) {
|
|
657
|
+
if (!normalizedComponentArea && !allowMultipleComponentRoots) {
|
|
624
658
|
errors.push("component_area could not be derived from scope");
|
|
625
659
|
}
|
|
626
660
|
const breadth = classifyGlobBreadth(normalizedWriteGlobs);
|
|
@@ -96,6 +96,8 @@ _VALID_COLORS = {"always", "never", "auto"}
|
|
|
96
96
|
_VALID_AUTH_MODES = {"auto", "api_key", "chatgpt"}
|
|
97
97
|
_VALID_REASONING_EFFORTS = {"low", "medium", "high", "xhigh"}
|
|
98
98
|
_MAX_WRAPPER_RECOVERY_ATTEMPTS = 2
|
|
99
|
+
_MAX_WRAPPER_BOOTSTRAP_OUTPUT_CHARS = 1_200
|
|
100
|
+
_MAX_WRAPPER_BOOTSTRAP_TOTAL_CHARS = 5_000
|
|
99
101
|
|
|
100
102
|
|
|
101
103
|
def _model_supports_xhigh_reasoning(model: str) -> bool:
|
|
@@ -1108,6 +1110,177 @@ def _build_wrapper_recovery_guidance(rejected_commands: List[str], *, hard: bool
|
|
|
1108
1110
|
return "\n".join(guidance_lines)
|
|
1109
1111
|
|
|
1110
1112
|
|
|
1113
|
+
def _truncate_wrapper_bootstrap_output(text: str) -> str:
|
|
1114
|
+
value = str(text or "").replace("\r\n", "\n").strip()
|
|
1115
|
+
if len(value) <= _MAX_WRAPPER_BOOTSTRAP_OUTPUT_CHARS:
|
|
1116
|
+
return value
|
|
1117
|
+
return f"{value[:_MAX_WRAPPER_BOOTSTRAP_OUTPUT_CHARS].rstrip()}\n...(truncated)"
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _resolve_repo_scoped_path(repo: str, raw_path: str) -> Optional[Path]:
|
|
1121
|
+
candidate = str(raw_path or "").strip()
|
|
1122
|
+
if not candidate:
|
|
1123
|
+
return None
|
|
1124
|
+
repo_root = Path(repo).resolve()
|
|
1125
|
+
resolved = (repo_root / candidate).resolve()
|
|
1126
|
+
try:
|
|
1127
|
+
common = os.path.commonpath([str(repo_root), str(resolved)])
|
|
1128
|
+
except ValueError:
|
|
1129
|
+
return None
|
|
1130
|
+
if common != str(repo_root):
|
|
1131
|
+
return None
|
|
1132
|
+
return resolved
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _run_wrapper_bootstrap_command(repo: str, command: str) -> str:
|
|
1136
|
+
normalized = _normalize_command_text(command)
|
|
1137
|
+
if not normalized:
|
|
1138
|
+
return ""
|
|
1139
|
+
try:
|
|
1140
|
+
args = shlex.split(normalized, posix=True)
|
|
1141
|
+
except ValueError:
|
|
1142
|
+
return ""
|
|
1143
|
+
if not args:
|
|
1144
|
+
return ""
|
|
1145
|
+
program = str(args[0] or "").strip().lower()
|
|
1146
|
+
if program == "pwd" and len(args) == 1:
|
|
1147
|
+
return repo
|
|
1148
|
+
if program == "ls":
|
|
1149
|
+
target = Path(repo).resolve()
|
|
1150
|
+
if len(args) == 2 and not str(args[1]).startswith("-"):
|
|
1151
|
+
resolved = _resolve_repo_scoped_path(repo, str(args[1]))
|
|
1152
|
+
if not resolved:
|
|
1153
|
+
return ""
|
|
1154
|
+
target = resolved
|
|
1155
|
+
elif len(args) > 1:
|
|
1156
|
+
return ""
|
|
1157
|
+
if not target.exists():
|
|
1158
|
+
return f"{target.name or str(target)} (missing)"
|
|
1159
|
+
if target.is_file():
|
|
1160
|
+
return target.name
|
|
1161
|
+
entries = sorted(child.name for child in target.iterdir())
|
|
1162
|
+
return "\n".join(entries[:120])
|
|
1163
|
+
if program == "git" and len(args) >= 2:
|
|
1164
|
+
safe_git_args: Optional[List[str]] = None
|
|
1165
|
+
if args[1:] == ["branch", "--show-current"]:
|
|
1166
|
+
safe_git_args = ["git", "--no-pager", "branch", "--show-current"]
|
|
1167
|
+
elif args[1:] == ["status", "--porcelain"]:
|
|
1168
|
+
safe_git_args = ["git", "--no-pager", "status", "--porcelain"]
|
|
1169
|
+
elif len(args) >= 3 and args[1] == "diff":
|
|
1170
|
+
diff_args = list(args[2:])
|
|
1171
|
+
sanitized_paths: List[str] = []
|
|
1172
|
+
if diff_args == ["--name-only"]:
|
|
1173
|
+
safe_git_args = [
|
|
1174
|
+
"git",
|
|
1175
|
+
"--no-pager",
|
|
1176
|
+
"diff",
|
|
1177
|
+
"--no-ext-diff",
|
|
1178
|
+
"--no-textconv",
|
|
1179
|
+
"--name-only",
|
|
1180
|
+
]
|
|
1181
|
+
elif len(diff_args) >= 2 and diff_args[0] == "--name-only" and diff_args[1] == "--":
|
|
1182
|
+
for raw_path in diff_args[2:]:
|
|
1183
|
+
resolved = _resolve_repo_scoped_path(repo, str(raw_path))
|
|
1184
|
+
if not resolved:
|
|
1185
|
+
return ""
|
|
1186
|
+
sanitized_paths.append(os.path.relpath(str(resolved), repo))
|
|
1187
|
+
safe_git_args = [
|
|
1188
|
+
"git",
|
|
1189
|
+
"--no-pager",
|
|
1190
|
+
"diff",
|
|
1191
|
+
"--no-ext-diff",
|
|
1192
|
+
"--no-textconv",
|
|
1193
|
+
"--name-only",
|
|
1194
|
+
"--",
|
|
1195
|
+
*sanitized_paths,
|
|
1196
|
+
]
|
|
1197
|
+
elif diff_args and diff_args[0] == "--":
|
|
1198
|
+
for raw_path in diff_args[1:]:
|
|
1199
|
+
resolved = _resolve_repo_scoped_path(repo, str(raw_path))
|
|
1200
|
+
if not resolved:
|
|
1201
|
+
return ""
|
|
1202
|
+
sanitized_paths.append(os.path.relpath(str(resolved), repo))
|
|
1203
|
+
safe_git_args = [
|
|
1204
|
+
"git",
|
|
1205
|
+
"--no-pager",
|
|
1206
|
+
"diff",
|
|
1207
|
+
"--no-ext-diff",
|
|
1208
|
+
"--no-textconv",
|
|
1209
|
+
"--",
|
|
1210
|
+
*sanitized_paths,
|
|
1211
|
+
]
|
|
1212
|
+
if not safe_git_args:
|
|
1213
|
+
return ""
|
|
1214
|
+
proc = subprocess.run(
|
|
1215
|
+
safe_git_args,
|
|
1216
|
+
cwd=repo,
|
|
1217
|
+
capture_output=True,
|
|
1218
|
+
text=True,
|
|
1219
|
+
timeout=15,
|
|
1220
|
+
check=False,
|
|
1221
|
+
)
|
|
1222
|
+
output = proc.stdout.strip()
|
|
1223
|
+
if proc.returncode != 0:
|
|
1224
|
+
detail = proc.stderr.strip() or output
|
|
1225
|
+
return f"(command failed: {detail})" if detail else "(command failed)"
|
|
1226
|
+
return output
|
|
1227
|
+
if program == "cat" and len(args) == 2:
|
|
1228
|
+
resolved = _resolve_repo_scoped_path(repo, str(args[1]))
|
|
1229
|
+
if not resolved or not resolved.is_file():
|
|
1230
|
+
return ""
|
|
1231
|
+
return resolved.read_text(encoding="utf-8", errors="replace")
|
|
1232
|
+
if program == "sed" and len(args) == 4 and args[1] == "-n":
|
|
1233
|
+
match = re.fullmatch(r"(\d+),(\d+)p", str(args[2] or "").strip())
|
|
1234
|
+
if not match:
|
|
1235
|
+
return ""
|
|
1236
|
+
start = max(1, int(match.group(1)))
|
|
1237
|
+
end = max(start, int(match.group(2)))
|
|
1238
|
+
resolved = _resolve_repo_scoped_path(repo, str(args[3]))
|
|
1239
|
+
if not resolved or not resolved.is_file():
|
|
1240
|
+
return ""
|
|
1241
|
+
lines = resolved.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
1242
|
+
return "\n".join(lines[start - 1 : end])
|
|
1243
|
+
return ""
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _build_wrapper_bootstrap_context(repo: str, rejected_commands: List[str]) -> str:
|
|
1247
|
+
blocks: List[str] = []
|
|
1248
|
+
total_chars = 0
|
|
1249
|
+
seen: set[str] = set()
|
|
1250
|
+
for rejected in rejected_commands:
|
|
1251
|
+
direct = _unwrap_shell_wrapper_command(rejected)
|
|
1252
|
+
key = direct.lower()
|
|
1253
|
+
if not direct or key in seen:
|
|
1254
|
+
continue
|
|
1255
|
+
seen.add(key)
|
|
1256
|
+
output = _run_wrapper_bootstrap_command(repo, direct)
|
|
1257
|
+
if not output:
|
|
1258
|
+
continue
|
|
1259
|
+
truncated = _truncate_wrapper_bootstrap_output(output)
|
|
1260
|
+
block = (
|
|
1261
|
+
f"- Direct command: `{direct}`\n"
|
|
1262
|
+
f" Rejected wrapper: `{rejected}`\n"
|
|
1263
|
+
" Output:\n"
|
|
1264
|
+
" ```text\n"
|
|
1265
|
+
f"{truncated}\n"
|
|
1266
|
+
" ```"
|
|
1267
|
+
)
|
|
1268
|
+
if total_chars + len(block) > _MAX_WRAPPER_BOOTSTRAP_TOTAL_CHARS and blocks:
|
|
1269
|
+
break
|
|
1270
|
+
blocks.append(block)
|
|
1271
|
+
total_chars += len(block)
|
|
1272
|
+
if not blocks:
|
|
1273
|
+
return ""
|
|
1274
|
+
return "\n".join(
|
|
1275
|
+
[
|
|
1276
|
+
"Direct command context bootstrap:",
|
|
1277
|
+
"The backend already ran safe read-only direct replacements for some rejected wrapper commands.",
|
|
1278
|
+
"Use these outputs as current repo context and do not rerun the wrapped variants.",
|
|
1279
|
+
*blocks[:6],
|
|
1280
|
+
]
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
|
|
1111
1284
|
def _merge_usage_records(first: Any, second: Any) -> Dict[str, Any]:
|
|
1112
1285
|
first_record = first if isinstance(first, dict) else {}
|
|
1113
1286
|
second_record = second if isinstance(second, dict) else {}
|
|
@@ -1547,10 +1720,16 @@ def _run_codex_task(
|
|
|
1547
1720
|
hard=hard_recovery,
|
|
1548
1721
|
)
|
|
1549
1722
|
if recovery_guidance:
|
|
1723
|
+
bootstrap_context = (
|
|
1724
|
+
_build_wrapper_bootstrap_context(repo, rejected_shell_wrappers)
|
|
1725
|
+
if hard_recovery
|
|
1726
|
+
else ""
|
|
1727
|
+
)
|
|
1550
1728
|
log.warning(
|
|
1551
1729
|
"Codex hit a shell-wrapper rejection loop; retrying once with "
|
|
1552
1730
|
+ (
|
|
1553
1731
|
"strict no-wrapper recovery guidance."
|
|
1732
|
+
+ (" Added direct-command context bootstrap." if bootstrap_context else "")
|
|
1554
1733
|
if hard_recovery
|
|
1555
1734
|
else "direct-command recovery guidance."
|
|
1556
1735
|
)
|
|
@@ -1558,7 +1737,11 @@ def _run_codex_task(
|
|
|
1558
1737
|
retry_result = _run_codex_task(
|
|
1559
1738
|
repo,
|
|
1560
1739
|
instruction,
|
|
1561
|
-
[
|
|
1740
|
+
[
|
|
1741
|
+
*effective_supplemental_guidance,
|
|
1742
|
+
*( [bootstrap_context] if bootstrap_context else [] ),
|
|
1743
|
+
recovery_guidance,
|
|
1744
|
+
],
|
|
1562
1745
|
wrapper_recovery_attempt=wrapper_recovery_attempt + 1,
|
|
1563
1746
|
baseline_changes=baseline_snapshot,
|
|
1564
1747
|
)
|
|
@@ -24,6 +24,7 @@ from executor_base import (
|
|
|
24
24
|
from openai_codex_executor import (
|
|
25
25
|
OpenAICodexRuntimeConfig,
|
|
26
26
|
_augment_supplemental_guidance,
|
|
27
|
+
_build_wrapper_bootstrap_context,
|
|
27
28
|
_build_wrapper_recovery_guidance,
|
|
28
29
|
_run_codex_task,
|
|
29
30
|
_resolve_reasoning_effort,
|
|
@@ -295,6 +296,56 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
|
|
|
295
296
|
self.assertIn("first command invocation on this retry must be one of the direct replacements", lowered)
|
|
296
297
|
self.assertIn("`/bin/bash -lc 'git status --porcelain'` -> `git status --porcelain`", guidance)
|
|
297
298
|
|
|
299
|
+
def test_build_wrapper_bootstrap_context_runs_safe_direct_replacements(self) -> None:
|
|
300
|
+
with tempfile.TemporaryDirectory(prefix="pushpals-codex-wrapper-bootstrap-") as temp_dir:
|
|
301
|
+
repo = Path(temp_dir) / "repo"
|
|
302
|
+
repo.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
(repo / "README.md").write_text("# wrapper bootstrap test\n", encoding="utf-8")
|
|
304
|
+
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True, text=True)
|
|
305
|
+
subprocess.run(
|
|
306
|
+
["git", "config", "user.name", "PushPals Test"],
|
|
307
|
+
cwd=repo,
|
|
308
|
+
check=True,
|
|
309
|
+
capture_output=True,
|
|
310
|
+
text=True,
|
|
311
|
+
)
|
|
312
|
+
subprocess.run(
|
|
313
|
+
["git", "config", "user.email", "pushpals-tests@example.com"],
|
|
314
|
+
cwd=repo,
|
|
315
|
+
check=True,
|
|
316
|
+
capture_output=True,
|
|
317
|
+
text=True,
|
|
318
|
+
)
|
|
319
|
+
subprocess.run(["git", "add", "README.md"], cwd=repo, check=True, capture_output=True, text=True)
|
|
320
|
+
subprocess.run(
|
|
321
|
+
["git", "commit", "-m", "chore: seed wrapper bootstrap repo"],
|
|
322
|
+
cwd=repo,
|
|
323
|
+
check=True,
|
|
324
|
+
capture_output=True,
|
|
325
|
+
text=True,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
guidance = _build_wrapper_bootstrap_context(
|
|
329
|
+
str(repo),
|
|
330
|
+
[
|
|
331
|
+
"/bin/bash -lc pwd",
|
|
332
|
+
"/bin/bash -c pwd",
|
|
333
|
+
"/bin/bash -lc ls",
|
|
334
|
+
"/bin/bash -lc 'git branch --show-current'",
|
|
335
|
+
"/bin/bash -lc 'cat README.md'",
|
|
336
|
+
"/bin/bash -lc 'git diff --output=leak.txt'",
|
|
337
|
+
],
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
self.assertIn("Direct command context bootstrap:", guidance)
|
|
341
|
+
self.assertIn("Direct command: `pwd`", guidance)
|
|
342
|
+
self.assertIn("Direct command: `ls`", guidance)
|
|
343
|
+
self.assertIn("Direct command: `git branch --show-current`", guidance)
|
|
344
|
+
self.assertIn("Direct command: `cat README.md`", guidance)
|
|
345
|
+
self.assertIn("README.md", guidance)
|
|
346
|
+
self.assertIn("# wrapper bootstrap test", guidance)
|
|
347
|
+
self.assertNotIn("git diff --output=leak.txt", guidance)
|
|
348
|
+
|
|
298
349
|
def test_run_codex_task_escalates_wrapper_recovery_and_recovers(self) -> None:
|
|
299
350
|
with tempfile.TemporaryDirectory(prefix="pushpals-codex-wrapper-recovery-") as temp_dir:
|
|
300
351
|
repo = Path(temp_dir) / "repo"
|
|
@@ -341,13 +392,16 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
|
|
|
341
392
|
"",
|
|
342
393
|
"prompt = sys.stdin.read()",
|
|
343
394
|
"hard_marker = 'Your first command invocation on this retry must be one of the direct replacements listed below'",
|
|
344
|
-
"
|
|
395
|
+
"bootstrap_marker = 'Direct command context bootstrap:'",
|
|
396
|
+
"pwd_marker = 'Direct command: `pwd`'",
|
|
397
|
+
"branch_marker = 'Direct command: `git branch --show-current`'",
|
|
398
|
+
"if hard_marker in prompt and bootstrap_marker in prompt and pwd_marker in prompt and branch_marker in prompt:",
|
|
345
399
|
" if last_message_path:",
|
|
346
400
|
" Path(last_message_path).write_text(",
|
|
347
|
-
" 'Recovered by
|
|
401
|
+
" 'Recovered by using backend-supplied direct command bootstrap after strict wrapper recovery.',",
|
|
348
402
|
" encoding='utf-8',",
|
|
349
403
|
" )",
|
|
350
|
-
" print('item.completed | Used
|
|
404
|
+
" print('item.completed | Used backend bootstrap context after strict recovery guidance.', flush=True)",
|
|
351
405
|
" sys.exit(0)",
|
|
352
406
|
"",
|
|
353
407
|
"for line in (",
|
|
@@ -380,6 +434,7 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
|
|
|
380
434
|
self.assertTrue(result.get("ok"), result)
|
|
381
435
|
self.assertIn("Recovered after Codex attempts hit command-router shell-wrapper rejections.", str(result.get("stdout") or ""))
|
|
382
436
|
self.assertIn("strict wrapper recovery", str(result.get("stdout") or "").lower())
|
|
437
|
+
self.assertIn("backend-supplied direct command bootstrap", str(result.get("stdout") or ""))
|
|
383
438
|
|
|
384
439
|
def test_usage_falls_back_to_estimate_when_trace_has_no_usage(self) -> None:
|
|
385
440
|
usage = _usage_from_trace_or_estimate({}, "abc" * 30, "done", model="gpt-5.4")
|
|
@@ -333,6 +333,13 @@ export function shouldEnqueueNoChangeReviewCompletion(
|
|
|
333
333
|
return extractReviewFixContext(params) == null;
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
+
function reviewAgentAllowsMultiRootScope(value: unknown): boolean {
|
|
337
|
+
const normalized = String(value ?? "")
|
|
338
|
+
.trim()
|
|
339
|
+
.toLowerCase();
|
|
340
|
+
return normalized === "review_fix" || normalized === "merge_conflict";
|
|
341
|
+
}
|
|
342
|
+
|
|
336
343
|
export function deriveQualityGatePolicy(
|
|
337
344
|
params: Record<string, unknown> | null | undefined,
|
|
338
345
|
runtimeConfig: WorkerpalsRuntimeConfig = DEFAULT_CONFIG,
|
|
@@ -3217,6 +3224,7 @@ function validateTaskExecutePlanning(
|
|
|
3217
3224
|
options?: {
|
|
3218
3225
|
origin?: "autonomy" | "user";
|
|
3219
3226
|
autonomyComponentArea?: unknown;
|
|
3227
|
+
reviewAgentResolutionType?: unknown;
|
|
3220
3228
|
},
|
|
3221
3229
|
): { ok: true } | { ok: false; message: string } {
|
|
3222
3230
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -3300,14 +3308,18 @@ function validateTaskExecutePlanning(
|
|
|
3300
3308
|
const normalizedWriteGlobs = isStringArray(scope.writeGlobs)
|
|
3301
3309
|
? toStringArray(scope.writeGlobs)
|
|
3302
3310
|
: [];
|
|
3311
|
+
const allowMultiRootAutonomyScope =
|
|
3312
|
+
origin === "autonomy" &&
|
|
3313
|
+
reviewAgentAllowsMultiRootScope(options?.reviewAgentResolutionType);
|
|
3303
3314
|
if (origin === "autonomy") {
|
|
3304
3315
|
const declaredComponentArea = asAutonomyComponentArea(options?.autonomyComponentArea);
|
|
3305
|
-
const inferredComponentArea =
|
|
3306
|
-
|
|
3307
|
-
normalizedWriteGlobs
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3316
|
+
const inferredComponentArea = allowMultiRootAutonomyScope
|
|
3317
|
+
? null
|
|
3318
|
+
: deriveAutonomyComponentArea(normalizedTargetPaths, normalizedWriteGlobs);
|
|
3319
|
+
const componentArea = allowMultiRootAutonomyScope
|
|
3320
|
+
? declaredComponentArea
|
|
3321
|
+
: declaredComponentArea ?? inferredComponentArea;
|
|
3322
|
+
if (!allowMultiRootAutonomyScope && !componentArea) {
|
|
3311
3323
|
return {
|
|
3312
3324
|
ok: false,
|
|
3313
3325
|
message:
|
|
@@ -3315,6 +3327,7 @@ function validateTaskExecutePlanning(
|
|
|
3315
3327
|
};
|
|
3316
3328
|
}
|
|
3317
3329
|
if (
|
|
3330
|
+
!allowMultiRootAutonomyScope &&
|
|
3318
3331
|
declaredComponentArea &&
|
|
3319
3332
|
inferredComponentArea &&
|
|
3320
3333
|
declaredComponentArea !== inferredComponentArea
|
|
@@ -3328,7 +3341,7 @@ function validateTaskExecutePlanning(
|
|
|
3328
3341
|
componentArea,
|
|
3329
3342
|
normalizedTargetPaths,
|
|
3330
3343
|
normalizedWriteGlobs,
|
|
3331
|
-
{ requireWriteGlobs: false },
|
|
3344
|
+
{ requireWriteGlobs: false, allowMultipleComponentRoots: allowMultiRootAutonomyScope },
|
|
3332
3345
|
);
|
|
3333
3346
|
if (!validatedScope.ok) {
|
|
3334
3347
|
return {
|
|
@@ -3686,9 +3699,14 @@ export async function executeJob(
|
|
|
3686
3699
|
params.autonomy && typeof params.autonomy === "object" && !Array.isArray(params.autonomy)
|
|
3687
3700
|
? (params.autonomy as Record<string, unknown>)
|
|
3688
3701
|
: null;
|
|
3702
|
+
const reviewAgent =
|
|
3703
|
+
params.reviewAgent && typeof params.reviewAgent === "object" && !Array.isArray(params.reviewAgent)
|
|
3704
|
+
? (params.reviewAgent as Record<string, unknown>)
|
|
3705
|
+
: null;
|
|
3689
3706
|
const planningValidation = validateTaskExecutePlanning(params.planning, {
|
|
3690
3707
|
origin,
|
|
3691
3708
|
autonomyComponentArea: autonomyScope?.componentArea ?? autonomyScope?.component_area,
|
|
3709
|
+
reviewAgentResolutionType: reviewAgent?.resolutionType,
|
|
3692
3710
|
});
|
|
3693
3711
|
if (!planningValidation.ok) {
|
|
3694
3712
|
return {
|
|
@@ -66,6 +66,7 @@ function commonRepoAncestor(paths: string[]): string | null {
|
|
|
66
66
|
.map((entry) => normalizeRepoRelativePath(entry))
|
|
67
67
|
.filter((entry): entry is string => Boolean(entry));
|
|
68
68
|
if (normalized.length === 0) return null;
|
|
69
|
+
if (normalized.length === 1) return normalized[0] ?? null;
|
|
69
70
|
const segments = normalized.map((entry) => entry.split("/"));
|
|
70
71
|
const shared: string[] = [];
|
|
71
72
|
const first = segments[0] ?? [];
|
|
@@ -78,7 +79,7 @@ function commonRepoAncestor(paths: string[]): string | null {
|
|
|
78
79
|
}
|
|
79
80
|
break;
|
|
80
81
|
}
|
|
81
|
-
if (shared.length === 0) return
|
|
82
|
+
if (shared.length === 0) return null;
|
|
82
83
|
return shared.join("/");
|
|
83
84
|
}
|
|
84
85
|
|
|
@@ -114,6 +115,29 @@ export function deriveAutonomyComponentArea(
|
|
|
114
115
|
return commonRepoAncestor(targetSeeds);
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
function collectScopeSeedPaths(targetPathsInput: unknown[], writeGlobsInput?: unknown[]): string[] {
|
|
119
|
+
const seeds = new Set<string>();
|
|
120
|
+
if (Array.isArray(writeGlobsInput)) {
|
|
121
|
+
for (const raw of writeGlobsInput) {
|
|
122
|
+
const normalized = normalizeWriteGlob(raw);
|
|
123
|
+
if (!normalized) continue;
|
|
124
|
+
const prefix = literalPrefix(normalized);
|
|
125
|
+
if (!prefix) continue;
|
|
126
|
+
const seed = scopeSeedPath(prefix);
|
|
127
|
+
if (seed) seeds.add(seed);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(targetPathsInput)) {
|
|
131
|
+
for (const raw of targetPathsInput) {
|
|
132
|
+
const normalized = normalizeTargetPath(raw);
|
|
133
|
+
if (!normalized) continue;
|
|
134
|
+
const seed = scopeSeedPath(normalized);
|
|
135
|
+
if (seed) seeds.add(seed);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return [...seeds];
|
|
139
|
+
}
|
|
140
|
+
|
|
117
141
|
export function componentRootPrefix(area: AutonomyComponentArea): string {
|
|
118
142
|
const normalized = normalizeAutonomyComponentArea(area);
|
|
119
143
|
if (!normalized) return "";
|
|
@@ -295,12 +319,19 @@ export function validateScopeInvariants(
|
|
|
295
319
|
componentArea: AutonomyComponentArea | null | undefined,
|
|
296
320
|
targetPathsInput: unknown[],
|
|
297
321
|
writeGlobsInput: unknown[],
|
|
298
|
-
options?: { requireWriteGlobs?: boolean },
|
|
322
|
+
options?: { requireWriteGlobs?: boolean; allowMultipleComponentRoots?: boolean },
|
|
299
323
|
): ScopeValidationResult {
|
|
300
324
|
const errors: string[] = [];
|
|
325
|
+
const scopeSeeds = collectScopeSeedPaths(targetPathsInput, writeGlobsInput);
|
|
301
326
|
const normalizedComponentArea =
|
|
302
327
|
normalizeAutonomyComponentArea(componentArea) ??
|
|
303
328
|
deriveAutonomyComponentArea(targetPathsInput, writeGlobsInput);
|
|
329
|
+
const allowMultipleComponentRoots = options?.allowMultipleComponentRoots === true;
|
|
330
|
+
if (!normalizedComponentArea && scopeSeeds.length > 1 && !allowMultipleComponentRoots) {
|
|
331
|
+
errors.push(
|
|
332
|
+
`scope spans multiple component roots: ${scopeSeeds.slice(0, 6).join(", ")}`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
304
335
|
const rootPrefix = normalizedComponentArea ? componentRootPrefix(normalizedComponentArea) : "";
|
|
305
336
|
const normalizedTargetPaths: string[] = [];
|
|
306
337
|
const targetSeen = new Set<string>();
|
|
@@ -368,7 +399,7 @@ export function validateScopeInvariants(
|
|
|
368
399
|
if (!covered) errors.push(`target_path not covered by write_globs: ${targetPath}`);
|
|
369
400
|
}
|
|
370
401
|
}
|
|
371
|
-
if (!normalizedComponentArea) {
|
|
402
|
+
if (!normalizedComponentArea && !allowMultipleComponentRoots) {
|
|
372
403
|
errors.push("component_area could not be derived from scope");
|
|
373
404
|
}
|
|
374
405
|
|