forgexa-cli 1.57.6__tar.gz → 1.58.0__tar.gz
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.
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/PKG-INFO +1 -1
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli/agent_core.py +16 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli/daemon.py +185 -31
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/pyproject.toml +1 -1
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/README.md +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/setup.cfg +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.57.6 → forgexa_cli-1.58.0}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.58.0"
|
|
@@ -877,6 +877,8 @@ class AgentDiscovery:
|
|
|
877
877
|
"binary found but not functional".
|
|
878
878
|
"""
|
|
879
879
|
import re
|
|
880
|
+
probe_dir = None
|
|
881
|
+
proc = None
|
|
880
882
|
try:
|
|
881
883
|
parts = detect_cmd.split()
|
|
882
884
|
if executable:
|
|
@@ -886,11 +888,17 @@ class AgentDiscovery:
|
|
|
886
888
|
if resolved:
|
|
887
889
|
parts[0] = resolved
|
|
888
890
|
parts = _prepare_agent_command(parts, preserve_argv=False)
|
|
891
|
+
env = None
|
|
892
|
+
if detect_cmd in ("opencode --version", "mimo --version"):
|
|
893
|
+
probe_dir = tempfile.TemporaryDirectory(prefix=f"forgexa-{detect_cmd.split()[0]}-probe-")
|
|
894
|
+
env = os.environ.copy()
|
|
895
|
+
env.update(TMPDIR=probe_dir.name, BUN_TMPDIR=probe_dir.name)
|
|
889
896
|
proc = await asyncio.create_subprocess_exec(
|
|
890
897
|
*parts,
|
|
891
898
|
stdin=asyncio.subprocess.DEVNULL,
|
|
892
899
|
stdout=asyncio.subprocess.PIPE,
|
|
893
900
|
stderr=asyncio.subprocess.PIPE,
|
|
901
|
+
env=env,
|
|
894
902
|
**_agent_process_group_kwargs(),
|
|
895
903
|
)
|
|
896
904
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
|
@@ -918,11 +926,19 @@ class AgentDiscovery:
|
|
|
918
926
|
)
|
|
919
927
|
return None
|
|
920
928
|
return output
|
|
929
|
+
except asyncio.CancelledError:
|
|
930
|
+
raise
|
|
921
931
|
except Exception as exc:
|
|
922
932
|
logger.warning(
|
|
923
933
|
"Version check for %r raised %s: %s", detect_cmd, type(exc).__name__, exc,
|
|
924
934
|
)
|
|
925
935
|
return None
|
|
936
|
+
finally:
|
|
937
|
+
if proc is not None and proc.returncode is None:
|
|
938
|
+
_kill_proc(proc)
|
|
939
|
+
await proc.wait()
|
|
940
|
+
if probe_dir is not None:
|
|
941
|
+
probe_dir.cleanup()
|
|
926
942
|
|
|
927
943
|
@staticmethod
|
|
928
944
|
async def _probe_bwrap_support() -> None:
|
|
@@ -1150,7 +1150,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1150
1150
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1151
1151
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1152
1152
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1153
|
-
DAEMON_VERSION = "1.
|
|
1153
|
+
DAEMON_VERSION = "1.58.0"
|
|
1154
1154
|
|
|
1155
1155
|
|
|
1156
1156
|
def _detect_client_type() -> str:
|
|
@@ -2899,11 +2899,21 @@ class WorkspaceManager:
|
|
|
2899
2899
|
self._ssh_keys[project_key] = ssh_private_key
|
|
2900
2900
|
|
|
2901
2901
|
@staticmethod
|
|
2902
|
-
def _summarize_git_error(error: Exception) -> str:
|
|
2902
|
+
def _summarize_git_error(error: Exception, *, tail: bool = False) -> str:
|
|
2903
2903
|
"""Return a compact Git error without exposing embedded HTTPS credentials."""
|
|
2904
2904
|
detail = " ".join(str(error).split())
|
|
2905
2905
|
detail = re.sub(r"(https?://)[^/@\s]+@", r"\1***@", detail)
|
|
2906
|
-
return detail[:500]
|
|
2906
|
+
return detail[-500:] if tail else detail[:500]
|
|
2907
|
+
|
|
2908
|
+
@staticmethod
|
|
2909
|
+
def _git_push_diagnostic(error: Exception) -> str:
|
|
2910
|
+
detail = str(error).replace("\r\n", "\n").replace("\r", "\n")
|
|
2911
|
+
detail = re.sub(r"(https?://)[^/@\s]+@", r"\1***@", detail)
|
|
2912
|
+
if len(detail) <= 16_000:
|
|
2913
|
+
return detail
|
|
2914
|
+
beginning, ending = detail[:1000], detail[-14_800:]
|
|
2915
|
+
omitted = len(detail) - len(beginning) - len(ending)
|
|
2916
|
+
return f"{beginning}\n... [{omitted} earlier characters omitted] ...\n{ending}"
|
|
2907
2917
|
|
|
2908
2918
|
def _migrate_legacy_project_dirs(self) -> None:
|
|
2909
2919
|
"""Move project directories from root/ to root/projects/ (one-time migration).
|
|
@@ -5507,6 +5517,10 @@ class _TaskAttachmentDownloadError(RuntimeError):
|
|
|
5507
5517
|
"""Raised when a required task attachment cannot be staged safely."""
|
|
5508
5518
|
|
|
5509
5519
|
|
|
5520
|
+
class _TaskAttachmentUnavailableError(_TaskAttachmentDownloadError):
|
|
5521
|
+
"""Raised when the attachment source is unavailable but the task can proceed."""
|
|
5522
|
+
|
|
5523
|
+
|
|
5510
5524
|
def _workspace_has_recent_activity(
|
|
5511
5525
|
workspace_path: "Path", since_monotonic: float, max_depth: int = 4
|
|
5512
5526
|
) -> bool:
|
|
@@ -5627,6 +5641,8 @@ class ProcessManager:
|
|
|
5627
5641
|
"authentication required",
|
|
5628
5642
|
"authenticate with the github",
|
|
5629
5643
|
"gh auth login",
|
|
5644
|
+
"your github token may be invalid",
|
|
5645
|
+
"rejected the cli github credential",
|
|
5630
5646
|
]
|
|
5631
5647
|
|
|
5632
5648
|
def __init__(self):
|
|
@@ -6693,6 +6709,7 @@ class ProcessManager:
|
|
|
6693
6709
|
data_home=tmp_data_root,
|
|
6694
6710
|
require_windows_shell=True,
|
|
6695
6711
|
)
|
|
6712
|
+
env.update(TMPDIR=tmp_data_root, BUN_TMPDIR=tmp_data_root)
|
|
6696
6713
|
except RuntimeError as exc:
|
|
6697
6714
|
return TaskResult(
|
|
6698
6715
|
status="failed",
|
|
@@ -6786,6 +6803,7 @@ class ProcessManager:
|
|
|
6786
6803
|
prompt_file=prompt_file,
|
|
6787
6804
|
)
|
|
6788
6805
|
env = agent_core.mimo_base_env(os.environ.copy())
|
|
6806
|
+
env.update(TMPDIR=tmp_root, BUN_TMPDIR=tmp_root)
|
|
6789
6807
|
result = await self._run_cli(
|
|
6790
6808
|
cmd, cwd, timeout, task_id, on_chunk=on_chunk, env=env,
|
|
6791
6809
|
)
|
|
@@ -7640,6 +7658,59 @@ class ProcessManager:
|
|
|
7640
7658
|
return retry_result
|
|
7641
7659
|
_auth_state["persist"] = False
|
|
7642
7660
|
|
|
7661
|
+
auth_detail = " ".join(
|
|
7662
|
+
[*structured_errors, stderr]
|
|
7663
|
+
).lower()
|
|
7664
|
+
token_auth_error = (
|
|
7665
|
+
"your github token may be invalid" in auth_detail
|
|
7666
|
+
or "rejected the cli github credential" in auth_detail
|
|
7667
|
+
)
|
|
7668
|
+
if token_auth_error and not (copilot_exit == 0 and returncode == 0):
|
|
7669
|
+
_auth_state["persist"] = False
|
|
7670
|
+
if (
|
|
7671
|
+
token_auth_error
|
|
7672
|
+
and not (copilot_exit == 0 and returncode == 0)
|
|
7673
|
+
and not signals["has_meaningful_content"]
|
|
7674
|
+
and not self._copilot_called_any_tools(stdout)
|
|
7675
|
+
):
|
|
7676
|
+
recovery_home = (
|
|
7677
|
+
Path(_master_copilot_home)
|
|
7678
|
+
if auth_env_var and _master_copilot_home
|
|
7679
|
+
else agent_core.copilot_recovery_home(_isolated_copilot_home)
|
|
7680
|
+
)
|
|
7681
|
+
if (
|
|
7682
|
+
not _retry_without_token_env
|
|
7683
|
+
and recovery_home
|
|
7684
|
+
and (recovery_home / "config.json").is_file()
|
|
7685
|
+
):
|
|
7686
|
+
restored = await asyncio.to_thread(
|
|
7687
|
+
agent_core.replace_copilot_auth_state,
|
|
7688
|
+
recovery_home,
|
|
7689
|
+
_isolated_copilot_home,
|
|
7690
|
+
)
|
|
7691
|
+
can_retry_with_login = bool(auth_env_var or restored)
|
|
7692
|
+
else:
|
|
7693
|
+
can_retry_with_login = False
|
|
7694
|
+
if can_retry_with_login:
|
|
7695
|
+
logger.warning(
|
|
7696
|
+
"Copilot token authentication failed before work for task %s; "
|
|
7697
|
+
"retrying once with stored login and token environment variables removed",
|
|
7698
|
+
task_id,
|
|
7699
|
+
)
|
|
7700
|
+
retry_result = await self._run_copilot(
|
|
7701
|
+
agent, prompt, cwd, timeout, task_id, on_chunk,
|
|
7702
|
+
stream_stderr=stream_stderr,
|
|
7703
|
+
disable_builtin_mcps=disable_builtin_mcps,
|
|
7704
|
+
_retry_without_effort=_retry_without_effort,
|
|
7705
|
+
_retry_without_token_env=True,
|
|
7706
|
+
_isolated_copilot_home=_isolated_copilot_home,
|
|
7707
|
+
_auth_state=_auth_state,
|
|
7708
|
+
)
|
|
7709
|
+
_auth_state["persist"] = retry_result.status == "success"
|
|
7710
|
+
retry_result.metrics["copilot_auth_recovery"] = True
|
|
7711
|
+
return retry_result
|
|
7712
|
+
_auth_state["persist"] = False
|
|
7713
|
+
|
|
7643
7714
|
if copilot_exit == 0 and returncode == 0:
|
|
7644
7715
|
return TaskResult(
|
|
7645
7716
|
status="success",
|
|
@@ -7737,6 +7808,12 @@ class ProcessManager:
|
|
|
7737
7808
|
failure_error = f"Copilot exited with code {effective_rc}: {stderr[-500:].strip()}"
|
|
7738
7809
|
else:
|
|
7739
7810
|
failure_error = f"Copilot exited with code {effective_rc}"
|
|
7811
|
+
if token_auth_error:
|
|
7812
|
+
auth_source = auth_env_var or "stored Copilot login"
|
|
7813
|
+
failure_error = (
|
|
7814
|
+
f"Copilot authentication failed using {auth_source}. "
|
|
7815
|
+
f"{failure_error}"
|
|
7816
|
+
)
|
|
7740
7817
|
logger.error(
|
|
7741
7818
|
"Copilot task %s failed (process_rc=%s, result_exit=%s): %s",
|
|
7742
7819
|
task_id,
|
|
@@ -7835,6 +7912,7 @@ class ProcessManager:
|
|
|
7835
7912
|
env: dict | None = None,
|
|
7836
7913
|
stream_stderr: bool = False,
|
|
7837
7914
|
) -> TaskResult:
|
|
7915
|
+
proc = None
|
|
7838
7916
|
try:
|
|
7839
7917
|
cmd = _prepare_agent_command(cmd)
|
|
7840
7918
|
proc = await asyncio.create_subprocess_exec(
|
|
@@ -7873,7 +7951,8 @@ class ProcessManager:
|
|
|
7873
7951
|
failure_code="agent_launch_not_found",
|
|
7874
7952
|
)
|
|
7875
7953
|
except asyncio.TimeoutError as exc:
|
|
7876
|
-
|
|
7954
|
+
if proc is not None:
|
|
7955
|
+
_kill_proc(proc)
|
|
7877
7956
|
_err = (
|
|
7878
7957
|
f"Agent produced no stdout, stderr, or workspace changes for "
|
|
7879
7958
|
f"{exc.idle_seconds:.0f}s — process terminated. Inspect retained "
|
|
@@ -7889,16 +7968,14 @@ class ProcessManager:
|
|
|
7889
7968
|
)
|
|
7890
7969
|
except Exception as exc:
|
|
7891
7970
|
logger.exception("CLI stream error for task %s", task_id)
|
|
7892
|
-
if task_id in self.active_processes:
|
|
7893
|
-
try:
|
|
7894
|
-
self.active_processes[task_id].kill()
|
|
7895
|
-
except Exception:
|
|
7896
|
-
pass
|
|
7897
7971
|
return TaskResult(
|
|
7898
7972
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
7899
7973
|
error=f"Stream processing error: {exc}",
|
|
7900
7974
|
)
|
|
7901
7975
|
finally:
|
|
7976
|
+
if proc is not None and proc.returncode is None:
|
|
7977
|
+
_kill_proc(proc)
|
|
7978
|
+
await proc.wait()
|
|
7902
7979
|
self.active_processes.pop(task_id, None)
|
|
7903
7980
|
|
|
7904
7981
|
def _parse_claude_output(self, stdout: str) -> dict:
|
|
@@ -10574,9 +10651,22 @@ class RuntimeDaemon:
|
|
|
10574
10651
|
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
|
10575
10652
|
) as response:
|
|
10576
10653
|
if not 200 <= response.status_code < 300:
|
|
10577
|
-
|
|
10578
|
-
|
|
10579
|
-
|
|
10654
|
+
error = f"Attachment '{attachment_name}' download failed with HTTP {response.status_code}"
|
|
10655
|
+
if response.status_code in {409, 502}:
|
|
10656
|
+
await response.aread()
|
|
10657
|
+
try:
|
|
10658
|
+
detail = response.json()
|
|
10659
|
+
except ValueError:
|
|
10660
|
+
detail = None
|
|
10661
|
+
if isinstance(detail, dict) and detail.get("detail") == "Task attachment integrity check failed":
|
|
10662
|
+
raise _TaskAttachmentDownloadError(error)
|
|
10663
|
+
if response.status_code == 409 and isinstance(detail, dict) and detail.get("detail") == "Task attachment source no longer matches stored checksum":
|
|
10664
|
+
raise _TaskAttachmentUnavailableError(
|
|
10665
|
+
f"Attachment '{attachment_name}' is unavailable: {detail['detail']}"
|
|
10666
|
+
)
|
|
10667
|
+
if response.status_code in {404, 502, 503, 504}:
|
|
10668
|
+
raise _TaskAttachmentUnavailableError(error)
|
|
10669
|
+
raise _TaskAttachmentDownloadError(error)
|
|
10580
10670
|
content_length = response.headers.get("content-length")
|
|
10581
10671
|
if content_length:
|
|
10582
10672
|
try:
|
|
@@ -10610,6 +10700,10 @@ class RuntimeDaemon:
|
|
|
10610
10700
|
temporary_path.replace(destination)
|
|
10611
10701
|
except _TaskAttachmentDownloadError:
|
|
10612
10702
|
raise
|
|
10703
|
+
except httpx.RequestError as exc:
|
|
10704
|
+
raise _TaskAttachmentUnavailableError(
|
|
10705
|
+
f"Attachment '{attachment_name}' download failed ({type(exc).__name__})"
|
|
10706
|
+
) from exc
|
|
10613
10707
|
except httpx.HTTPError as exc:
|
|
10614
10708
|
raise _TaskAttachmentDownloadError(
|
|
10615
10709
|
f"Attachment '{attachment_name}' download failed ({type(exc).__name__})"
|
|
@@ -10625,7 +10719,7 @@ class RuntimeDaemon:
|
|
|
10625
10719
|
timeout=timeout_seconds,
|
|
10626
10720
|
)
|
|
10627
10721
|
except asyncio.TimeoutError as exc:
|
|
10628
|
-
raise
|
|
10722
|
+
raise _TaskAttachmentUnavailableError(
|
|
10629
10723
|
f"Attachment '{attachment_name}' download timed out after "
|
|
10630
10724
|
f"{timeout_seconds:.0f}s"
|
|
10631
10725
|
) from exc
|
|
@@ -10648,6 +10742,7 @@ class RuntimeDaemon:
|
|
|
10648
10742
|
|
|
10649
10743
|
staging_dir = Path(tempfile.mkdtemp(prefix=f"forgexa-attachments-{task.task_id[:8]}-"))
|
|
10650
10744
|
staged: list[tuple[str, list[str], str]] = []
|
|
10745
|
+
unavailable: list[tuple[str, list[str], str]] = []
|
|
10651
10746
|
deadline = time.monotonic() + self._TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS
|
|
10652
10747
|
remaining_bytes = self._TASK_ATTACHMENT_MAX_TOTAL_BYTES
|
|
10653
10748
|
try:
|
|
@@ -10676,12 +10771,6 @@ class RuntimeDaemon:
|
|
|
10676
10771
|
raise _TaskAttachmentDownloadError(
|
|
10677
10772
|
f"Attachment '{file_name}' cannot be staged securely because it has no artifact ID"
|
|
10678
10773
|
)
|
|
10679
|
-
remaining_seconds = deadline - time.monotonic()
|
|
10680
|
-
if remaining_seconds <= 0:
|
|
10681
|
-
raise _TaskAttachmentDownloadError(
|
|
10682
|
-
f"Task attachment staging timed out after "
|
|
10683
|
-
f"{self._TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS}s"
|
|
10684
|
-
)
|
|
10685
10774
|
if not conn.runtime_id:
|
|
10686
10775
|
raise _TaskAttachmentDownloadError(
|
|
10687
10776
|
f"Attachment '{file_name}' cannot be staged because this runtime is not registered"
|
|
@@ -10690,16 +10779,27 @@ class RuntimeDaemon:
|
|
|
10690
10779
|
f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/"
|
|
10691
10780
|
f"tasks/{task.task_id}/attachments/{artifact_id}"
|
|
10692
10781
|
)
|
|
10693
|
-
|
|
10694
|
-
|
|
10695
|
-
|
|
10696
|
-
|
|
10697
|
-
|
|
10698
|
-
|
|
10699
|
-
|
|
10700
|
-
|
|
10701
|
-
|
|
10702
|
-
|
|
10782
|
+
try:
|
|
10783
|
+
remaining_seconds = deadline - time.monotonic()
|
|
10784
|
+
if remaining_seconds <= 0:
|
|
10785
|
+
raise _TaskAttachmentUnavailableError(
|
|
10786
|
+
f"Task attachment staging timed out after "
|
|
10787
|
+
f"{self._TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS}s"
|
|
10788
|
+
)
|
|
10789
|
+
await self._download_task_attachment(
|
|
10790
|
+
conn.client,
|
|
10791
|
+
source_url,
|
|
10792
|
+
destination,
|
|
10793
|
+
attachment_name=file_name,
|
|
10794
|
+
expected_size=expected_size or None,
|
|
10795
|
+
expected_checksum=str(attachment.get("checksum") or ""),
|
|
10796
|
+
max_bytes=min(self._TASK_ATTACHMENT_MAX_BYTES, remaining_bytes),
|
|
10797
|
+
timeout_seconds=remaining_seconds,
|
|
10798
|
+
)
|
|
10799
|
+
except _TaskAttachmentUnavailableError as exc:
|
|
10800
|
+
logger.warning("Task %s attachment unavailable: %s", task.task_id, exc)
|
|
10801
|
+
unavailable.append((file_name, download_url_aliases, str(exc)))
|
|
10802
|
+
continue
|
|
10703
10803
|
remaining_bytes -= destination.stat().st_size
|
|
10704
10804
|
staged.append((file_name, download_url_aliases, str(destination)))
|
|
10705
10805
|
except Exception:
|
|
@@ -10707,10 +10807,29 @@ class RuntimeDaemon:
|
|
|
10707
10807
|
raise
|
|
10708
10808
|
|
|
10709
10809
|
task.input_prompt = self._rewrite_staged_attachment_references(task.input_prompt, staged)
|
|
10810
|
+
if unavailable:
|
|
10811
|
+
notices = []
|
|
10812
|
+
for file_name, download_urls, error in unavailable:
|
|
10813
|
+
notices.append(f"- `{file_name}`: {error}")
|
|
10814
|
+
for download_url in download_urls:
|
|
10815
|
+
command_pattern = r"`curl -L '" + re.escape(download_url) + r"' -o /tmp/[^`]+`"
|
|
10816
|
+
task.input_prompt = re.sub(command_pattern, "[attachment unavailable]", task.input_prompt)
|
|
10817
|
+
task.input_prompt = task.input_prompt.replace(f"Download: {download_url}", "Attachment unavailable")
|
|
10818
|
+
task.input_prompt = task.input_prompt.replace(download_url, "[attachment unavailable]")
|
|
10819
|
+
task.input_prompt = "\n".join([
|
|
10820
|
+
"## Unavailable Attachments",
|
|
10821
|
+
"These files could not be retrieved. Continue with available inputs and report any missing evidence; do not retry their download URLs.",
|
|
10822
|
+
*notices,
|
|
10823
|
+
"",
|
|
10824
|
+
task.input_prompt,
|
|
10825
|
+
])
|
|
10710
10826
|
input_data = dict(task.input_data or {})
|
|
10711
10827
|
input_data["prompt"] = task.input_prompt
|
|
10712
10828
|
task.input_data = input_data
|
|
10713
10829
|
logger.info("Prepared %d attachment(s) for task %s", len(staged), task.task_id)
|
|
10830
|
+
if not staged:
|
|
10831
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
10832
|
+
return None
|
|
10714
10833
|
return staging_dir
|
|
10715
10834
|
|
|
10716
10835
|
@staticmethod
|
|
@@ -17608,6 +17727,41 @@ class RuntimeDaemon:
|
|
|
17608
17727
|
|
|
17609
17728
|
if remote_ahead:
|
|
17610
17729
|
remote_count = len(remote_ahead.splitlines())
|
|
17730
|
+
if (
|
|
17731
|
+
task is not None
|
|
17732
|
+
and task.workspace_resolution == "cloud_worktree"
|
|
17733
|
+
and task.node_type == "analysis"
|
|
17734
|
+
and task.input_data.get("analysis_mode") == "fresh"
|
|
17735
|
+
and task.requirement_key
|
|
17736
|
+
and branch == f"feature/{task.requirement_key}"
|
|
17737
|
+
):
|
|
17738
|
+
default_branch = task.project.get("default_branch") or "main"
|
|
17739
|
+
try:
|
|
17740
|
+
await git(
|
|
17741
|
+
"merge-base", "--is-ancestor", f"origin/{default_branch}",
|
|
17742
|
+
"HEAD", cwd=workspace_path,
|
|
17743
|
+
)
|
|
17744
|
+
if await self.workspace_manager._branch_has_impl_commits(
|
|
17745
|
+
workspace_path, branch, default_branch,
|
|
17746
|
+
):
|
|
17747
|
+
return (
|
|
17748
|
+
f"Push blocked: remote branch '{branch}' contains "
|
|
17749
|
+
"implementation commits; fresh analysis cannot replace it."
|
|
17750
|
+
)
|
|
17751
|
+
remote_sha = (await git(
|
|
17752
|
+
"rev-parse", "--verify", f"refs/remotes/origin/{branch}",
|
|
17753
|
+
cwd=workspace_path,
|
|
17754
|
+
)).strip()
|
|
17755
|
+
await git(
|
|
17756
|
+
"push", f"--force-with-lease=refs/heads/{branch}:{remote_sha}",
|
|
17757
|
+
"-u", "origin", branch, cwd=workspace_path,
|
|
17758
|
+
project_key=project_key, timeout=settings.GIT_PUSH_TIMEOUT,
|
|
17759
|
+
)
|
|
17760
|
+
logger.info("Pushed fresh analysis branch %s with exact lease", branch)
|
|
17761
|
+
return None
|
|
17762
|
+
except RuntimeError as exc:
|
|
17763
|
+
detail = self.workspace_manager._git_push_diagnostic(exc)
|
|
17764
|
+
return f"Fresh analysis push blocked for branch '{branch}': {detail}"
|
|
17611
17765
|
# Divergence detected — but this is expected after rebase.
|
|
17612
17766
|
# Check if the remote commits are already incorporated
|
|
17613
17767
|
# (i.e., their patches are empty against our branch, meaning
|
|
@@ -17815,8 +17969,8 @@ class RuntimeDaemon:
|
|
|
17815
17969
|
f"Push refused: remote branch '{branch}' has {remote_count} "
|
|
17816
17970
|
f"commit(s) not in local history, rebase (-X theirs) failed, "
|
|
17817
17971
|
f"and force-with-lease fallback also failed. "
|
|
17818
|
-
f"Rebase error
|
|
17819
|
-
f"Force error
|
|
17972
|
+
f"Rebase error:\n{self.workspace_manager._git_push_diagnostic(rebase_exc)}\n"
|
|
17973
|
+
f"Force error:\n{self.workspace_manager._git_push_diagnostic(force_exc)}"
|
|
17820
17974
|
)
|
|
17821
17975
|
|
|
17822
17976
|
logger.info("Found unpushed commits on %s, pushing...", branch)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|