forgexa-cli 1.68.0__tar.gz → 1.68.2__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.68.0 → forgexa_cli-1.68.2}/PKG-INFO +1 -1
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli/daemon.py +321 -39
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/pyproject.toml +1 -1
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/README.md +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli/aws_evidence.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli/aws_logs_evidence.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/setup.cfg +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.68.0 → forgexa_cli-1.68.2}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.68.
|
|
2
|
+
__version__ = "1.68.2"
|
|
@@ -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.68.
|
|
1237
|
+
DAEMON_VERSION = "1.68.2"
|
|
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,11 @@ 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
|
+
Uncommitted task-owned requirement artifacts are backed up locally
|
|
3659
|
+
before restoring their committed baseline. User code is never reset.
|
|
3660
|
+
Ordinary local-bind tasks perform NO destructive git operation
|
|
3655
3661
|
(no reset --hard, clean, branch deletion, or implicit branch switch)
|
|
3656
3662
|
on the user's real directory. The only exception is an explicitly
|
|
3657
3663
|
authorized fresh-analysis reset, which has its own server marker,
|
|
@@ -3739,16 +3745,30 @@ class WorkspaceManager:
|
|
|
3739
3745
|
"will never switch branches in your real working directory automatically."
|
|
3740
3746
|
)
|
|
3741
3747
|
|
|
3742
|
-
status_output = await self._git(
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3748
|
+
status_output = await self._git(
|
|
3749
|
+
"status", "--porcelain=v1", "--untracked-files=no", "-z", cwd=local_path,
|
|
3750
|
+
)
|
|
3751
|
+
dirty_paths = _git_status_paths(status_output)
|
|
3752
|
+
recoverable_paths = self._local_requirement_artifact_paths(task, local_path)
|
|
3753
|
+
user_dirty_paths = [
|
|
3754
|
+
path for path in dirty_paths
|
|
3755
|
+
if path not in recoverable_paths or not path.startswith("docs/requirements/")
|
|
3756
|
+
]
|
|
3757
|
+
if user_dirty_paths:
|
|
3758
|
+
shown = ", ".join(user_dirty_paths[:20])
|
|
3759
|
+
more = f" (+{len(user_dirty_paths) - 20} more)" if len(user_dirty_paths) > 20 else ""
|
|
3747
3760
|
raise RuntimeError(
|
|
3748
|
-
"local_binding_unavailable: local directory has uncommitted changes,
|
|
3749
|
-
f"to
|
|
3761
|
+
"local_binding_unavailable: local directory has uncommitted tracked changes, "
|
|
3762
|
+
f"refusing to overwrite user work: {shown}{more}. Commit or stash these "
|
|
3763
|
+
"changes, then retry; unrelated untracked files do not need to be deleted."
|
|
3750
3764
|
)
|
|
3751
3765
|
|
|
3766
|
+
task.local_untracked_paths = {
|
|
3767
|
+
path for path in (await self._git(
|
|
3768
|
+
"ls-files", "--others", "-z", cwd=local_path,
|
|
3769
|
+
)).split("\0") if path
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3752
3772
|
try:
|
|
3753
3773
|
remote_branch = await self._git(
|
|
3754
3774
|
"ls-remote", "--heads", "origin", f"refs/heads/{target_branch}",
|
|
@@ -3783,9 +3803,14 @@ class WorkspaceManager:
|
|
|
3783
3803
|
remote_head = (
|
|
3784
3804
|
await self._git("rev-parse", f"origin/{target_branch}", cwd=local_path)
|
|
3785
3805
|
).strip()
|
|
3806
|
+
await self._recover_local_requirement_artifacts(local_path, task, recoverable_paths)
|
|
3786
3807
|
if local_head != remote_head:
|
|
3808
|
+
await self._check_local_untracked_overwrites(local_path, remote_head)
|
|
3787
3809
|
try:
|
|
3788
|
-
await self._git(
|
|
3810
|
+
await self._git(
|
|
3811
|
+
"merge", "--ff-only", "--no-overwrite-ignore",
|
|
3812
|
+
f"origin/{target_branch}", cwd=local_path,
|
|
3813
|
+
)
|
|
3789
3814
|
except RuntimeError as exc:
|
|
3790
3815
|
raise RuntimeError(
|
|
3791
3816
|
f"local_binding_unavailable: local branch '{target_branch}' cannot "
|
|
@@ -3824,6 +3849,7 @@ class WorkspaceManager:
|
|
|
3824
3849
|
f"local branch is not at origin/{default_branch}; refusing to bootstrap from "
|
|
3825
3850
|
"unpushed or stale history"
|
|
3826
3851
|
)
|
|
3852
|
+
await self._recover_local_requirement_artifacts(local_path, task, recoverable_paths)
|
|
3827
3853
|
|
|
3828
3854
|
if (
|
|
3829
3855
|
task.node_type == "analysis"
|
|
@@ -3864,8 +3890,153 @@ class WorkspaceManager:
|
|
|
3864
3890
|
f"HEAD after fetch — cross-phase handoff incomplete: {', '.join(missing_shas)}"
|
|
3865
3891
|
)
|
|
3866
3892
|
|
|
3893
|
+
task.local_untracked_paths = {
|
|
3894
|
+
path for path in (await self._git(
|
|
3895
|
+
"ls-files", "--others", "-z", cwd=local_path,
|
|
3896
|
+
)).split("\0") if path
|
|
3897
|
+
}
|
|
3867
3898
|
return local_path
|
|
3868
3899
|
|
|
3900
|
+
@staticmethod
|
|
3901
|
+
def _local_requirement_artifact_paths(task: TaskInfo, local_path: Path | None = None) -> set[str]:
|
|
3902
|
+
key = str(task.requirement_key or "")
|
|
3903
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", key):
|
|
3904
|
+
return set()
|
|
3905
|
+
prefix = f"docs/requirements/{key}/"
|
|
3906
|
+
data = task.input_data or {}
|
|
3907
|
+
context = data.get("context") or {}
|
|
3908
|
+
names = {
|
|
3909
|
+
name for outputs in _ANALYSIS_OUTPUTS_BY_TYPE.values() for name in outputs
|
|
3910
|
+
} | {
|
|
3911
|
+
"design.md", "test-cases.json", "coverage-matrix.json",
|
|
3912
|
+
"test-evidence.json", "test-report.md", "release-note.md",
|
|
3913
|
+
}
|
|
3914
|
+
names.update(
|
|
3915
|
+
_get_design_output_for_type(requirement_type)
|
|
3916
|
+
for requirement_type in _ANALYSIS_OUTPUTS_BY_TYPE
|
|
3917
|
+
)
|
|
3918
|
+
paths: set[str] = set()
|
|
3919
|
+
for values in (data, context):
|
|
3920
|
+
for field_name in ("analysis_output_dir", "output_dir"):
|
|
3921
|
+
directory = str(values.get(field_name) or "").replace("\\", "/").rstrip("/")
|
|
3922
|
+
if (
|
|
3923
|
+
directory.startswith(prefix)
|
|
3924
|
+
and not any(part in ("", ".", "..") for part in directory.split("/"))
|
|
3925
|
+
):
|
|
3926
|
+
paths.update(f"{directory}/{name}" for name in names)
|
|
3927
|
+
if local_path is not None:
|
|
3928
|
+
for manifest_path in sorted(paths):
|
|
3929
|
+
if not manifest_path.endswith("/test-cases.json"):
|
|
3930
|
+
continue
|
|
3931
|
+
manifest_file = local_path / manifest_path
|
|
3932
|
+
if not manifest_file.resolve().is_relative_to(local_path.resolve()):
|
|
3933
|
+
continue
|
|
3934
|
+
try:
|
|
3935
|
+
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
|
|
3936
|
+
except (OSError, ValueError, UnicodeError):
|
|
3937
|
+
continue
|
|
3938
|
+
cases = manifest.get("test_cases") if isinstance(manifest, dict) else None
|
|
3939
|
+
if not isinstance(cases, list):
|
|
3940
|
+
continue
|
|
3941
|
+
for case in cases:
|
|
3942
|
+
if not isinstance(case, dict):
|
|
3943
|
+
continue
|
|
3944
|
+
case_id = str(case.get("id") or "")
|
|
3945
|
+
script_path = str(case.get("script_path") or "").replace("\\", "/")
|
|
3946
|
+
parts = script_path.split("/")
|
|
3947
|
+
if (
|
|
3948
|
+
re.fullmatch(r"TC-\d+", case_id)
|
|
3949
|
+
and len(parts) == 3
|
|
3950
|
+
and parts[0] == "tests"
|
|
3951
|
+
and parts[1] in ("e2e", "unit", "integration")
|
|
3952
|
+
and parts[2].startswith((case_id + "-", case_id + "."))
|
|
3953
|
+
and parts[2].endswith((".spec.ts", ".spec.js", ".test.ts", ".test.js", ".py"))
|
|
3954
|
+
):
|
|
3955
|
+
paths.add(script_path)
|
|
3956
|
+
return paths
|
|
3957
|
+
|
|
3958
|
+
async def _recover_local_requirement_artifacts(
|
|
3959
|
+
self, local_path: Path, task: TaskInfo, recoverable_paths: set[str],
|
|
3960
|
+
) -> None:
|
|
3961
|
+
if not recoverable_paths:
|
|
3962
|
+
return
|
|
3963
|
+
git = self._git
|
|
3964
|
+
status_output = await git(
|
|
3965
|
+
"status", "--porcelain=v1", "--untracked-files=all", "-z", cwd=local_path,
|
|
3966
|
+
)
|
|
3967
|
+
paths = sorted({
|
|
3968
|
+
path for status, path in _git_status_entries(status_output)
|
|
3969
|
+
if path in recoverable_paths
|
|
3970
|
+
and (status == "??" or path.startswith("docs/requirements/"))
|
|
3971
|
+
})
|
|
3972
|
+
if not paths:
|
|
3973
|
+
return
|
|
3974
|
+
workspace_root = local_path.resolve()
|
|
3975
|
+
if any(not (local_path / path).resolve().is_relative_to(workspace_root) for path in paths):
|
|
3976
|
+
raise RuntimeError(
|
|
3977
|
+
"local_binding_unavailable: requirement artifact resolves outside the local "
|
|
3978
|
+
"workspace; refusing to recover files through an external symbolic link"
|
|
3979
|
+
)
|
|
3980
|
+
recovery_ref = f"refs/forgexa/local-artifact-recovery/{task.task_id}/{time.time_ns()}"
|
|
3981
|
+
await git("check-ref-format", recovery_ref, cwd=local_path)
|
|
3982
|
+
name, email = _resolve_git_author(task.project)
|
|
3983
|
+
try:
|
|
3984
|
+
await git(
|
|
3985
|
+
"-c", f"user.name={name}", "-c", f"user.email={email}",
|
|
3986
|
+
"stash", "push", "--include-untracked", "--message",
|
|
3987
|
+
f"Forgexa requirement artifact recovery for {task.task_id}", "--",
|
|
3988
|
+
*(f":(literal){path}" for path in paths), cwd=local_path,
|
|
3989
|
+
)
|
|
3990
|
+
recovery_sha = (await git("rev-parse", "refs/stash", cwd=local_path)).strip()
|
|
3991
|
+
await git("update-ref", recovery_ref, recovery_sha, cwd=local_path)
|
|
3992
|
+
except RuntimeError as exc:
|
|
3993
|
+
raise RuntimeError(
|
|
3994
|
+
"local_binding_unavailable: could not preserve uncommitted requirement "
|
|
3995
|
+
"artifacts; any created backup remains in the local Git stash. "
|
|
3996
|
+
"No user code was discarded."
|
|
3997
|
+
) from exc
|
|
3998
|
+
_append_local_bind_output_log(task.task_id, [
|
|
3999
|
+
f"[daemon] Preserved {len(paths)} unfinished requirement artifact(s) in "
|
|
4000
|
+
f"{recovery_ref}; restored their committed baseline for this task. "
|
|
4001
|
+
f"Recover with git stash apply --index {recovery_ref}.",
|
|
4002
|
+
])
|
|
4003
|
+
logger.info("Preserved local requirement artifacts for task %s in %s", task.task_id, recovery_ref)
|
|
4004
|
+
remaining_status = await git(
|
|
4005
|
+
"status", "--porcelain=v1", "--untracked-files=no", "-z", cwd=local_path,
|
|
4006
|
+
)
|
|
4007
|
+
if _git_status_paths(remaining_status):
|
|
4008
|
+
raise RuntimeError(
|
|
4009
|
+
"local_binding_unavailable: tracked changes remain after requirement artifact "
|
|
4010
|
+
f"recovery; no task was started. Preserved artifacts are in {recovery_ref}."
|
|
4011
|
+
)
|
|
4012
|
+
|
|
4013
|
+
async def _check_local_untracked_overwrites(self, local_path: Path, revision: str) -> None:
|
|
4014
|
+
untracked_paths = {
|
|
4015
|
+
path for path in (await self._git(
|
|
4016
|
+
"ls-files", "--others", "-z", cwd=local_path,
|
|
4017
|
+
)).split("\0") if path
|
|
4018
|
+
}
|
|
4019
|
+
if not untracked_paths:
|
|
4020
|
+
return
|
|
4021
|
+
target_paths = {
|
|
4022
|
+
path for path in (await self._git(
|
|
4023
|
+
"ls-tree", "-r", "--name-only", "-z", revision, cwd=local_path,
|
|
4024
|
+
)).split("\0") if path
|
|
4025
|
+
}
|
|
4026
|
+
conflicts = {
|
|
4027
|
+
path for path in target_paths if _is_preserved_local_path(path, untracked_paths)
|
|
4028
|
+
}
|
|
4029
|
+
conflicts.update(
|
|
4030
|
+
path.rstrip("/") for path in untracked_paths
|
|
4031
|
+
if _is_preserved_local_path(path.rstrip("/"), target_paths)
|
|
4032
|
+
)
|
|
4033
|
+
if conflicts:
|
|
4034
|
+
raise RuntimeError(
|
|
4035
|
+
"local_binding_unavailable: branch update would overwrite untracked or "
|
|
4036
|
+
"ignored local files; no branch was updated. Preserve the conflicting "
|
|
4037
|
+
f"files, then retry: {', '.join(sorted(conflicts)[:20])}"
|
|
4038
|
+
)
|
|
4039
|
+
|
|
3869
4040
|
async def _reset_authorized_local_fresh_branch(
|
|
3870
4041
|
self,
|
|
3871
4042
|
local_path: Path,
|
|
@@ -3903,6 +4074,18 @@ class WorkspaceManager:
|
|
|
3903
4074
|
)
|
|
3904
4075
|
return
|
|
3905
4076
|
|
|
4077
|
+
await self._check_local_untracked_overwrites(local_path, default_sha)
|
|
4078
|
+
try:
|
|
4079
|
+
await git(
|
|
4080
|
+
"read-tree", "--dry-run", "-m", "-u", "HEAD", default_sha,
|
|
4081
|
+
cwd=local_path,
|
|
4082
|
+
)
|
|
4083
|
+
except RuntimeError as exc:
|
|
4084
|
+
raise RuntimeError(
|
|
4085
|
+
"local_binding_unavailable: local fresh branch reset would overwrite local "
|
|
4086
|
+
f"files; no branch was reset. Preserve the conflicting files, then retry: {exc}"
|
|
4087
|
+
) from exc
|
|
4088
|
+
|
|
3906
4089
|
backup_tag = f"forgexa/fresh-analysis-backup/{task_id}"
|
|
3907
4090
|
await git("check-ref-format", f"refs/tags/{backup_tag}", cwd=local_path)
|
|
3908
4091
|
remote_tag = await git(
|
|
@@ -3948,7 +4131,8 @@ class WorkspaceManager:
|
|
|
3948
4131
|
) from exc
|
|
3949
4132
|
|
|
3950
4133
|
try:
|
|
3951
|
-
await
|
|
4134
|
+
await self._check_local_untracked_overwrites(local_path, default_sha)
|
|
4135
|
+
await git("reset", "--keep", default_sha, cwd=local_path)
|
|
3952
4136
|
except RuntimeError as exc:
|
|
3953
4137
|
try:
|
|
3954
4138
|
await git(
|
|
@@ -6563,6 +6747,18 @@ class ProcessManager:
|
|
|
6563
6747
|
"Fix ALL listed issues. Focus on the specific problems above."
|
|
6564
6748
|
)
|
|
6565
6749
|
|
|
6750
|
+
if task.workspace_resolution == "local_bind":
|
|
6751
|
+
prompt += (
|
|
6752
|
+
"\n\n## Local Workspace Safety\n"
|
|
6753
|
+
"This is the user's real working directory. Pre-existing untracked and "
|
|
6754
|
+
"ignored files are user-owned, not task output. Preserve them: do not delete, "
|
|
6755
|
+
"overwrite, stage, or commit them. Modify only tracked task files or create "
|
|
6756
|
+
"new task files. If the task requires changing a pre-existing untracked "
|
|
6757
|
+
"file, report the conflict instead of overwriting it. Never run git clean, "
|
|
6758
|
+
"git reset --hard, force-push, or blanket staging such as git add -A. "
|
|
6759
|
+
"Leave committing and pushing task changes to Forgexa."
|
|
6760
|
+
)
|
|
6761
|
+
|
|
6566
6762
|
if workspace_path is not None:
|
|
6567
6763
|
return agent_core.with_workspace_environment_guidance(
|
|
6568
6764
|
prompt, workspace_path,
|
|
@@ -10412,9 +10608,9 @@ def _testing_intents_have_e2e(workspace_path: Path, input_data: dict[str, Any])
|
|
|
10412
10608
|
_QA_TEST_LEVELS = frozenset({"unit", "integration", "e2e"})
|
|
10413
10609
|
|
|
10414
10610
|
|
|
10415
|
-
def
|
|
10416
|
-
"""Extract
|
|
10417
|
-
|
|
10611
|
+
def _git_status_entries(status_output: str) -> list[tuple[str, str]]:
|
|
10612
|
+
"""Extract status/path pairs from porcelain-v1 `-z` output."""
|
|
10613
|
+
entries: list[tuple[str, str]] = []
|
|
10418
10614
|
records = status_output.split("\0")
|
|
10419
10615
|
index = 0
|
|
10420
10616
|
while index < len(records):
|
|
@@ -10422,12 +10618,35 @@ def _git_status_paths(status_output: str) -> list[str]:
|
|
|
10422
10618
|
index += 1
|
|
10423
10619
|
if len(record) < 4 or record[2] != " ":
|
|
10424
10620
|
continue
|
|
10425
|
-
|
|
10621
|
+
entries.append((record[:2], record[3:]))
|
|
10426
10622
|
if "R" in record[:2] or "C" in record[:2]:
|
|
10427
10623
|
if index < len(records) and records[index]:
|
|
10428
|
-
|
|
10624
|
+
entries.append((record[:2], records[index]))
|
|
10429
10625
|
index += 1
|
|
10430
|
-
return
|
|
10626
|
+
return entries
|
|
10627
|
+
|
|
10628
|
+
|
|
10629
|
+
def _git_status_paths(status_output: str) -> list[str]:
|
|
10630
|
+
"""Extract changed paths from porcelain-v1 `-z` output."""
|
|
10631
|
+
return [path for _status, path in _git_status_entries(status_output)]
|
|
10632
|
+
|
|
10633
|
+
|
|
10634
|
+
def _is_preserved_local_path(path: str, preserved_paths: set[str]) -> bool:
|
|
10635
|
+
if path in preserved_paths or path + "/" in preserved_paths:
|
|
10636
|
+
return True
|
|
10637
|
+
prefix = ""
|
|
10638
|
+
for component in path.split("/")[:-1]:
|
|
10639
|
+
prefix += component + "/"
|
|
10640
|
+
if prefix in preserved_paths or prefix[:-1] in preserved_paths:
|
|
10641
|
+
return True
|
|
10642
|
+
return False
|
|
10643
|
+
|
|
10644
|
+
|
|
10645
|
+
def _local_bind_changed_paths(status_output: str, preserved_paths: set[str]) -> list[str]:
|
|
10646
|
+
return [
|
|
10647
|
+
path for status, path in _git_status_entries(status_output)
|
|
10648
|
+
if status != "??" or not _is_preserved_local_path(path, preserved_paths)
|
|
10649
|
+
]
|
|
10431
10650
|
|
|
10432
10651
|
|
|
10433
10652
|
def _qa_test_level_from_source_path(path: object) -> str | None:
|
|
@@ -12258,6 +12477,8 @@ class RuntimeDaemon:
|
|
|
12258
12477
|
is_local_bind = task.workspace_resolution == "local_bind"
|
|
12259
12478
|
async def report_task_complete(result: TaskResult) -> None:
|
|
12260
12479
|
if is_local_bind:
|
|
12480
|
+
if result.error:
|
|
12481
|
+
_append_local_bind_output_log(task.task_id, [f"[daemon] {result.error}"])
|
|
12261
12482
|
_prepare_local_bind_completion(task, result)
|
|
12262
12483
|
if self.managed_policy is not None and not await self.managed_policy.finish_task():
|
|
12263
12484
|
result.status = "failed"
|
|
@@ -13217,6 +13438,9 @@ class RuntimeDaemon:
|
|
|
13217
13438
|
logger.info("Task %s completed: %s (agent=%s)", task.task_id, result.status, agent.agent_id)
|
|
13218
13439
|
|
|
13219
13440
|
except Exception as e:
|
|
13441
|
+
failure_code = str(getattr(e, "code", "") or "")
|
|
13442
|
+
if is_local_bind and str(e).startswith("local_binding_unavailable:"):
|
|
13443
|
+
failure_code = "local_binding_unavailable"
|
|
13220
13444
|
if self.managed_policy is not None:
|
|
13221
13445
|
_append_local_bind_output_log(task.task_id, [str(e)])
|
|
13222
13446
|
logger.warning("Managed local task failed: %s", getattr(e, "code", "local_task_failed"))
|
|
@@ -13225,6 +13449,7 @@ class RuntimeDaemon:
|
|
|
13225
13449
|
await report_task_complete(TaskResult(
|
|
13226
13450
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
13227
13451
|
error=str(e),
|
|
13452
|
+
failure_code=failure_code,
|
|
13228
13453
|
execution_attempt_id=task.execution_attempt_id,
|
|
13229
13454
|
))
|
|
13230
13455
|
finally:
|
|
@@ -15416,12 +15641,18 @@ class RuntimeDaemon:
|
|
|
15416
15641
|
require_source_inventory = _qa_source_test_validation_required(
|
|
15417
15642
|
(task.input_data or {}).get("qa_test_level_policy_snapshot")
|
|
15418
15643
|
)
|
|
15644
|
+
preserved_paths = (
|
|
15645
|
+
getattr(task, "local_untracked_paths", None)
|
|
15646
|
+
if getattr(task, "workspace_resolution", None) == "local_bind"
|
|
15647
|
+
else None
|
|
15648
|
+
)
|
|
15419
15649
|
for attempt in range(max_retries):
|
|
15420
15650
|
inventory_issues = await self._refresh_validation_changed_paths(
|
|
15421
15651
|
workspace_path,
|
|
15422
15652
|
result,
|
|
15423
15653
|
default_branch=default_branch,
|
|
15424
15654
|
before_sha=before_sha,
|
|
15655
|
+
preserved_paths=preserved_paths,
|
|
15425
15656
|
)
|
|
15426
15657
|
issues = self._validate_outputs(workspace_path, task, result)
|
|
15427
15658
|
if require_source_inventory:
|
|
@@ -15545,6 +15776,7 @@ class RuntimeDaemon:
|
|
|
15545
15776
|
result,
|
|
15546
15777
|
default_branch=default_branch,
|
|
15547
15778
|
before_sha=before_sha,
|
|
15779
|
+
preserved_paths=preserved_paths,
|
|
15548
15780
|
)
|
|
15549
15781
|
remaining = self._validate_outputs(workspace_path, task, result)
|
|
15550
15782
|
if require_source_inventory:
|
|
@@ -15639,6 +15871,7 @@ class RuntimeDaemon:
|
|
|
15639
15871
|
*,
|
|
15640
15872
|
default_branch: str,
|
|
15641
15873
|
before_sha: str,
|
|
15874
|
+
preserved_paths: set[str] | None = None,
|
|
15642
15875
|
) -> list[str]:
|
|
15643
15876
|
"""Merge reported, working-tree, and already-committed task paths."""
|
|
15644
15877
|
reported_paths = ProcessManager._normalize_repo_paths(result.files_changed)
|
|
@@ -15654,7 +15887,7 @@ class RuntimeDaemon:
|
|
|
15654
15887
|
cwd=workspace_path,
|
|
15655
15888
|
)
|
|
15656
15889
|
paths.update(ProcessManager._normalize_repo_paths(
|
|
15657
|
-
|
|
15890
|
+
_local_bind_changed_paths(status_output, preserved_paths or set())
|
|
15658
15891
|
))
|
|
15659
15892
|
collected_paths = True
|
|
15660
15893
|
except Exception:
|
|
@@ -16007,6 +16240,8 @@ class RuntimeDaemon:
|
|
|
16007
16240
|
async def _discard_transient_untracked_runtime_files(
|
|
16008
16241
|
self,
|
|
16009
16242
|
workspace_path: Path,
|
|
16243
|
+
*,
|
|
16244
|
+
preserved_paths: set[str] | None = None,
|
|
16010
16245
|
) -> list[str]:
|
|
16011
16246
|
"""Discard known untracked test-run metadata before automatic staging."""
|
|
16012
16247
|
git = self.workspace_manager._git
|
|
@@ -16026,6 +16261,8 @@ class RuntimeDaemon:
|
|
|
16026
16261
|
if not line.startswith("?? "):
|
|
16027
16262
|
continue
|
|
16028
16263
|
relative_path = line[3:].replace("\\", "/").lstrip("./")
|
|
16264
|
+
if _is_preserved_local_path(relative_path, preserved_paths or set()):
|
|
16265
|
+
continue
|
|
16029
16266
|
if relative_path not in _TRANSIENT_UNTRACKED_RUNTIME_PATHS:
|
|
16030
16267
|
continue
|
|
16031
16268
|
candidate = workspace_path / relative_path
|
|
@@ -16126,12 +16363,12 @@ class RuntimeDaemon:
|
|
|
16126
16363
|
before staging and again before pushing: if the user switched
|
|
16127
16364
|
branches while the agent ran, the task fails closed instead of
|
|
16128
16365
|
committing/pushing the changes onto the wrong branch
|
|
16129
|
-
|
|
16130
|
-
|
|
16131
|
-
|
|
16132
|
-
|
|
16133
|
-
|
|
16134
|
-
|
|
16366
|
+
- preserves the startup inventory of user-owned untracked/ignored
|
|
16367
|
+
files and excludes it from staging and convergence checks; if the
|
|
16368
|
+
agent stages or commits those files, refuses to push
|
|
16369
|
+
- refuses to commit/push task paths that look like secrets (.env,
|
|
16370
|
+
private keys, etc.), including paths committed by the agent itself
|
|
16371
|
+
since before_sha; untouched pre-existing private files stay local
|
|
16135
16372
|
- never rebases, merges, resets, or force-pushes; a non-fast-forward
|
|
16136
16373
|
push failure just fails the task closed, no automatic recovery
|
|
16137
16374
|
Returns a dict with commit/push status info (empty dict on success),
|
|
@@ -16173,9 +16410,26 @@ class RuntimeDaemon:
|
|
|
16173
16410
|
"from HEAD. Review the branch history yourself before retrying."
|
|
16174
16411
|
)}
|
|
16175
16412
|
|
|
16176
|
-
|
|
16177
|
-
|
|
16178
|
-
|
|
16413
|
+
if task.local_untracked_paths and not before_sha:
|
|
16414
|
+
return {"commit_error": "Refusing to commit: the local task baseline is unavailable"}
|
|
16415
|
+
|
|
16416
|
+
await self._discard_transient_untracked_runtime_files(
|
|
16417
|
+
workspace_path, preserved_paths=task.local_untracked_paths,
|
|
16418
|
+
)
|
|
16419
|
+
status_output = await git(
|
|
16420
|
+
"status", "--porcelain=v1", "--untracked-files=all", "-z", cwd=workspace_path,
|
|
16421
|
+
)
|
|
16422
|
+
dirty_paths = _local_bind_changed_paths(status_output, task.local_untracked_paths)
|
|
16423
|
+
preserved_dirty = [
|
|
16424
|
+
path for path in dirty_paths
|
|
16425
|
+
if _is_preserved_local_path(path, task.local_untracked_paths)
|
|
16426
|
+
]
|
|
16427
|
+
if preserved_dirty:
|
|
16428
|
+
return {"commit_error": (
|
|
16429
|
+
"Refusing to commit: pre-existing user-owned local files were staged ("
|
|
16430
|
+
+ ", ".join(preserved_dirty[:20])
|
|
16431
|
+
+ "). Not pushed; unstage these files and review the task changes."
|
|
16432
|
+
)}
|
|
16179
16433
|
|
|
16180
16434
|
sensitive_dirty = [p for p in dirty_paths if _is_sensitive_local_path(p)]
|
|
16181
16435
|
if sensitive_dirty:
|
|
@@ -16186,7 +16440,19 @@ class RuntimeDaemon:
|
|
|
16186
16440
|
)}
|
|
16187
16441
|
|
|
16188
16442
|
if dirty_paths:
|
|
16189
|
-
await git("add", "-
|
|
16443
|
+
await git("add", "-u", cwd=workspace_path)
|
|
16444
|
+
new_paths = [
|
|
16445
|
+
path for status, path in _git_status_entries(status_output)
|
|
16446
|
+
if status == "??" and not _is_preserved_local_path(
|
|
16447
|
+
path, task.local_untracked_paths,
|
|
16448
|
+
)
|
|
16449
|
+
]
|
|
16450
|
+
for offset in range(0, len(new_paths), 100):
|
|
16451
|
+
await git(
|
|
16452
|
+
"add", "--",
|
|
16453
|
+
*(f":(literal){path}" for path in new_paths[offset:offset + 100]),
|
|
16454
|
+
cwd=workspace_path,
|
|
16455
|
+
)
|
|
16190
16456
|
|
|
16191
16457
|
mode_change_error = await self._pure_file_mode_change_error(
|
|
16192
16458
|
workspace_path, "--cached"
|
|
@@ -16254,15 +16520,27 @@ class RuntimeDaemon:
|
|
|
16254
16520
|
# every path touched since before_sha for sensitive content too,
|
|
16255
16521
|
# not just what we just staged ourselves.
|
|
16256
16522
|
if before_sha:
|
|
16257
|
-
|
|
16523
|
+
committed_paths: set[str] = set()
|
|
16524
|
+
commits = await git("rev-list", f"{before_sha}..HEAD", cwd=workspace_path)
|
|
16525
|
+
for commit_sha in commits.splitlines():
|
|
16258
16526
|
changed_since_before = await git(
|
|
16259
|
-
"diff", "--name-only",
|
|
16527
|
+
"diff-tree", "--no-commit-id", "--name-only", "--no-renames",
|
|
16528
|
+
"-r", "-m", "-z", commit_sha, cwd=workspace_path,
|
|
16260
16529
|
)
|
|
16261
|
-
|
|
16262
|
-
|
|
16530
|
+
committed_paths.update(path for path in changed_since_before.split("\0") if path)
|
|
16531
|
+
preserved_committed = sorted(
|
|
16532
|
+
path for path in committed_paths
|
|
16533
|
+
if _is_preserved_local_path(path, task.local_untracked_paths)
|
|
16534
|
+
)
|
|
16535
|
+
if preserved_committed:
|
|
16536
|
+
return {"commit_error": (
|
|
16537
|
+
"Refusing to push: commit(s) made during this task include pre-existing "
|
|
16538
|
+
"user-owned local files ("
|
|
16539
|
+
+ ", ".join(preserved_committed[:20])
|
|
16540
|
+
+ "). Not pushed; inspect and fix the commit(s) yourself."
|
|
16541
|
+
)}
|
|
16263
16542
|
agent_sensitive = [
|
|
16264
|
-
|
|
16265
|
-
if p.strip() and _is_sensitive_local_path(p)
|
|
16543
|
+
path for path in sorted(committed_paths) if _is_sensitive_local_path(path)
|
|
16266
16544
|
]
|
|
16267
16545
|
if agent_sensitive:
|
|
16268
16546
|
return {"commit_error": (
|
|
@@ -16272,8 +16550,10 @@ class RuntimeDaemon:
|
|
|
16272
16550
|
+ "). Not pushed — inspect and fix the commit(s) yourself."
|
|
16273
16551
|
)}
|
|
16274
16552
|
|
|
16275
|
-
post_commit_status = await git(
|
|
16276
|
-
|
|
16553
|
+
post_commit_status = await git(
|
|
16554
|
+
"status", "--porcelain=v1", "--untracked-files=all", "-z", cwd=workspace_path,
|
|
16555
|
+
)
|
|
16556
|
+
if _local_bind_changed_paths(post_commit_status, task.local_untracked_paths):
|
|
16277
16557
|
if _post_commit_sync_attempts_remaining > 0:
|
|
16278
16558
|
logger.warning(
|
|
16279
16559
|
"local_bind: workspace changed after commit in %s; "
|
|
@@ -16328,8 +16608,10 @@ class RuntimeDaemon:
|
|
|
16328
16608
|
"yourself (e.g. pull/merge), then retry."
|
|
16329
16609
|
)}
|
|
16330
16610
|
|
|
16331
|
-
post_push_status = await git(
|
|
16332
|
-
|
|
16611
|
+
post_push_status = await git(
|
|
16612
|
+
"status", "--porcelain=v1", "--untracked-files=all", "-z", cwd=workspace_path,
|
|
16613
|
+
)
|
|
16614
|
+
if not _local_bind_changed_paths(post_push_status, task.local_untracked_paths):
|
|
16333
16615
|
return {}
|
|
16334
16616
|
|
|
16335
16617
|
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
|
|
File without changes
|
|
File without changes
|