forgexa-cli 1.67.1__tar.gz → 1.68.1__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.67.1 → forgexa_cli-1.68.1}/PKG-INFO +1 -1
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/agent_core.py +44 -12
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/aws_logs_evidence.py +3 -1
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/daemon.py +208 -42
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/pyproject.toml +1 -1
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/README.md +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/aws_evidence.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/setup.cfg +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/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.68.1"
|
|
@@ -3753,7 +3753,13 @@ def is_copilot_model_discovery_auth_error(*messages: str | None) -> bool:
|
|
|
3753
3753
|
def is_runtime_resource_exhaustion_error(*messages: str | None) -> bool:
|
|
3754
3754
|
"""Return whether the OS refused an agent process, thread, or file watch."""
|
|
3755
3755
|
detail = " ".join(str(message or "").lower() for message in messages)
|
|
3756
|
-
return
|
|
3756
|
+
return (
|
|
3757
|
+
any(pattern in detail for pattern in (
|
|
3758
|
+
"failed to spawn thread",
|
|
3759
|
+
"failed to start task transition watcher",
|
|
3760
|
+
))
|
|
3761
|
+
and "resource temporarily unavailable" in detail
|
|
3762
|
+
) or any(
|
|
3757
3763
|
pattern in detail
|
|
3758
3764
|
for pattern in (
|
|
3759
3765
|
"can't spawn worker thread",
|
|
@@ -3778,23 +3784,35 @@ def collect_runtime_resource_metrics(
|
|
|
3778
3784
|
return metrics
|
|
3779
3785
|
|
|
3780
3786
|
proc_path = Path(proc_root)
|
|
3781
|
-
cgroup_path = Path(cgroup_root)
|
|
3782
|
-
constraints: list[tuple[str, int, int]] = []
|
|
3787
|
+
cgroup_path = Path(cgroup_root).resolve()
|
|
3788
|
+
constraints: list[tuple[str, int, int, Path | None]] = []
|
|
3783
3789
|
|
|
3784
3790
|
try:
|
|
3785
3791
|
for line in (proc_path / "self" / "cgroup").read_text().splitlines():
|
|
3786
3792
|
hierarchy, controllers, relative_path = line.split(":", 2)
|
|
3787
3793
|
relative = relative_path.lstrip("/")
|
|
3788
3794
|
if hierarchy == "0" and not controllers:
|
|
3789
|
-
|
|
3795
|
+
controller_root = cgroup_path
|
|
3790
3796
|
elif "pids" in controllers.split(","):
|
|
3791
|
-
|
|
3797
|
+
controller_root = (cgroup_path / "pids").resolve()
|
|
3792
3798
|
else:
|
|
3793
3799
|
continue
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3800
|
+
pid_dir = (controller_root / relative).resolve()
|
|
3801
|
+
if not pid_dir.is_relative_to(controller_root):
|
|
3802
|
+
continue
|
|
3803
|
+
while True:
|
|
3804
|
+
try:
|
|
3805
|
+
current_text = (pid_dir / "pids.current").read_text().strip()
|
|
3806
|
+
maximum_text = (pid_dir / "pids.max").read_text().strip()
|
|
3807
|
+
if maximum_text != "max":
|
|
3808
|
+
constraints.append((
|
|
3809
|
+
"cgroup", int(current_text), int(maximum_text), pid_dir,
|
|
3810
|
+
))
|
|
3811
|
+
except (OSError, ValueError):
|
|
3812
|
+
pass
|
|
3813
|
+
if pid_dir == controller_root:
|
|
3814
|
+
break
|
|
3815
|
+
pid_dir = pid_dir.parent
|
|
3798
3816
|
break
|
|
3799
3817
|
except (OSError, ValueError):
|
|
3800
3818
|
pass
|
|
@@ -3819,12 +3837,12 @@ def collect_runtime_resource_metrics(
|
|
|
3819
3837
|
user_threads += threads
|
|
3820
3838
|
except (OSError, ValueError, IndexError):
|
|
3821
3839
|
continue
|
|
3822
|
-
constraints.append(("rlimit_nproc", user_threads, int(soft_limit)))
|
|
3840
|
+
constraints.append(("rlimit_nproc", user_threads, int(soft_limit), None))
|
|
3823
3841
|
except (ImportError, OSError, ValueError):
|
|
3824
3842
|
pass
|
|
3825
3843
|
|
|
3826
3844
|
if constraints:
|
|
3827
|
-
source, current, limit = min(
|
|
3845
|
+
source, current, limit, limit_path = min(
|
|
3828
3846
|
constraints,
|
|
3829
3847
|
key=lambda item: item[2] - item[1],
|
|
3830
3848
|
)
|
|
@@ -3834,6 +3852,20 @@ def collect_runtime_resource_metrics(
|
|
|
3834
3852
|
"process_limit": limit,
|
|
3835
3853
|
"process_remaining": max(0, limit - current),
|
|
3836
3854
|
})
|
|
3855
|
+
if limit_path is not None:
|
|
3856
|
+
metrics["process_limit_path"] = str(limit_path)
|
|
3857
|
+
try:
|
|
3858
|
+
for line in (limit_path / "pids.events").read_text().splitlines():
|
|
3859
|
+
name, value = line.split()
|
|
3860
|
+
if name == "max":
|
|
3861
|
+
metrics["process_limit_hits"] = int(value)
|
|
3862
|
+
break
|
|
3863
|
+
except (OSError, ValueError):
|
|
3864
|
+
pass
|
|
3865
|
+
try:
|
|
3866
|
+
metrics["process_peak"] = int((limit_path / "pids.peak").read_text().strip())
|
|
3867
|
+
except (OSError, ValueError):
|
|
3868
|
+
pass
|
|
3837
3869
|
|
|
3838
3870
|
try:
|
|
3839
3871
|
for line in (proc_path / "meminfo").read_text().splitlines():
|
|
@@ -3873,7 +3905,7 @@ def collect_runtime_resource_metrics(
|
|
|
3873
3905
|
def runtime_resource_exhaustion_reason(metrics: dict[str, Any]) -> str | None:
|
|
3874
3906
|
"""Return a blocking reason when the OS cannot safely start an agent."""
|
|
3875
3907
|
process_remaining = metrics.get("process_remaining")
|
|
3876
|
-
if isinstance(process_remaining, int) and process_remaining <
|
|
3908
|
+
if isinstance(process_remaining, int) and process_remaining < 64:
|
|
3877
3909
|
return (
|
|
3878
3910
|
f"Only {process_remaining} process/thread slots remain "
|
|
3879
3911
|
f"({metrics.get('process_current')}/{metrics.get('process_limit')} used via "
|
|
@@ -31,7 +31,7 @@ def select_attachment(evidence, event_ids, ranges, attachment_id):
|
|
|
31
31
|
available = {event["event_ref"]: event for event in evidence["events"]}
|
|
32
32
|
if len(event_ids) != len(set(event_ids)) or set(event_ids) - available.keys() or set(ranges) - set(event_ids):
|
|
33
33
|
raise ValueError("aws_logs_selection_invalid")
|
|
34
|
-
if not event_ids
|
|
34
|
+
if not event_ids:
|
|
35
35
|
raise ValueError("aws_logs_selection_required")
|
|
36
36
|
selected = []
|
|
37
37
|
for event_id in event_ids:
|
|
@@ -114,6 +114,8 @@ def validate_payload(reference, metadata, payload):
|
|
|
114
114
|
events = evidence.get("events")
|
|
115
115
|
if not isinstance(sources, list) or not 1 <= len(sources) <= 4 or not isinstance(events, list) or len(events) > 1000:
|
|
116
116
|
raise ValueError("aws_evidence_size_limit")
|
|
117
|
+
if not events:
|
|
118
|
+
raise ValueError("aws_logs_selection_required")
|
|
117
119
|
identities = set()
|
|
118
120
|
for source in sources:
|
|
119
121
|
if not isinstance(source, dict) or source.get("source_ref") in identities or not re.fullmatch(r"AWS-L1-S\d+", str(source.get("source_ref", ""))):
|
|
@@ -1234,7 +1234,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1234
1234
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1235
1235
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1236
1236
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1237
|
-
DAEMON_VERSION = "1.
|
|
1237
|
+
DAEMON_VERSION = "1.68.1"
|
|
1238
1238
|
|
|
1239
1239
|
|
|
1240
1240
|
def _detect_client_type() -> str:
|
|
@@ -1624,6 +1624,7 @@ class TaskInfo:
|
|
|
1624
1624
|
runtime_evidence_plan: dict | None = None
|
|
1625
1625
|
local_evidence_upload: bool = False
|
|
1626
1626
|
critic_review_required: bool = False
|
|
1627
|
+
local_untracked_paths: set[str] = field(default_factory=set, repr=False)
|
|
1627
1628
|
|
|
1628
1629
|
|
|
1629
1630
|
@dataclass
|
|
@@ -2612,6 +2613,8 @@ def _read_local_bind_shared_output_snapshot(task_id: str) -> dict | None:
|
|
|
2612
2613
|
start = max(total_bytes - _LOCAL_BIND_SHARED_OUTPUT_MAX_BYTES, 0)
|
|
2613
2614
|
output_file.seek(start)
|
|
2614
2615
|
raw = output_file.read()
|
|
2616
|
+
except FileNotFoundError:
|
|
2617
|
+
return None
|
|
2615
2618
|
except OSError as exc:
|
|
2616
2619
|
logger.warning("Failed to read local-bind output log for task %s: %s", task_id, exc)
|
|
2617
2620
|
return None
|
|
@@ -3650,8 +3653,9 @@ class WorkspaceManager:
|
|
|
3650
3653
|
"""Resolve the workspace path for a workspace_resolution="local_bind" task.
|
|
3651
3654
|
|
|
3652
3655
|
Validates the daemon-private mapping entry (binding_id -> local_path),
|
|
3653
|
-
the directory's git remote identity, and
|
|
3654
|
-
|
|
3656
|
+
the directory's git remote identity, branch, and tracked changes.
|
|
3657
|
+
Pre-existing untracked and ignored files remain user-owned and local.
|
|
3658
|
+
Ordinary local-bind tasks perform NO destructive git operation
|
|
3655
3659
|
(no reset --hard, clean, branch deletion, or implicit branch switch)
|
|
3656
3660
|
on the user's real directory. The only exception is an explicitly
|
|
3657
3661
|
authorized fresh-analysis reset, which has its own server marker,
|
|
@@ -3739,16 +3743,25 @@ class WorkspaceManager:
|
|
|
3739
3743
|
"will never switch branches in your real working directory automatically."
|
|
3740
3744
|
)
|
|
3741
3745
|
|
|
3742
|
-
status_output = await self._git(
|
|
3743
|
-
|
|
3744
|
-
|
|
3746
|
+
status_output = await self._git(
|
|
3747
|
+
"status", "--porcelain=v1", "--untracked-files=no", "-z", cwd=local_path,
|
|
3748
|
+
)
|
|
3749
|
+
dirty_paths = _git_status_paths(status_output)
|
|
3750
|
+
if dirty_paths:
|
|
3745
3751
|
shown = ", ".join(dirty_paths[:20])
|
|
3746
3752
|
more = f" (+{len(dirty_paths) - 20} more)" if len(dirty_paths) > 20 else ""
|
|
3747
3753
|
raise RuntimeError(
|
|
3748
|
-
"local_binding_unavailable: local directory has uncommitted changes,
|
|
3749
|
-
f"to
|
|
3754
|
+
"local_binding_unavailable: local directory has uncommitted tracked changes, "
|
|
3755
|
+
f"refusing to overwrite user work: {shown}{more}. Commit or stash these "
|
|
3756
|
+
"changes, then retry; unrelated untracked files do not need to be deleted."
|
|
3750
3757
|
)
|
|
3751
3758
|
|
|
3759
|
+
task.local_untracked_paths = {
|
|
3760
|
+
path for path in (await self._git(
|
|
3761
|
+
"ls-files", "--others", "-z", cwd=local_path,
|
|
3762
|
+
)).split("\0") if path
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3752
3765
|
try:
|
|
3753
3766
|
remote_branch = await self._git(
|
|
3754
3767
|
"ls-remote", "--heads", "origin", f"refs/heads/{target_branch}",
|
|
@@ -3784,8 +3797,12 @@ class WorkspaceManager:
|
|
|
3784
3797
|
await self._git("rev-parse", f"origin/{target_branch}", cwd=local_path)
|
|
3785
3798
|
).strip()
|
|
3786
3799
|
if local_head != remote_head:
|
|
3800
|
+
await self._check_local_untracked_overwrites(local_path, remote_head)
|
|
3787
3801
|
try:
|
|
3788
|
-
await self._git(
|
|
3802
|
+
await self._git(
|
|
3803
|
+
"merge", "--ff-only", "--no-overwrite-ignore",
|
|
3804
|
+
f"origin/{target_branch}", cwd=local_path,
|
|
3805
|
+
)
|
|
3789
3806
|
except RuntimeError as exc:
|
|
3790
3807
|
raise RuntimeError(
|
|
3791
3808
|
f"local_binding_unavailable: local branch '{target_branch}' cannot "
|
|
@@ -3866,6 +3883,33 @@ class WorkspaceManager:
|
|
|
3866
3883
|
|
|
3867
3884
|
return local_path
|
|
3868
3885
|
|
|
3886
|
+
async def _check_local_untracked_overwrites(self, local_path: Path, revision: str) -> None:
|
|
3887
|
+
untracked_paths = {
|
|
3888
|
+
path for path in (await self._git(
|
|
3889
|
+
"ls-files", "--others", "-z", cwd=local_path,
|
|
3890
|
+
)).split("\0") if path
|
|
3891
|
+
}
|
|
3892
|
+
if not untracked_paths:
|
|
3893
|
+
return
|
|
3894
|
+
target_paths = {
|
|
3895
|
+
path for path in (await self._git(
|
|
3896
|
+
"ls-tree", "-r", "--name-only", "-z", revision, cwd=local_path,
|
|
3897
|
+
)).split("\0") if path
|
|
3898
|
+
}
|
|
3899
|
+
conflicts = {
|
|
3900
|
+
path for path in target_paths if _is_preserved_local_path(path, untracked_paths)
|
|
3901
|
+
}
|
|
3902
|
+
conflicts.update(
|
|
3903
|
+
path.rstrip("/") for path in untracked_paths
|
|
3904
|
+
if _is_preserved_local_path(path.rstrip("/"), target_paths)
|
|
3905
|
+
)
|
|
3906
|
+
if conflicts:
|
|
3907
|
+
raise RuntimeError(
|
|
3908
|
+
"local_binding_unavailable: branch update would overwrite untracked or "
|
|
3909
|
+
"ignored local files; no branch was updated. Preserve the conflicting "
|
|
3910
|
+
f"files, then retry: {', '.join(sorted(conflicts)[:20])}"
|
|
3911
|
+
)
|
|
3912
|
+
|
|
3869
3913
|
async def _reset_authorized_local_fresh_branch(
|
|
3870
3914
|
self,
|
|
3871
3915
|
local_path: Path,
|
|
@@ -3903,6 +3947,18 @@ class WorkspaceManager:
|
|
|
3903
3947
|
)
|
|
3904
3948
|
return
|
|
3905
3949
|
|
|
3950
|
+
await self._check_local_untracked_overwrites(local_path, default_sha)
|
|
3951
|
+
try:
|
|
3952
|
+
await git(
|
|
3953
|
+
"read-tree", "--dry-run", "-m", "-u", "HEAD", default_sha,
|
|
3954
|
+
cwd=local_path,
|
|
3955
|
+
)
|
|
3956
|
+
except RuntimeError as exc:
|
|
3957
|
+
raise RuntimeError(
|
|
3958
|
+
"local_binding_unavailable: local fresh branch reset would overwrite local "
|
|
3959
|
+
f"files; no branch was reset. Preserve the conflicting files, then retry: {exc}"
|
|
3960
|
+
) from exc
|
|
3961
|
+
|
|
3906
3962
|
backup_tag = f"forgexa/fresh-analysis-backup/{task_id}"
|
|
3907
3963
|
await git("check-ref-format", f"refs/tags/{backup_tag}", cwd=local_path)
|
|
3908
3964
|
remote_tag = await git(
|
|
@@ -3948,7 +4004,8 @@ class WorkspaceManager:
|
|
|
3948
4004
|
) from exc
|
|
3949
4005
|
|
|
3950
4006
|
try:
|
|
3951
|
-
await
|
|
4007
|
+
await self._check_local_untracked_overwrites(local_path, default_sha)
|
|
4008
|
+
await git("reset", "--keep", default_sha, cwd=local_path)
|
|
3952
4009
|
except RuntimeError as exc:
|
|
3953
4010
|
try:
|
|
3954
4011
|
await git(
|
|
@@ -6563,6 +6620,18 @@ class ProcessManager:
|
|
|
6563
6620
|
"Fix ALL listed issues. Focus on the specific problems above."
|
|
6564
6621
|
)
|
|
6565
6622
|
|
|
6623
|
+
if task.workspace_resolution == "local_bind":
|
|
6624
|
+
prompt += (
|
|
6625
|
+
"\n\n## Local Workspace Safety\n"
|
|
6626
|
+
"This is the user's real working directory. Pre-existing untracked and "
|
|
6627
|
+
"ignored files are user-owned, not task output. Preserve them: do not delete, "
|
|
6628
|
+
"overwrite, stage, or commit them. Modify only tracked task files or create "
|
|
6629
|
+
"new task files. If the task requires changing a pre-existing untracked "
|
|
6630
|
+
"file, report the conflict instead of overwriting it. Never run git clean, "
|
|
6631
|
+
"git reset --hard, force-push, or blanket staging such as git add -A. "
|
|
6632
|
+
"Leave committing and pushing task changes to Forgexa."
|
|
6633
|
+
)
|
|
6634
|
+
|
|
6566
6635
|
if workspace_path is not None:
|
|
6567
6636
|
return agent_core.with_workspace_environment_guidance(
|
|
6568
6637
|
prompt, workspace_path,
|
|
@@ -7906,12 +7975,15 @@ class ProcessManager:
|
|
|
7906
7975
|
# CREATE_NO_WINDOW covers this secondary check so Copilot always sees a fully
|
|
7907
7976
|
# headless environment and processes the complete task specification.
|
|
7908
7977
|
proc: asyncio.subprocess.Process | None = None
|
|
7978
|
+
cleaned_process: asyncio.subprocess.Process | None = None
|
|
7909
7979
|
|
|
7910
7980
|
async def _terminate_copilot_process(
|
|
7911
7981
|
process: asyncio.subprocess.Process | None,
|
|
7912
7982
|
) -> None:
|
|
7913
|
-
|
|
7983
|
+
nonlocal cleaned_process
|
|
7984
|
+
if process is None or process is cleaned_process:
|
|
7914
7985
|
return
|
|
7986
|
+
cleaned_process = process
|
|
7915
7987
|
_kill_proc(process)
|
|
7916
7988
|
try:
|
|
7917
7989
|
await asyncio.wait_for(process.wait(), timeout=5)
|
|
@@ -7919,6 +7991,7 @@ class ProcessManager:
|
|
|
7919
7991
|
pass
|
|
7920
7992
|
|
|
7921
7993
|
try:
|
|
7994
|
+
resources_before = agent_core.collect_runtime_resource_metrics()
|
|
7922
7995
|
proc = await _create_subprocess_exec(
|
|
7923
7996
|
*cmd,
|
|
7924
7997
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -7947,6 +8020,15 @@ class ProcessManager:
|
|
|
7947
8020
|
# from the JSONL "result" event for a true success signal.
|
|
7948
8021
|
copilot_exit = self._extract_copilot_exit_code(stdout)
|
|
7949
8022
|
signals = self._extract_output_signals(stdout)
|
|
8023
|
+
resource_exhausted = agent_core.is_runtime_resource_exhaustion_error(
|
|
8024
|
+
stderr, *(signals.get("error_messages") or []),
|
|
8025
|
+
)
|
|
8026
|
+
if resource_exhausted:
|
|
8027
|
+
metrics["runtime_resources_before"] = resources_before
|
|
8028
|
+
metrics["runtime_resources_at_failure"] = (
|
|
8029
|
+
agent_core.collect_runtime_resource_metrics()
|
|
8030
|
+
)
|
|
8031
|
+
await _terminate_copilot_process(proc)
|
|
7950
8032
|
completed_without_result = bool(
|
|
7951
8033
|
signals["has_turn_completed"]
|
|
7952
8034
|
and signals["has_assistant_events"]
|
|
@@ -8226,10 +8308,7 @@ class ProcessManager:
|
|
|
8226
8308
|
error=failure_error,
|
|
8227
8309
|
failure_code=(
|
|
8228
8310
|
"runtime_resource_exhausted"
|
|
8229
|
-
if
|
|
8230
|
-
failure_error,
|
|
8231
|
-
stderr,
|
|
8232
|
-
)
|
|
8311
|
+
if resource_exhausted
|
|
8233
8312
|
else ""
|
|
8234
8313
|
),
|
|
8235
8314
|
metrics=metrics,
|
|
@@ -8278,6 +8357,7 @@ class ProcessManager:
|
|
|
8278
8357
|
error=f"Stream processing error: {exc}",
|
|
8279
8358
|
)
|
|
8280
8359
|
finally:
|
|
8360
|
+
await _terminate_copilot_process(proc)
|
|
8281
8361
|
self.active_processes.pop(task_id, None)
|
|
8282
8362
|
if _is_outer_call and _isolated_copilot_home:
|
|
8283
8363
|
if _master_copilot_home and _auth_state["persist"]:
|
|
@@ -10401,9 +10481,9 @@ def _testing_intents_have_e2e(workspace_path: Path, input_data: dict[str, Any])
|
|
|
10401
10481
|
_QA_TEST_LEVELS = frozenset({"unit", "integration", "e2e"})
|
|
10402
10482
|
|
|
10403
10483
|
|
|
10404
|
-
def
|
|
10405
|
-
"""Extract
|
|
10406
|
-
|
|
10484
|
+
def _git_status_entries(status_output: str) -> list[tuple[str, str]]:
|
|
10485
|
+
"""Extract status/path pairs from porcelain-v1 `-z` output."""
|
|
10486
|
+
entries: list[tuple[str, str]] = []
|
|
10407
10487
|
records = status_output.split("\0")
|
|
10408
10488
|
index = 0
|
|
10409
10489
|
while index < len(records):
|
|
@@ -10411,12 +10491,35 @@ def _git_status_paths(status_output: str) -> list[str]:
|
|
|
10411
10491
|
index += 1
|
|
10412
10492
|
if len(record) < 4 or record[2] != " ":
|
|
10413
10493
|
continue
|
|
10414
|
-
|
|
10494
|
+
entries.append((record[:2], record[3:]))
|
|
10415
10495
|
if "R" in record[:2] or "C" in record[:2]:
|
|
10416
10496
|
if index < len(records) and records[index]:
|
|
10417
|
-
|
|
10497
|
+
entries.append((record[:2], records[index]))
|
|
10418
10498
|
index += 1
|
|
10419
|
-
return
|
|
10499
|
+
return entries
|
|
10500
|
+
|
|
10501
|
+
|
|
10502
|
+
def _git_status_paths(status_output: str) -> list[str]:
|
|
10503
|
+
"""Extract changed paths from porcelain-v1 `-z` output."""
|
|
10504
|
+
return [path for _status, path in _git_status_entries(status_output)]
|
|
10505
|
+
|
|
10506
|
+
|
|
10507
|
+
def _is_preserved_local_path(path: str, preserved_paths: set[str]) -> bool:
|
|
10508
|
+
if path in preserved_paths or path + "/" in preserved_paths:
|
|
10509
|
+
return True
|
|
10510
|
+
prefix = ""
|
|
10511
|
+
for component in path.split("/")[:-1]:
|
|
10512
|
+
prefix += component + "/"
|
|
10513
|
+
if prefix in preserved_paths or prefix[:-1] in preserved_paths:
|
|
10514
|
+
return True
|
|
10515
|
+
return False
|
|
10516
|
+
|
|
10517
|
+
|
|
10518
|
+
def _local_bind_changed_paths(status_output: str, preserved_paths: set[str]) -> list[str]:
|
|
10519
|
+
return [
|
|
10520
|
+
path for status, path in _git_status_entries(status_output)
|
|
10521
|
+
if status != "??" or not _is_preserved_local_path(path, preserved_paths)
|
|
10522
|
+
]
|
|
10420
10523
|
|
|
10421
10524
|
|
|
10422
10525
|
def _qa_test_level_from_source_path(path: object) -> str | None:
|
|
@@ -12247,6 +12350,8 @@ class RuntimeDaemon:
|
|
|
12247
12350
|
is_local_bind = task.workspace_resolution == "local_bind"
|
|
12248
12351
|
async def report_task_complete(result: TaskResult) -> None:
|
|
12249
12352
|
if is_local_bind:
|
|
12353
|
+
if result.error:
|
|
12354
|
+
_append_local_bind_output_log(task.task_id, [f"[daemon] {result.error}"])
|
|
12250
12355
|
_prepare_local_bind_completion(task, result)
|
|
12251
12356
|
if self.managed_policy is not None and not await self.managed_policy.finish_task():
|
|
12252
12357
|
result.status = "failed"
|
|
@@ -13206,6 +13311,9 @@ class RuntimeDaemon:
|
|
|
13206
13311
|
logger.info("Task %s completed: %s (agent=%s)", task.task_id, result.status, agent.agent_id)
|
|
13207
13312
|
|
|
13208
13313
|
except Exception as e:
|
|
13314
|
+
failure_code = str(getattr(e, "code", "") or "")
|
|
13315
|
+
if is_local_bind and str(e).startswith("local_binding_unavailable:"):
|
|
13316
|
+
failure_code = "local_binding_unavailable"
|
|
13209
13317
|
if self.managed_policy is not None:
|
|
13210
13318
|
_append_local_bind_output_log(task.task_id, [str(e)])
|
|
13211
13319
|
logger.warning("Managed local task failed: %s", getattr(e, "code", "local_task_failed"))
|
|
@@ -13214,6 +13322,7 @@ class RuntimeDaemon:
|
|
|
13214
13322
|
await report_task_complete(TaskResult(
|
|
13215
13323
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
13216
13324
|
error=str(e),
|
|
13325
|
+
failure_code=failure_code,
|
|
13217
13326
|
execution_attempt_id=task.execution_attempt_id,
|
|
13218
13327
|
))
|
|
13219
13328
|
finally:
|
|
@@ -15405,12 +15514,18 @@ class RuntimeDaemon:
|
|
|
15405
15514
|
require_source_inventory = _qa_source_test_validation_required(
|
|
15406
15515
|
(task.input_data or {}).get("qa_test_level_policy_snapshot")
|
|
15407
15516
|
)
|
|
15517
|
+
preserved_paths = (
|
|
15518
|
+
getattr(task, "local_untracked_paths", None)
|
|
15519
|
+
if getattr(task, "workspace_resolution", None) == "local_bind"
|
|
15520
|
+
else None
|
|
15521
|
+
)
|
|
15408
15522
|
for attempt in range(max_retries):
|
|
15409
15523
|
inventory_issues = await self._refresh_validation_changed_paths(
|
|
15410
15524
|
workspace_path,
|
|
15411
15525
|
result,
|
|
15412
15526
|
default_branch=default_branch,
|
|
15413
15527
|
before_sha=before_sha,
|
|
15528
|
+
preserved_paths=preserved_paths,
|
|
15414
15529
|
)
|
|
15415
15530
|
issues = self._validate_outputs(workspace_path, task, result)
|
|
15416
15531
|
if require_source_inventory:
|
|
@@ -15534,6 +15649,7 @@ class RuntimeDaemon:
|
|
|
15534
15649
|
result,
|
|
15535
15650
|
default_branch=default_branch,
|
|
15536
15651
|
before_sha=before_sha,
|
|
15652
|
+
preserved_paths=preserved_paths,
|
|
15537
15653
|
)
|
|
15538
15654
|
remaining = self._validate_outputs(workspace_path, task, result)
|
|
15539
15655
|
if require_source_inventory:
|
|
@@ -15628,6 +15744,7 @@ class RuntimeDaemon:
|
|
|
15628
15744
|
*,
|
|
15629
15745
|
default_branch: str,
|
|
15630
15746
|
before_sha: str,
|
|
15747
|
+
preserved_paths: set[str] | None = None,
|
|
15631
15748
|
) -> list[str]:
|
|
15632
15749
|
"""Merge reported, working-tree, and already-committed task paths."""
|
|
15633
15750
|
reported_paths = ProcessManager._normalize_repo_paths(result.files_changed)
|
|
@@ -15643,7 +15760,7 @@ class RuntimeDaemon:
|
|
|
15643
15760
|
cwd=workspace_path,
|
|
15644
15761
|
)
|
|
15645
15762
|
paths.update(ProcessManager._normalize_repo_paths(
|
|
15646
|
-
|
|
15763
|
+
_local_bind_changed_paths(status_output, preserved_paths or set())
|
|
15647
15764
|
))
|
|
15648
15765
|
collected_paths = True
|
|
15649
15766
|
except Exception:
|
|
@@ -15996,6 +16113,8 @@ class RuntimeDaemon:
|
|
|
15996
16113
|
async def _discard_transient_untracked_runtime_files(
|
|
15997
16114
|
self,
|
|
15998
16115
|
workspace_path: Path,
|
|
16116
|
+
*,
|
|
16117
|
+
preserved_paths: set[str] | None = None,
|
|
15999
16118
|
) -> list[str]:
|
|
16000
16119
|
"""Discard known untracked test-run metadata before automatic staging."""
|
|
16001
16120
|
git = self.workspace_manager._git
|
|
@@ -16015,6 +16134,8 @@ class RuntimeDaemon:
|
|
|
16015
16134
|
if not line.startswith("?? "):
|
|
16016
16135
|
continue
|
|
16017
16136
|
relative_path = line[3:].replace("\\", "/").lstrip("./")
|
|
16137
|
+
if _is_preserved_local_path(relative_path, preserved_paths or set()):
|
|
16138
|
+
continue
|
|
16018
16139
|
if relative_path not in _TRANSIENT_UNTRACKED_RUNTIME_PATHS:
|
|
16019
16140
|
continue
|
|
16020
16141
|
candidate = workspace_path / relative_path
|
|
@@ -16115,12 +16236,12 @@ class RuntimeDaemon:
|
|
|
16115
16236
|
before staging and again before pushing: if the user switched
|
|
16116
16237
|
branches while the agent ran, the task fails closed instead of
|
|
16117
16238
|
committing/pushing the changes onto the wrong branch
|
|
16118
|
-
|
|
16119
|
-
|
|
16120
|
-
|
|
16121
|
-
|
|
16122
|
-
|
|
16123
|
-
|
|
16239
|
+
- preserves the startup inventory of user-owned untracked/ignored
|
|
16240
|
+
files and excludes it from staging and convergence checks; if the
|
|
16241
|
+
agent stages or commits those files, refuses to push
|
|
16242
|
+
- refuses to commit/push task paths that look like secrets (.env,
|
|
16243
|
+
private keys, etc.), including paths committed by the agent itself
|
|
16244
|
+
since before_sha; untouched pre-existing private files stay local
|
|
16124
16245
|
- never rebases, merges, resets, or force-pushes; a non-fast-forward
|
|
16125
16246
|
push failure just fails the task closed, no automatic recovery
|
|
16126
16247
|
Returns a dict with commit/push status info (empty dict on success),
|
|
@@ -16162,9 +16283,26 @@ class RuntimeDaemon:
|
|
|
16162
16283
|
"from HEAD. Review the branch history yourself before retrying."
|
|
16163
16284
|
)}
|
|
16164
16285
|
|
|
16165
|
-
|
|
16166
|
-
|
|
16167
|
-
|
|
16286
|
+
if task.local_untracked_paths and not before_sha:
|
|
16287
|
+
return {"commit_error": "Refusing to commit: the local task baseline is unavailable"}
|
|
16288
|
+
|
|
16289
|
+
await self._discard_transient_untracked_runtime_files(
|
|
16290
|
+
workspace_path, preserved_paths=task.local_untracked_paths,
|
|
16291
|
+
)
|
|
16292
|
+
status_output = await git(
|
|
16293
|
+
"status", "--porcelain=v1", "--untracked-files=all", "-z", cwd=workspace_path,
|
|
16294
|
+
)
|
|
16295
|
+
dirty_paths = _local_bind_changed_paths(status_output, task.local_untracked_paths)
|
|
16296
|
+
preserved_dirty = [
|
|
16297
|
+
path for path in dirty_paths
|
|
16298
|
+
if _is_preserved_local_path(path, task.local_untracked_paths)
|
|
16299
|
+
]
|
|
16300
|
+
if preserved_dirty:
|
|
16301
|
+
return {"commit_error": (
|
|
16302
|
+
"Refusing to commit: pre-existing user-owned local files were staged ("
|
|
16303
|
+
+ ", ".join(preserved_dirty[:20])
|
|
16304
|
+
+ "). Not pushed; unstage these files and review the task changes."
|
|
16305
|
+
)}
|
|
16168
16306
|
|
|
16169
16307
|
sensitive_dirty = [p for p in dirty_paths if _is_sensitive_local_path(p)]
|
|
16170
16308
|
if sensitive_dirty:
|
|
@@ -16175,7 +16313,19 @@ class RuntimeDaemon:
|
|
|
16175
16313
|
)}
|
|
16176
16314
|
|
|
16177
16315
|
if dirty_paths:
|
|
16178
|
-
await git("add", "-
|
|
16316
|
+
await git("add", "-u", cwd=workspace_path)
|
|
16317
|
+
new_paths = [
|
|
16318
|
+
path for status, path in _git_status_entries(status_output)
|
|
16319
|
+
if status == "??" and not _is_preserved_local_path(
|
|
16320
|
+
path, task.local_untracked_paths,
|
|
16321
|
+
)
|
|
16322
|
+
]
|
|
16323
|
+
for offset in range(0, len(new_paths), 100):
|
|
16324
|
+
await git(
|
|
16325
|
+
"add", "--",
|
|
16326
|
+
*(f":(literal){path}" for path in new_paths[offset:offset + 100]),
|
|
16327
|
+
cwd=workspace_path,
|
|
16328
|
+
)
|
|
16179
16329
|
|
|
16180
16330
|
mode_change_error = await self._pure_file_mode_change_error(
|
|
16181
16331
|
workspace_path, "--cached"
|
|
@@ -16243,15 +16393,27 @@ class RuntimeDaemon:
|
|
|
16243
16393
|
# every path touched since before_sha for sensitive content too,
|
|
16244
16394
|
# not just what we just staged ourselves.
|
|
16245
16395
|
if before_sha:
|
|
16246
|
-
|
|
16396
|
+
committed_paths: set[str] = set()
|
|
16397
|
+
commits = await git("rev-list", f"{before_sha}..HEAD", cwd=workspace_path)
|
|
16398
|
+
for commit_sha in commits.splitlines():
|
|
16247
16399
|
changed_since_before = await git(
|
|
16248
|
-
"diff", "--name-only",
|
|
16400
|
+
"diff-tree", "--no-commit-id", "--name-only", "--no-renames",
|
|
16401
|
+
"-r", "-m", "-z", commit_sha, cwd=workspace_path,
|
|
16249
16402
|
)
|
|
16250
|
-
|
|
16251
|
-
|
|
16403
|
+
committed_paths.update(path for path in changed_since_before.split("\0") if path)
|
|
16404
|
+
preserved_committed = sorted(
|
|
16405
|
+
path for path in committed_paths
|
|
16406
|
+
if _is_preserved_local_path(path, task.local_untracked_paths)
|
|
16407
|
+
)
|
|
16408
|
+
if preserved_committed:
|
|
16409
|
+
return {"commit_error": (
|
|
16410
|
+
"Refusing to push: commit(s) made during this task include pre-existing "
|
|
16411
|
+
"user-owned local files ("
|
|
16412
|
+
+ ", ".join(preserved_committed[:20])
|
|
16413
|
+
+ "). Not pushed; inspect and fix the commit(s) yourself."
|
|
16414
|
+
)}
|
|
16252
16415
|
agent_sensitive = [
|
|
16253
|
-
|
|
16254
|
-
if p.strip() and _is_sensitive_local_path(p)
|
|
16416
|
+
path for path in sorted(committed_paths) if _is_sensitive_local_path(path)
|
|
16255
16417
|
]
|
|
16256
16418
|
if agent_sensitive:
|
|
16257
16419
|
return {"commit_error": (
|
|
@@ -16261,8 +16423,10 @@ class RuntimeDaemon:
|
|
|
16261
16423
|
+ "). Not pushed — inspect and fix the commit(s) yourself."
|
|
16262
16424
|
)}
|
|
16263
16425
|
|
|
16264
|
-
post_commit_status = await git(
|
|
16265
|
-
|
|
16426
|
+
post_commit_status = await git(
|
|
16427
|
+
"status", "--porcelain=v1", "--untracked-files=all", "-z", cwd=workspace_path,
|
|
16428
|
+
)
|
|
16429
|
+
if _local_bind_changed_paths(post_commit_status, task.local_untracked_paths):
|
|
16266
16430
|
if _post_commit_sync_attempts_remaining > 0:
|
|
16267
16431
|
logger.warning(
|
|
16268
16432
|
"local_bind: workspace changed after commit in %s; "
|
|
@@ -16317,8 +16481,10 @@ class RuntimeDaemon:
|
|
|
16317
16481
|
"yourself (e.g. pull/merge), then retry."
|
|
16318
16482
|
)}
|
|
16319
16483
|
|
|
16320
|
-
post_push_status = await git(
|
|
16321
|
-
|
|
16484
|
+
post_push_status = await git(
|
|
16485
|
+
"status", "--porcelain=v1", "--untracked-files=all", "-z", cwd=workspace_path,
|
|
16486
|
+
)
|
|
16487
|
+
if not _local_bind_changed_paths(post_push_status, task.local_untracked_paths):
|
|
16322
16488
|
return {}
|
|
16323
16489
|
|
|
16324
16490
|
if _post_commit_sync_attempts_remaining > 0:
|
|
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
|
|
File without changes
|