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.
Files changed (31) hide show
  1. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/PKG-INFO +1 -1
  2. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/agent_core.py +44 -12
  4. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/aws_logs_evidence.py +3 -1
  5. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/daemon.py +208 -42
  6. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
  7. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/pyproject.toml +1 -1
  8. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/README.md +0 -0
  9. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/_build_config.py +0 -0
  10. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/_local_bind.py +0 -0
  11. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/autoupgrade.py +0 -0
  12. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/aws_evidence.py +0 -0
  13. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/main.py +0 -0
  14. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/py.typed +0 -0
  15. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/runtime_evidence.py +0 -0
  16. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli/zcode_protocol.py +0 -0
  17. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  18. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  19. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
  20. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli.egg-info/requires.txt +0 -0
  21. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/forgexa_cli.egg-info/top_level.txt +0 -0
  22. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/setup.cfg +0 -0
  23. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_auth_and_runtime_commands.py +0 -0
  24. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_autoupgrade.py +0 -0
  25. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_check_command.py +0 -0
  26. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_expiry_warnings_and_revoke.py +0 -0
  27. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_local_bind_commands.py +0 -0
  28. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_runtime_credentials.py +0 -0
  29. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_session_credentials.py +0 -0
  30. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_silent_install.py +0 -0
  31. {forgexa_cli-1.67.1 → forgexa_cli-1.68.1}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.67.1
3
+ Version: 1.68.1
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.67.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 any(
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
- pid_dir = cgroup_path / relative
3795
+ controller_root = cgroup_path
3790
3796
  elif "pids" in controllers.split(","):
3791
- pid_dir = cgroup_path / "pids" / relative
3797
+ controller_root = (cgroup_path / "pids").resolve()
3792
3798
  else:
3793
3799
  continue
3794
- current_text = (pid_dir / "pids.current").read_text().strip()
3795
- maximum_text = (pid_dir / "pids.max").read_text().strip()
3796
- if maximum_text != "max":
3797
- constraints.append(("cgroup", int(current_text), int(maximum_text)))
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 < 32:
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 and evidence["events"]:
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.67.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 its branch/cleanliness
3654
- state. Ordinary local-bind tasks perform NO destructive git operation
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("status", "--porcelain", cwd=local_path)
3743
- if status_output.strip():
3744
- dirty_paths = [line[3:] for line in status_output.splitlines() if line.strip()]
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, refusing "
3749
- f"to run: {shown}{more}"
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("merge", "--ff-only", f"origin/{target_branch}", cwd=local_path)
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 git("reset", "--hard", default_sha, cwd=local_path)
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
- if process is None:
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 agent_core.is_runtime_resource_exhaustion_error(
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 _git_status_paths(status_output: str) -> list[str]:
10405
- """Extract changed paths from porcelain-v1 `-z` output."""
10406
- paths: list[str] = []
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
- paths.append(record[3:])
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
- paths.append(records[index])
10497
+ entries.append((record[:2], records[index]))
10418
10498
  index += 1
10419
- return paths
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
- _git_status_paths(status_output)
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
- - refuses to commit if any changed path (staged, or already
16119
- committed by the agent itself since before_sha) looks like a
16120
- secret (.env, private keys, etc.) — fails the task closed rather
16121
- than silently excluding just those files, since a partial commit
16122
- would leave the directory dirty and block future local_bind tasks
16123
- on this binding (which requires a clean working tree)
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
- await self._discard_transient_untracked_runtime_files(workspace_path)
16166
- status_output = await git("status", "--porcelain", cwd=workspace_path)
16167
- dirty_paths = [line[3:] for line in status_output.splitlines() if line.strip()]
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", "-A", cwd=workspace_path)
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
- try:
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", f"{before_sha}..HEAD", cwd=workspace_path,
16400
+ "diff-tree", "--no-commit-id", "--name-only", "--no-renames",
16401
+ "-r", "-m", "-z", commit_sha, cwd=workspace_path,
16249
16402
  )
16250
- except RuntimeError:
16251
- changed_since_before = ""
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
- p for p in changed_since_before.splitlines()
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("status", "--porcelain", cwd=workspace_path)
16265
- if post_commit_status.strip():
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("status", "--porcelain", cwd=workspace_path)
16321
- if not post_push_status.strip():
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.67.1
3
+ Version: 1.68.1
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.67.1"
3
+ version = "1.68.1"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes