@pushpalsdev/cli 1.0.50 → 1.0.52
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 +69 -0
- package/package.json +1 -1
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/openai_codex_executor.py +124 -3
- package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/test_openai_codex_runtime_config.py +67 -2
- package/runtime/sandbox/apps/workerpals/src/backends/shared/executor_base.py +45 -4
- package/runtime/sandbox/apps/workerpals/src/execute_job.ts +132 -1
package/dist/pushpals-cli.js
CHANGED
|
@@ -3048,6 +3048,44 @@ async function cleanupLingeringWorkerpalWarmContainers(opts) {
|
|
|
3048
3048
|
removed: containerIds.length
|
|
3049
3049
|
};
|
|
3050
3050
|
}
|
|
3051
|
+
async function cleanupLocalWorkerpalSandboxImage(opts) {
|
|
3052
|
+
const imageName = String(opts.dockerImage ?? "").trim();
|
|
3053
|
+
if (!imageName) {
|
|
3054
|
+
return {
|
|
3055
|
+
ok: true,
|
|
3056
|
+
detail: "no local WorkerPal sandbox image configured",
|
|
3057
|
+
removed: false,
|
|
3058
|
+
imageName: ""
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
const runCommandWithEnvFn = opts.runCommandWithEnvFn ?? runCommandWithEnv;
|
|
3062
|
+
const commandTimeoutMs = typeof opts.commandTimeoutMs === "number" && Number.isFinite(opts.commandTimeoutMs) ? Math.max(1, Math.floor(opts.commandTimeoutMs)) : WORKERPAL_IMAGE_INSPECT_TIMEOUT_MS;
|
|
3063
|
+
const dockerExecutable = resolveConfiguredDockerExecutable(opts.env, opts.platform ?? process.platform);
|
|
3064
|
+
const remove = await runCommandWithEnvFn([dockerExecutable, "image", "rm", "-f", imageName], opts.repoRoot, opts.env, commandTimeoutMs);
|
|
3065
|
+
if (!remove.ok) {
|
|
3066
|
+
const detail = remove.stderr || remove.stdout || `exit ${remove.exitCode}`;
|
|
3067
|
+
if (isMissingDockerImageDetail(detail)) {
|
|
3068
|
+
return {
|
|
3069
|
+
ok: true,
|
|
3070
|
+
detail: `no local WorkerPal sandbox image found for ${imageName}`,
|
|
3071
|
+
removed: false,
|
|
3072
|
+
imageName
|
|
3073
|
+
};
|
|
3074
|
+
}
|
|
3075
|
+
return {
|
|
3076
|
+
ok: false,
|
|
3077
|
+
detail: `failed to remove local WorkerPal sandbox image ${imageName}: ${detail}`,
|
|
3078
|
+
removed: false,
|
|
3079
|
+
imageName
|
|
3080
|
+
};
|
|
3081
|
+
}
|
|
3082
|
+
return {
|
|
3083
|
+
ok: true,
|
|
3084
|
+
detail: `removed local WorkerPal sandbox image ${imageName}`,
|
|
3085
|
+
removed: true,
|
|
3086
|
+
imageName
|
|
3087
|
+
};
|
|
3088
|
+
}
|
|
3051
3089
|
async function cleanupLingeringPushPalsGitWorktrees(opts) {
|
|
3052
3090
|
const runCommandWithEnvFn = opts.runCommandWithEnvFn ?? runCommandWithEnv;
|
|
3053
3091
|
const forceDeleteWorktreePathFn = opts.forceDeleteWorktreePathFn ?? forceDeleteWorktreePath;
|
|
@@ -3564,6 +3602,36 @@ async function clearPushpalsState(opts) {
|
|
|
3564
3602
|
for (const target of missing) {
|
|
3565
3603
|
console.log(`[pushpals] Nothing to clear for ${target.label}: ${target.path}`);
|
|
3566
3604
|
}
|
|
3605
|
+
if (opts.config.remotebuddy.workerpalDocker || opts.config.remotebuddy.workerpalRequireDocker) {
|
|
3606
|
+
const dockerEnv = normalizeChildProcessEnv(process.env);
|
|
3607
|
+
const warmCleanup = await cleanupLingeringWorkerpalWarmContainers({
|
|
3608
|
+
repoRoot: opts.repoRoot,
|
|
3609
|
+
env: dockerEnv
|
|
3610
|
+
});
|
|
3611
|
+
if (warmCleanup.ok) {
|
|
3612
|
+
console.log(warmCleanup.removed > 0 ? `[pushpals] Cleared WorkerPal warm containers: ${warmCleanup.detail}` : `[pushpals] Nothing to clear for WorkerPal warm containers: ${warmCleanup.detail}`);
|
|
3613
|
+
} else {
|
|
3614
|
+
failed.push({
|
|
3615
|
+
label: "WorkerPal warm containers",
|
|
3616
|
+
path: opts.repoRoot,
|
|
3617
|
+
detail: warmCleanup.detail
|
|
3618
|
+
});
|
|
3619
|
+
}
|
|
3620
|
+
const imageCleanup = await cleanupLocalWorkerpalSandboxImage({
|
|
3621
|
+
repoRoot: opts.repoRoot,
|
|
3622
|
+
env: dockerEnv,
|
|
3623
|
+
dockerImage: opts.config.remotebuddy.workerpalImage ?? opts.config.workerpals.dockerImage
|
|
3624
|
+
});
|
|
3625
|
+
if (imageCleanup.ok) {
|
|
3626
|
+
console.log(imageCleanup.removed ? `[pushpals] Cleared WorkerPal sandbox image: ${imageCleanup.imageName}` : `[pushpals] Nothing to clear for WorkerPal sandbox image: ${imageCleanup.detail}`);
|
|
3627
|
+
} else {
|
|
3628
|
+
failed.push({
|
|
3629
|
+
label: "WorkerPal sandbox image",
|
|
3630
|
+
path: imageCleanup.imageName || opts.repoRoot,
|
|
3631
|
+
detail: imageCleanup.detail
|
|
3632
|
+
});
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3567
3635
|
for (const failure of failed) {
|
|
3568
3636
|
console.error(`[pushpals] Failed to clear ${failure.label}: ${failure.path} (${failure.detail})`);
|
|
3569
3637
|
}
|
|
@@ -5276,6 +5344,7 @@ export {
|
|
|
5276
5344
|
createSessionEventReplayFilter,
|
|
5277
5345
|
copyTrackedRepoPath,
|
|
5278
5346
|
computeEmbeddedServiceRestartBackoffMs,
|
|
5347
|
+
cleanupLocalWorkerpalSandboxImage,
|
|
5279
5348
|
cleanupLingeringWorkerpalWarmContainers,
|
|
5280
5349
|
cleanupLingeringPushPalsGitWorktrees,
|
|
5281
5350
|
bundledMonitoringHubNeedsRefresh,
|
package/package.json
CHANGED
|
@@ -77,11 +77,16 @@ _CODEX_WORKAROUND_NEGATION_HINTS = (
|
|
|
77
77
|
_REJECTED_EXEC_COMMAND_PATTERN = re.compile(r"exec_command failed for `([^`]+)`", re.IGNORECASE)
|
|
78
78
|
_DISALLOWED_SHELL_WRAPPER_PREFIXES = (
|
|
79
79
|
"/bin/bash -lc ",
|
|
80
|
+
"/bin/bash -c ",
|
|
80
81
|
"bash -lc ",
|
|
82
|
+
"bash -c ",
|
|
81
83
|
"sh -lc ",
|
|
84
|
+
"sh -c ",
|
|
82
85
|
"cmd /c ",
|
|
83
86
|
"powershell -command ",
|
|
87
|
+
"powershell.exe -command ",
|
|
84
88
|
"pwsh -command ",
|
|
89
|
+
"pwsh.exe -command ",
|
|
85
90
|
)
|
|
86
91
|
|
|
87
92
|
_VALID_APPROVAL_POLICIES = {"untrusted", "on-failure", "on-request", "never"}
|
|
@@ -89,6 +94,12 @@ _VALID_SANDBOX_POLICIES = {"read-only", "workspace-write", "danger-full-access"}
|
|
|
89
94
|
_VALID_COLORS = {"always", "never", "auto"}
|
|
90
95
|
_VALID_AUTH_MODES = {"auto", "api_key", "chatgpt"}
|
|
91
96
|
_VALID_REASONING_EFFORTS = {"low", "medium", "high", "xhigh"}
|
|
97
|
+
_DIRECT_COMMAND_POLICY_GUIDANCE = (
|
|
98
|
+
"Command-router policy: use direct commands only. Do not invoke `/bin/bash -lc`, `bash -c`, "
|
|
99
|
+
"`sh -lc`, `cmd /c`, `powershell -Command`, or `pwsh -Command`. Run the direct command "
|
|
100
|
+
"instead, such as `pwd`, `git status --porcelain`, `git diff -- path`, `ls dir`, "
|
|
101
|
+
"`cat file`, `sed -n '1,160p' file`, or `bun test <path>`."
|
|
102
|
+
)
|
|
92
103
|
|
|
93
104
|
|
|
94
105
|
def _model_supports_xhigh_reasoning(model: str) -> bool:
|
|
@@ -981,6 +992,81 @@ def _collect_disallowed_shell_wrapper_rejections(*texts: str) -> List[str]:
|
|
|
981
992
|
return rejected
|
|
982
993
|
|
|
983
994
|
|
|
995
|
+
def _unwrap_shell_wrapper_command(command: str) -> str:
|
|
996
|
+
normalized = _normalize_command_text(command)
|
|
997
|
+
if not normalized:
|
|
998
|
+
return ""
|
|
999
|
+
try:
|
|
1000
|
+
parts = shlex.split(normalized, posix=True)
|
|
1001
|
+
except ValueError:
|
|
1002
|
+
return ""
|
|
1003
|
+
if len(parts) < 3:
|
|
1004
|
+
return ""
|
|
1005
|
+
executable = str(parts[0] or "").strip().lower()
|
|
1006
|
+
flag = str(parts[1] or "").strip().lower()
|
|
1007
|
+
if executable in {"/bin/bash", "bash", "sh"} and flag in {"-lc", "-c"}:
|
|
1008
|
+
return _normalize_command_text(" ".join(parts[2:]))
|
|
1009
|
+
if executable == "cmd" and flag == "/c":
|
|
1010
|
+
return _normalize_command_text(" ".join(parts[2:]))
|
|
1011
|
+
if executable in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"} and flag == "-command":
|
|
1012
|
+
return _normalize_command_text(" ".join(parts[2:]))
|
|
1013
|
+
return ""
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _build_wrapper_recovery_guidance(rejected_commands: List[str]) -> str:
|
|
1017
|
+
direct_equivalents: List[str] = []
|
|
1018
|
+
seen: set[str] = set()
|
|
1019
|
+
for command in rejected_commands:
|
|
1020
|
+
direct = _unwrap_shell_wrapper_command(command)
|
|
1021
|
+
lowered = direct.lower()
|
|
1022
|
+
if not direct or lowered in seen:
|
|
1023
|
+
continue
|
|
1024
|
+
seen.add(lowered)
|
|
1025
|
+
direct_equivalents.append(f"- `{command}` -> `{direct}`")
|
|
1026
|
+
guidance_lines = [
|
|
1027
|
+
"Command-router recovery: the previous attempt retried disallowed shell wrappers.",
|
|
1028
|
+
"Retry once using direct commands only. Do not use `/bin/bash -lc`, `bash -c`, `sh -lc`, `cmd /c`, `powershell -Command`, `pwsh -Command`, pipelines, or chained shell snippets.",
|
|
1029
|
+
"If you need to inspect files or git state, run the direct command itself (for example `git diff --name-only`, `git status --porcelain`, `ls path`, `cat file`, or `sed -n '1,120p' file`).",
|
|
1030
|
+
]
|
|
1031
|
+
if direct_equivalents:
|
|
1032
|
+
guidance_lines.append("Use these direct replacements for the rejected commands:")
|
|
1033
|
+
guidance_lines.extend(direct_equivalents[:6])
|
|
1034
|
+
return "\n".join(guidance_lines)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _merge_usage_records(first: Any, second: Any) -> Dict[str, Any]:
|
|
1038
|
+
first_record = first if isinstance(first, dict) else {}
|
|
1039
|
+
second_record = second if isinstance(second, dict) else {}
|
|
1040
|
+
if not first_record:
|
|
1041
|
+
return dict(second_record)
|
|
1042
|
+
if not second_record:
|
|
1043
|
+
return dict(first_record)
|
|
1044
|
+
prompt_tokens = to_int(first_record.get("promptTokens"), 0) + to_int(
|
|
1045
|
+
second_record.get("promptTokens"), 0
|
|
1046
|
+
)
|
|
1047
|
+
completion_tokens = to_int(first_record.get("completionTokens"), 0) + to_int(
|
|
1048
|
+
second_record.get("completionTokens"), 0
|
|
1049
|
+
)
|
|
1050
|
+
merged = dict(second_record)
|
|
1051
|
+
merged["promptTokens"] = prompt_tokens
|
|
1052
|
+
merged["completionTokens"] = completion_tokens
|
|
1053
|
+
merged["totalTokens"] = prompt_tokens + completion_tokens
|
|
1054
|
+
merged["estimated"] = bool(first_record.get("estimated")) or bool(second_record.get("estimated"))
|
|
1055
|
+
if not merged.get("backend"):
|
|
1056
|
+
merged["backend"] = first_record.get("backend")
|
|
1057
|
+
if not merged.get("modelId"):
|
|
1058
|
+
merged["modelId"] = first_record.get("modelId")
|
|
1059
|
+
return merged
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _augment_supplemental_guidance(supplemental_guidance: List[str]) -> List[str]:
|
|
1063
|
+
normalized = [str(item or "").strip() for item in supplemental_guidance if str(item or "").strip()]
|
|
1064
|
+
joined = "\n".join(normalized).lower()
|
|
1065
|
+
if "direct commands only" in joined or "shell-wrapper" in joined or "/bin/bash -lc" in joined:
|
|
1066
|
+
return normalized
|
|
1067
|
+
return [_DIRECT_COMMAND_POLICY_GUIDANCE, *normalized]
|
|
1068
|
+
|
|
1069
|
+
|
|
984
1070
|
def _read_text_if_exists(path: Path) -> str:
|
|
985
1071
|
try:
|
|
986
1072
|
if not path.exists():
|
|
@@ -1008,6 +1094,9 @@ def _run_codex_task(
|
|
|
1008
1094
|
repo: str,
|
|
1009
1095
|
instruction: str,
|
|
1010
1096
|
supplemental_guidance: List[str],
|
|
1097
|
+
*,
|
|
1098
|
+
wrapper_recovery_attempt: int = 0,
|
|
1099
|
+
baseline_changes: Optional[List[str]] = None,
|
|
1011
1100
|
) -> Dict[str, Any]:
|
|
1012
1101
|
global _ACTIVE_CHILD, _INTERRUPTED_SIGNAL
|
|
1013
1102
|
_INTERRUPTED_SIGNAL = None
|
|
@@ -1064,8 +1153,9 @@ def _run_codex_task(
|
|
|
1064
1153
|
use_json = runtime_config.json_output
|
|
1065
1154
|
reasoning_effort = _resolve_reasoning_effort(runtime_config, model)
|
|
1066
1155
|
communicate_timeout_s = _resolve_communicate_timeout_seconds(runtime_config)
|
|
1067
|
-
|
|
1068
|
-
|
|
1156
|
+
effective_supplemental_guidance = _augment_supplemental_guidance(supplemental_guidance)
|
|
1157
|
+
prompt = _build_instruction(instruction, effective_supplemental_guidance)
|
|
1158
|
+
baseline_snapshot = list(baseline_changes) if baseline_changes is not None else summarize_git_changes(repo)
|
|
1069
1159
|
|
|
1070
1160
|
with tempfile.TemporaryDirectory(prefix="pushpals-codex-") as tmp_dir:
|
|
1071
1161
|
last_message_path = Path(tmp_dir) / "codex-last-message.txt"
|
|
@@ -1376,6 +1466,37 @@ def _run_codex_task(
|
|
|
1376
1466
|
log_git_status(repo, log)
|
|
1377
1467
|
|
|
1378
1468
|
if command_policy_rejection_loop:
|
|
1469
|
+
if wrapper_recovery_attempt < 1:
|
|
1470
|
+
recovery_guidance = _build_wrapper_recovery_guidance(rejected_shell_wrappers)
|
|
1471
|
+
if recovery_guidance:
|
|
1472
|
+
log.warning(
|
|
1473
|
+
"Codex hit a shell-wrapper rejection loop; retrying once with direct-command recovery guidance."
|
|
1474
|
+
)
|
|
1475
|
+
retry_result = _run_codex_task(
|
|
1476
|
+
repo,
|
|
1477
|
+
instruction,
|
|
1478
|
+
[*effective_supplemental_guidance, recovery_guidance],
|
|
1479
|
+
wrapper_recovery_attempt=wrapper_recovery_attempt + 1,
|
|
1480
|
+
baseline_changes=baseline_snapshot,
|
|
1481
|
+
)
|
|
1482
|
+
retry_result["usage"] = _merge_usage_records(usage, retry_result.get("usage"))
|
|
1483
|
+
if retry_result.get("ok"):
|
|
1484
|
+
recovered_stdout = str(retry_result.get("stdout") or "").strip()
|
|
1485
|
+
retry_result["stdout"] = _truncate(
|
|
1486
|
+
(
|
|
1487
|
+
"Recovered after the first Codex attempt hit command-router shell-wrapper rejections.\n\n"
|
|
1488
|
+
f"{recovered_stdout}"
|
|
1489
|
+
).strip()
|
|
1490
|
+
)
|
|
1491
|
+
else:
|
|
1492
|
+
retry_stderr = str(retry_result.get("stderr") or "").strip()
|
|
1493
|
+
retry_result["stderr"] = _truncate(
|
|
1494
|
+
(
|
|
1495
|
+
"The first Codex attempt hit command-router shell-wrapper rejections and was retried once with direct-command recovery guidance.\n\n"
|
|
1496
|
+
f"{retry_stderr}"
|
|
1497
|
+
).strip()
|
|
1498
|
+
)
|
|
1499
|
+
return retry_result
|
|
1379
1500
|
command_lines = (
|
|
1380
1501
|
"\n".join(f"- {command}" for command in rejected_shell_wrappers[:6])
|
|
1381
1502
|
if rejected_shell_wrappers
|
|
@@ -1460,7 +1581,7 @@ def _run_codex_task(
|
|
|
1460
1581
|
}
|
|
1461
1582
|
|
|
1462
1583
|
changed_paths = summarize_git_changes(repo)
|
|
1463
|
-
delta = [p for p in changed_paths if p not in
|
|
1584
|
+
delta = [p for p in changed_paths if p not in baseline_snapshot]
|
|
1464
1585
|
effective = delta if delta else changed_paths
|
|
1465
1586
|
stdout_parts: List[str] = []
|
|
1466
1587
|
if last_message:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import re
|
|
2
3
|
import sys
|
|
3
4
|
import unittest
|
|
4
5
|
import tempfile
|
|
@@ -10,9 +11,16 @@ for path in (_HERE, _SHARED):
|
|
|
10
11
|
if str(path) not in sys.path:
|
|
11
12
|
sys.path.insert(0, str(path))
|
|
12
13
|
|
|
13
|
-
from executor_base import
|
|
14
|
+
from executor_base import (
|
|
15
|
+
LOGGER_STANDARD_METHODS,
|
|
16
|
+
Logger,
|
|
17
|
+
SettingsResolver,
|
|
18
|
+
config_dir_for_runtime_config,
|
|
19
|
+
runtime_config,
|
|
20
|
+
)
|
|
14
21
|
from openai_codex_executor import (
|
|
15
22
|
OpenAICodexRuntimeConfig,
|
|
23
|
+
_augment_supplemental_guidance,
|
|
16
24
|
_resolve_reasoning_effort,
|
|
17
25
|
_build_instruction,
|
|
18
26
|
_collect_disallowed_shell_wrapper_rejections,
|
|
@@ -20,6 +28,7 @@ from openai_codex_executor import (
|
|
|
20
28
|
_extract_usage_counts,
|
|
21
29
|
_load_prompt_template,
|
|
22
30
|
_repo_root_for_prompt_loading,
|
|
31
|
+
_unwrap_shell_wrapper_command,
|
|
23
32
|
_usage_from_trace_or_estimate,
|
|
24
33
|
)
|
|
25
34
|
|
|
@@ -200,10 +209,66 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
|
|
|
200
209
|
def test_collects_disallowed_shell_wrapper_rejections(self) -> None:
|
|
201
210
|
commands = _collect_disallowed_shell_wrapper_rejections(
|
|
202
211
|
"error=exec_command failed for `/bin/bash -lc pwd`: CreateProcess { message: \"Rejected\" }",
|
|
212
|
+
"error=exec_command failed for `/bin/bash -c \"git status --porcelain\"`: Rejected",
|
|
203
213
|
"error=exec_command failed for `sh -lc \"git diff\"`: Rejected",
|
|
204
214
|
"error=exec_command failed for `pwd`: Rejected",
|
|
205
215
|
)
|
|
206
|
-
self.assertEqual(
|
|
216
|
+
self.assertEqual(
|
|
217
|
+
commands,
|
|
218
|
+
["/bin/bash -lc pwd", '/bin/bash -c "git status --porcelain"', 'sh -lc "git diff"'],
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def test_unwraps_disallowed_shell_wrapper_commands_to_direct_commands(self) -> None:
|
|
222
|
+
self.assertEqual(
|
|
223
|
+
_unwrap_shell_wrapper_command("/bin/bash -lc 'git diff --name-only'"),
|
|
224
|
+
"git diff --name-only",
|
|
225
|
+
)
|
|
226
|
+
self.assertEqual(
|
|
227
|
+
_unwrap_shell_wrapper_command('cmd /c dir /b'),
|
|
228
|
+
"dir /b",
|
|
229
|
+
)
|
|
230
|
+
self.assertEqual(
|
|
231
|
+
_unwrap_shell_wrapper_command('pwsh -Command "Get-ChildItem src"'),
|
|
232
|
+
"Get-ChildItem src",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def test_logger_supports_warning_alias_used_by_recovery_paths(self) -> None:
|
|
236
|
+
logger = Logger("[test]")
|
|
237
|
+
self.assertTrue(callable(getattr(logger, "warn", None)))
|
|
238
|
+
self.assertTrue(callable(getattr(logger, "warning", None)))
|
|
239
|
+
|
|
240
|
+
def test_logger_supports_standard_backend_method_surface(self) -> None:
|
|
241
|
+
logger = Logger("[test]")
|
|
242
|
+
for method_name in LOGGER_STANDARD_METHODS:
|
|
243
|
+
self.assertTrue(
|
|
244
|
+
callable(getattr(logger, method_name, None)),
|
|
245
|
+
f"Logger is missing required method: {method_name}",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def test_backend_log_method_usage_matches_shared_logger_contract(self) -> None:
|
|
249
|
+
backend_root = _HERE.parent
|
|
250
|
+
method_pattern = re.compile(r"\blog\.(\w+)\(")
|
|
251
|
+
used_methods = set()
|
|
252
|
+
for path in backend_root.rglob("*.py"):
|
|
253
|
+
if path.name.startswith("test_"):
|
|
254
|
+
continue
|
|
255
|
+
text = path.read_text(encoding="utf-8")
|
|
256
|
+
used_methods.update(method_pattern.findall(text))
|
|
257
|
+
|
|
258
|
+
self.assertTrue(used_methods, "Expected to discover backend logger usage")
|
|
259
|
+
unsupported = sorted(method for method in used_methods if method not in LOGGER_STANDARD_METHODS)
|
|
260
|
+
self.assertEqual(
|
|
261
|
+
unsupported,
|
|
262
|
+
[],
|
|
263
|
+
f"Backend code uses logger method(s) not covered by executor_base.Logger: {unsupported}",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def test_augments_guidance_with_direct_command_policy_once(self) -> None:
|
|
267
|
+
guidance = _augment_supplemental_guidance(["Run bun test tests/example.test.ts"])
|
|
268
|
+
self.assertGreaterEqual(len(guidance), 2)
|
|
269
|
+
self.assertIn("direct commands only", guidance[0].lower())
|
|
270
|
+
guidance_again = _augment_supplemental_guidance(guidance)
|
|
271
|
+
self.assertEqual(guidance_again, guidance)
|
|
207
272
|
|
|
208
273
|
def test_usage_falls_back_to_estimate_when_trace_has_no_usage(self) -> None:
|
|
209
274
|
usage = _usage_from_trace_or_estimate({}, "abc" * 30, "done", model="gpt-5.4")
|
|
@@ -14,6 +14,7 @@ import os
|
|
|
14
14
|
import re
|
|
15
15
|
import subprocess
|
|
16
16
|
import sys
|
|
17
|
+
import traceback
|
|
17
18
|
from dataclasses import dataclass, field
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
|
@@ -50,6 +51,15 @@ KNOWN_LITELLM_PROVIDER_PREFIXES: Set[str] = {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
DEFAULT_TOOLCALL_RETRY_MAX = 1
|
|
54
|
+
LOGGER_STANDARD_METHODS: Tuple[str, ...] = (
|
|
55
|
+
"debug",
|
|
56
|
+
"info",
|
|
57
|
+
"warn",
|
|
58
|
+
"warning",
|
|
59
|
+
"error",
|
|
60
|
+
"exception",
|
|
61
|
+
"critical",
|
|
62
|
+
)
|
|
53
63
|
|
|
54
64
|
# Superset of signals from both executors indicating the model failed to
|
|
55
65
|
# emit tool calls / tool actions.
|
|
@@ -100,12 +110,43 @@ class Logger:
|
|
|
100
110
|
def __init__(self, prefix: str) -> None:
|
|
101
111
|
self.prefix = prefix
|
|
102
112
|
|
|
103
|
-
def
|
|
104
|
-
|
|
113
|
+
def _coerce_message(self, message: Any, args: Tuple[Any, ...]) -> str:
|
|
114
|
+
text = str(message)
|
|
115
|
+
if not args:
|
|
116
|
+
return text
|
|
117
|
+
try:
|
|
118
|
+
return text % args
|
|
119
|
+
except Exception:
|
|
120
|
+
pieces = [text, *(str(arg) for arg in args)]
|
|
121
|
+
return " ".join(piece for piece in pieces if piece)
|
|
122
|
+
|
|
123
|
+
def _emit(self, _level: str, message: Any, *args: Any) -> None:
|
|
124
|
+
executor_log(f"{self.prefix} {self._coerce_message(message, args)}")
|
|
105
125
|
|
|
106
|
-
def
|
|
126
|
+
def info(self, message: Any, *args: Any) -> None:
|
|
127
|
+
self._emit("info", message, *args)
|
|
128
|
+
|
|
129
|
+
def debug(self, message: Any, *args: Any) -> None:
|
|
107
130
|
if _debug_enabled():
|
|
108
|
-
|
|
131
|
+
self._emit("debug", message, *args)
|
|
132
|
+
|
|
133
|
+
def warn(self, message: Any, *args: Any) -> None:
|
|
134
|
+
self._emit("warn", message, *args)
|
|
135
|
+
|
|
136
|
+
def warning(self, message: Any, *args: Any) -> None:
|
|
137
|
+
self.warn(message, *args)
|
|
138
|
+
|
|
139
|
+
def error(self, message: Any, *args: Any) -> None:
|
|
140
|
+
self._emit("error", message, *args)
|
|
141
|
+
|
|
142
|
+
def critical(self, message: Any, *args: Any) -> None:
|
|
143
|
+
self._emit("critical", message, *args)
|
|
144
|
+
|
|
145
|
+
def exception(self, message: Any, *args: Any, exc_info: Any = True) -> None:
|
|
146
|
+
detail = self._coerce_message(message, args)
|
|
147
|
+
if exc_info:
|
|
148
|
+
detail = f"{detail}\n{traceback.format_exc().strip()}"
|
|
149
|
+
self._emit("exception", detail)
|
|
109
150
|
|
|
110
151
|
|
|
111
152
|
def fail(summary: str, stderr: Optional[str] = None, exit_code: int = 1) -> int:
|
|
@@ -2042,6 +2042,126 @@ async function activeGitOperation(repo: string): Promise<"rebase" | "merge" | "c
|
|
|
2042
2042
|
return null;
|
|
2043
2043
|
}
|
|
2044
2044
|
|
|
2045
|
+
export async function resumePreparedMergeConflictRebase(
|
|
2046
|
+
repo: string,
|
|
2047
|
+
kind: string,
|
|
2048
|
+
params?: Record<string, unknown>,
|
|
2049
|
+
onLog?: (stream: "stdout" | "stderr", line: string) => void,
|
|
2050
|
+
): Promise<
|
|
2051
|
+
| { ok: true; resumed: boolean; sequencer: "rebase" | "merge" | "cherry-pick" | null; detail?: string }
|
|
2052
|
+
| { ok: false; error: string }
|
|
2053
|
+
> {
|
|
2054
|
+
const sequencer = await activeGitOperation(repo);
|
|
2055
|
+
if (sequencer !== "rebase") {
|
|
2056
|
+
return { ok: true, resumed: false, sequencer };
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
const unresolved = await git(repo, ["diff", "--name-only", "--diff-filter=U"]);
|
|
2060
|
+
if (!unresolved.ok) {
|
|
2061
|
+
return {
|
|
2062
|
+
ok: false,
|
|
2063
|
+
error: `Failed to inspect unresolved merge-conflict paths: ${combinedGitOutput(unresolved)}`,
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
const unresolvedPaths = parseChangedPathsFromNameOnlyOutput(unresolved.stdout);
|
|
2067
|
+
if (unresolvedPaths.length > 0) {
|
|
2068
|
+
const stillMarked = unresolvedPaths.filter((relativePath) => {
|
|
2069
|
+
try {
|
|
2070
|
+
const contents = readFileSync(resolve(repo, relativePath), "utf8");
|
|
2071
|
+
return /^(<{7}|={7}|>{7})( .*)?$/m.test(contents);
|
|
2072
|
+
} catch {
|
|
2073
|
+
return true;
|
|
2074
|
+
}
|
|
2075
|
+
});
|
|
2076
|
+
if (stillMarked.length > 0) {
|
|
2077
|
+
return {
|
|
2078
|
+
ok: true,
|
|
2079
|
+
resumed: false,
|
|
2080
|
+
sequencer,
|
|
2081
|
+
detail: `rebase still has ${stillMarked.length} unresolved conflict marker file(s)`,
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
onLog?.(
|
|
2085
|
+
"stdout",
|
|
2086
|
+
`[MergeConflict] Found ${unresolvedPaths.length} resolved-but-unstaged conflict file(s); staging them before continuing the rebase.`,
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
let stageResult: { ok: boolean; stdout: string; stderr: string };
|
|
2091
|
+
const stageArgs = buildStageCommand(kind, params);
|
|
2092
|
+
if (stageArgs) {
|
|
2093
|
+
stageResult = await git(repo, stageArgs);
|
|
2094
|
+
if (!stageResult.ok) {
|
|
2095
|
+
const stageErr = stageResult.stderr || stageResult.stdout;
|
|
2096
|
+
if (
|
|
2097
|
+
/pathspec .* did not match any files/i.test(stageErr) ||
|
|
2098
|
+
/invalid path/i.test(stageErr) ||
|
|
2099
|
+
/outside repository/i.test(stageErr)
|
|
2100
|
+
) {
|
|
2101
|
+
onLog?.(
|
|
2102
|
+
"stdout",
|
|
2103
|
+
`[MergeConflict] Stage target invalid/missing for ${kind}; retrying with fallback "git add -A".`,
|
|
2104
|
+
);
|
|
2105
|
+
stageResult = await git(repo, [
|
|
2106
|
+
"add",
|
|
2107
|
+
"-A",
|
|
2108
|
+
"--",
|
|
2109
|
+
".",
|
|
2110
|
+
":(exclude)workspace/**",
|
|
2111
|
+
":(exclude)outputs/**",
|
|
2112
|
+
]);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
} else {
|
|
2116
|
+
stageResult = await git(repo, [
|
|
2117
|
+
"add",
|
|
2118
|
+
"-A",
|
|
2119
|
+
"--",
|
|
2120
|
+
".",
|
|
2121
|
+
":(exclude)workspace/**",
|
|
2122
|
+
":(exclude)outputs/**",
|
|
2123
|
+
]);
|
|
2124
|
+
}
|
|
2125
|
+
if (!stageResult.ok) {
|
|
2126
|
+
return {
|
|
2127
|
+
ok: false,
|
|
2128
|
+
error:
|
|
2129
|
+
"Failed to stage resolved merge-conflict changes before continuing rebase: " +
|
|
2130
|
+
combinedGitOutput(stageResult),
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
let rebaseContinue = await git(repo, ["-c", "core.editor=true", "rebase", "--continue"]);
|
|
2135
|
+
let continueOutput = combinedGitOutput(rebaseContinue);
|
|
2136
|
+
if (!rebaseContinue.ok && isRebaseEditorPromptOutput(continueOutput)) {
|
|
2137
|
+
rebaseContinue = await git(repo, ["-c", "core.editor=true", "rebase", "--continue"]);
|
|
2138
|
+
continueOutput = combinedGitOutput(rebaseContinue);
|
|
2139
|
+
}
|
|
2140
|
+
if (!rebaseContinue.ok) {
|
|
2141
|
+
return {
|
|
2142
|
+
ok: false,
|
|
2143
|
+
error: `Failed to continue prepared merge-conflict rebase: ${continueOutput}`,
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
const remainingSequencer = await activeGitOperation(repo);
|
|
2148
|
+
if (!remainingSequencer) {
|
|
2149
|
+
onLog?.(
|
|
2150
|
+
"stdout",
|
|
2151
|
+
"[MergeConflict] Auto-continued the prepared rebase after the executor returned with no unresolved conflicts.",
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
return {
|
|
2155
|
+
ok: true,
|
|
2156
|
+
resumed: true,
|
|
2157
|
+
sequencer: remainingSequencer,
|
|
2158
|
+
detail:
|
|
2159
|
+
remainingSequencer === "rebase"
|
|
2160
|
+
? "rebase advanced but another continuation step is still required"
|
|
2161
|
+
: undefined,
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2045
2165
|
async function isAncestorRef(repo: string, ancestor: string, descendant: string): Promise<boolean> {
|
|
2046
2166
|
const result = await git(repo, ["merge-base", "--is-ancestor", ancestor, descendant]);
|
|
2047
2167
|
return result.ok;
|
|
@@ -3409,7 +3529,18 @@ export async function executeJob(
|
|
|
3409
3529
|
);
|
|
3410
3530
|
if (!result.ok) return result;
|
|
3411
3531
|
if (mergeConflictContext) {
|
|
3412
|
-
const
|
|
3532
|
+
const resume = await resumePreparedMergeConflictRebase(repo, kind, attemptParams, onLog);
|
|
3533
|
+
if (!resume.ok) {
|
|
3534
|
+
onLog?.("stderr", `[MergeConflict] ${resume.error}`);
|
|
3535
|
+
return {
|
|
3536
|
+
ok: false,
|
|
3537
|
+
summary: "Merge-conflict rebase continuation failed",
|
|
3538
|
+
stdout: result.stdout,
|
|
3539
|
+
stderr: [result.stderr ?? "", resume.error].filter(Boolean).join("\n"),
|
|
3540
|
+
exitCode: 4,
|
|
3541
|
+
};
|
|
3542
|
+
}
|
|
3543
|
+
const sequencer = resume.sequencer;
|
|
3413
3544
|
if (sequencer) {
|
|
3414
3545
|
const detail =
|
|
3415
3546
|
`Merge-conflict job returned with git ${sequencer} still in progress. ` +
|