forgexa-cli 1.1.6__tar.gz → 1.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.1.6
3
+ Version: 1.2.2
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: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.1.6"
2
+ __version__ = "1.2.2"
@@ -11,6 +11,7 @@ Usage:
11
11
  from __future__ import annotations
12
12
 
13
13
  import asyncio
14
+ import base64
14
15
  import hashlib
15
16
  import json
16
17
  import logging
@@ -27,8 +28,15 @@ from dataclasses import dataclass, field
27
28
  from datetime import datetime, timezone
28
29
  from pathlib import Path
29
30
  from typing import Any
31
+ from urllib.parse import urlparse
30
32
  from uuid import UUID
31
33
 
34
+ # fcntl is Unix-only; on Windows we skip file locking
35
+ try:
36
+ import fcntl
37
+ except ImportError:
38
+ fcntl = None # type: ignore[assignment]
39
+
32
40
  try:
33
41
  import httpx
34
42
  except ImportError:
@@ -104,8 +112,8 @@ except (ImportError, ModuleNotFoundError):
104
112
 
105
113
  def get_daemon_server_urls(self) -> list:
106
114
  if self.DAEMON_SERVER_URLS:
107
- return [u.strip() for u in self.DAEMON_SERVER_URLS.split(",") if u.strip()]
108
- return [self.DAEMON_SERVER_URL] if self.DAEMON_SERVER_URL else []
115
+ return list(dict.fromkeys(u.strip().rstrip("/") for u in self.DAEMON_SERVER_URLS.split(",") if u.strip()))
116
+ return [self.DAEMON_SERVER_URL.rstrip("/")] if self.DAEMON_SERVER_URL else []
109
117
 
110
118
  settings = _StandaloneSettings() # type: ignore[assignment]
111
119
 
@@ -444,12 +452,89 @@ class WorkspaceManager:
444
452
  def __init__(self, root: str):
445
453
  self.root = Path(root).expanduser()
446
454
  self.root.mkdir(parents=True, exist_ok=True)
455
+ # Project repos live under projects/ to avoid mixing with other daemon files
456
+ self.projects_root = self.root / "projects"
457
+ self.projects_root.mkdir(parents=True, exist_ok=True)
447
458
  self._ssh_keys: dict[str, str] = {} # project_key -> PEM key content
459
+ self._http_auth: dict[str, dict[str, str]] = {} # project_key -> provider/token/username
460
+
461
+ # One-time migration: move legacy project dirs from root/ to projects/
462
+ self._migrate_legacy_project_dirs()
448
463
 
449
464
  def set_ssh_key(self, project_key: str, ssh_private_key: str) -> None:
450
465
  """Register an SSH private key for a project."""
451
466
  self._ssh_keys[project_key] = ssh_private_key
452
467
 
468
+ def _migrate_legacy_project_dirs(self) -> None:
469
+ """Move project directories from root/ to root/projects/ (one-time migration).
470
+
471
+ Previously project workspaces lived at e.g. ~/.forgexa/daemon/AILP/
472
+ Now they live at ~/.forgexa/daemon/projects/AILP/
473
+ Skip known non-project entries (projects/, daemon.log, daemon.lock, etc.).
474
+ """
475
+ _skip = {"projects", "daemon.log", "daemon.lock", ".DS_Store"}
476
+ try:
477
+ for child in self.root.iterdir():
478
+ if child.name in _skip or child.name.startswith("."):
479
+ continue
480
+ if child.is_dir() and child != self.projects_root:
481
+ dest = self.projects_root / child.name
482
+ if not dest.exists():
483
+ try:
484
+ child.rename(dest)
485
+ logger.info("Migrated workspace %s → projects/%s", child.name, child.name)
486
+ except OSError as e:
487
+ logger.warning("Failed to migrate workspace %s: %s", child.name, e)
488
+ except Exception:
489
+ pass # Non-critical migration
490
+
491
+ def set_http_auth(
492
+ self,
493
+ project_key: str,
494
+ provider: str,
495
+ api_token: str,
496
+ username: str | None = None,
497
+ ) -> None:
498
+ """Register HTTPS git auth for a project.
499
+
500
+ This is used when repos are configured via HTTPS and no SSH key is provided.
501
+ """
502
+ if not api_token:
503
+ return
504
+ self._http_auth[project_key] = {
505
+ "provider": provider,
506
+ "api_token": api_token,
507
+ "username": username or "",
508
+ }
509
+
510
+ def _build_http_git_config_args(self, project_key: str) -> list[str]:
511
+ """Build git -c args for HTTP Authorization header."""
512
+ auth = self._http_auth.get(project_key)
513
+ if not auth:
514
+ return []
515
+
516
+ provider = (auth.get("provider") or "").strip().lower()
517
+ token = auth.get("api_token") or ""
518
+ if not token:
519
+ return []
520
+
521
+ username = (auth.get("username") or "").strip()
522
+ if not username:
523
+ if provider == "github":
524
+ username = "x-access-token"
525
+ elif provider == "gitlab":
526
+ username = "oauth2"
527
+ else:
528
+ # Works for Bitbucket Server/Data Center PAT in HTTPS git auth.
529
+ username = "x-token-auth"
530
+
531
+ basic = base64.b64encode(f"{username}:{token}".encode("utf-8")).decode("ascii")
532
+ args = ["-c", f"http.extraHeader=Authorization: Basic {basic}"]
533
+ # Disable SSL verification for self-signed certs (common in on-prem Bitbucket Server)
534
+ if provider in ("bitbucket-server", "gitlab", "gitea"):
535
+ args.extend(["-c", "http.sslVerify=false"])
536
+ return args
537
+
453
538
  def _build_ssh_env(self, project_key: str) -> dict | None:
454
539
  """Build env dict with GIT_SSH_COMMAND if an SSH key is available."""
455
540
  key_content = self._ssh_keys.get(project_key)
@@ -473,7 +558,15 @@ class WorkspaceManager:
473
558
  if ssh_key:
474
559
  self.set_ssh_key(project_key, ssh_key)
475
560
 
476
- project_dir = self.root / project_key
561
+ # Register HTTPS token auth for non-SSH repos.
562
+ # Token/provider are resolved by API server from integration config fallback chain.
563
+ git_provider = str(project.get("git_provider") or "")
564
+ git_api_token = str(project.get("git_api_token") or "")
565
+ git_username = str(project.get("git_username") or "")
566
+ if git_api_token:
567
+ self.set_http_auth(project_key, git_provider, git_api_token, git_username or None)
568
+
569
+ project_dir = self.projects_root / project_key
477
570
  project_dir.mkdir(parents=True, exist_ok=True)
478
571
 
479
572
  # Use requirement_workflow_id as workspace key when available so that
@@ -485,12 +578,12 @@ class WorkspaceManager:
485
578
  else:
486
579
  workspace_key = re.sub(r"[^a-zA-Z0-9_-]", "_", str(task.graph_id))
487
580
 
488
- # V5.4: Use human-readable branch name ai/{project_key}/{requirement_key}
489
- # when requirement_key is available; fallback to ai/{workspace_key}
581
+ # Use human-readable branch name feature/{requirement_key}
582
+ # when requirement_key is available; fallback to feature/{workspace_key}
490
583
  if task.requirement_key:
491
- branch_name = f"ai/{project_key}/{task.requirement_key}"
584
+ branch_name = f"feature/{task.requirement_key}"
492
585
  else:
493
- branch_name = f"ai/{workspace_key}"
586
+ branch_name = f"feature/{workspace_key}"
494
587
 
495
588
  # Determine whether this node is the "first" in a new graph that needs
496
589
  # a fresh base from the default branch. Analysis nodes always start
@@ -551,6 +644,40 @@ class WorkspaceManager:
551
644
  await self._git("checkout", "-b", branch_name, cwd=ws_path)
552
645
  return ws_path
553
646
 
647
+ async def _is_healthy_worktree(self, ws_path: Path) -> bool:
648
+ """Check if a git worktree directory is valid and functional."""
649
+ git_file = ws_path / ".git"
650
+ if not git_file.exists():
651
+ return False
652
+ if git_file.is_dir():
653
+ # It's a real .git directory (init'd repo), not a worktree
654
+ return True
655
+ # Worktree: .git is a file containing "gitdir: /path/to/main/.git/worktrees/..."
656
+ try:
657
+ content = git_file.read_text().strip()
658
+ if content.startswith("gitdir: "):
659
+ gitdir = Path(content.split("gitdir: ", 1)[1].strip())
660
+ return gitdir.exists()
661
+ except OSError:
662
+ pass
663
+ return False
664
+
665
+ async def _remove_broken_worktree(self, main_repo: Path, ws_path: Path, workspace_key: str):
666
+ """Remove a broken worktree directory and clean up stale worktree refs."""
667
+ import shutil
668
+ # Try to prune from main repo first
669
+ if main_repo.exists():
670
+ try:
671
+ await self._git("worktree", "prune", cwd=main_repo)
672
+ except RuntimeError:
673
+ pass
674
+ # Also remove the worktree ref if it still exists
675
+ wt_ref = main_repo / ".git" / "worktrees" / workspace_key
676
+ if wt_ref.exists():
677
+ shutil.rmtree(wt_ref, ignore_errors=True)
678
+ # Remove the broken worktree directory
679
+ shutil.rmtree(ws_path, ignore_errors=True)
680
+
554
681
  async def _create_worktree(
555
682
  self, project_dir: Path, repo_url: str, default_branch: str,
556
683
  workspace_key: str, branch_name: str, *, fresh_start: bool = False,
@@ -560,31 +687,46 @@ class WorkspaceManager:
560
687
  ws_path = project_dir / workspace_key
561
688
 
562
689
  if ws_path.exists():
563
- # Always fetch latest remote refs.
564
- try:
565
- await self._git("fetch", "origin", cwd=ws_path, project_key=project_key)
566
- except RuntimeError:
567
- pass
568
-
569
- if fresh_start:
570
- # This is the first node of a new graph (e.g. analysis).
571
- # Reset the worktree to the latest origin/default_branch so
572
- # that the AI works on up-to-date code. Any previous AI
573
- # commits have already been pushed, so they are safe.
574
- logger.info("Fresh start: resetting %s to origin/%s", ws_path, default_branch)
690
+ # Validate that this worktree is healthy (its .git file points to a
691
+ # valid main repo). Worktrees break when directories are moved.
692
+ if not await self._is_healthy_worktree(ws_path):
693
+ logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
694
+ await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
695
+ else:
696
+ # Healthy worktree — fetch and optionally reset
575
697
  try:
576
- await self._git("checkout", branch_name, cwd=ws_path)
698
+ await self._git("fetch", "origin", cwd=ws_path, project_key=project_key)
577
699
  except RuntimeError:
578
- pass # Already on this branch
579
- try:
580
- await self._git(
581
- "reset", "--hard", f"origin/{default_branch}", cwd=ws_path,
582
- )
583
- except RuntimeError as exc:
584
- logger.warning("Failed to reset to origin/%s: %s", default_branch, exc)
585
- # For non-fresh-start (continuation nodes like coding→testing),
586
- # keep the working branch as-is so changes accumulate.
587
- return ws_path
700
+ pass
701
+
702
+ if fresh_start:
703
+ logger.info("Fresh start: resetting %s to origin/%s", ws_path, default_branch)
704
+ # Use checkout -B to create or reset the branch to origin/default_branch.
705
+ # Plain `checkout branch_name` fails when the branch doesn't exist locally.
706
+ try:
707
+ await self._git(
708
+ "checkout", "-B", branch_name, f"origin/{default_branch}",
709
+ cwd=ws_path,
710
+ )
711
+ except RuntimeError as exc:
712
+ logger.warning(
713
+ "checkout -B %s failed: %s — falling back to reset", branch_name, exc,
714
+ )
715
+ try:
716
+ await self._git("checkout", branch_name, cwd=ws_path)
717
+ except RuntimeError:
718
+ # Last resort: create branch from HEAD
719
+ try:
720
+ await self._git("checkout", "-b", branch_name, cwd=ws_path)
721
+ except RuntimeError:
722
+ pass
723
+ try:
724
+ await self._git(
725
+ "reset", "--hard", f"origin/{default_branch}", cwd=ws_path,
726
+ )
727
+ except RuntimeError as exc2:
728
+ logger.warning("Failed to reset to origin/%s: %s", default_branch, exc2)
729
+ return ws_path
588
730
 
589
731
  # Ensure _main repo is present and up-to-date
590
732
  if not main_repo.exists():
@@ -605,7 +747,7 @@ class WorkspaceManager:
605
747
  except RuntimeError as exc:
606
748
  logger.warning("Could not fast-forward _main/%s: %s", default_branch, exc)
607
749
 
608
- # V5.4: branch_name is passed in (ai/{project_key}/{requirement_key} or ai/{workspace_key})
750
+ # branch_name is passed in (feature/{requirement_key} or feature/{workspace_key})
609
751
  # First check if the branch already exists on remote (for refine/continuation)
610
752
  branch_exists_remote = False
611
753
  try:
@@ -644,6 +786,11 @@ class WorkspaceManager:
644
786
  except Exception:
645
787
  ws_path.mkdir(parents=True, exist_ok=True)
646
788
  await self._git("clone", repo_url, str(ws_path), project_key=project_key)
789
+ # Ensure we're on the correct branch after clone
790
+ try:
791
+ await self._git("checkout", "-B", branch_name, cwd=ws_path)
792
+ except RuntimeError:
793
+ pass
647
794
  else:
648
795
  try:
649
796
  await self._git(
@@ -661,6 +808,11 @@ class WorkspaceManager:
661
808
  # Fallback to simple clone
662
809
  ws_path.mkdir(parents=True, exist_ok=True)
663
810
  await self._git("clone", repo_url, str(ws_path), project_key=project_key)
811
+ # Ensure we're on the correct branch after clone
812
+ try:
813
+ await self._git("checkout", "-B", branch_name, cwd=ws_path)
814
+ except RuntimeError:
815
+ pass
664
816
 
665
817
  return ws_path
666
818
 
@@ -676,6 +828,7 @@ class WorkspaceManager:
676
828
  (clone, fetch, pull, push) will use GIT_SSH_COMMAND.
677
829
  """
678
830
  env = None
831
+ git_prefix_args: list[str] = []
679
832
  # Inject SSH key for commands that touch the remote
680
833
  remote_commands = {"clone", "fetch", "pull", "push"}
681
834
  if project_key and args and args[0] in remote_commands:
@@ -698,9 +851,20 @@ class WorkspaceManager:
698
851
  os.close(fd)
699
852
  except OSError:
700
853
  pass
854
+ elif args[0] == "clone" and len(args) >= 2:
855
+ # Only use HTTP auth for HTTPS repos when no SSH key is configured.
856
+ repo_candidate = args[-2] if len(args) >= 3 else args[-1]
857
+ if isinstance(repo_candidate, str):
858
+ parsed = urlparse(repo_candidate)
859
+ if parsed.scheme in ("http", "https"):
860
+ git_prefix_args = self._build_http_git_config_args(project_key)
861
+ elif args[0] in {"fetch", "pull", "push"}:
862
+ git_prefix_args = self._build_http_git_config_args(project_key)
863
+ if git_prefix_args:
864
+ env = {**(env or os.environ), "GIT_TERMINAL_PROMPT": "0"}
701
865
 
702
866
  proc = await asyncio.create_subprocess_exec(
703
- "git", *args,
867
+ "git", *git_prefix_args, *args,
704
868
  stdout=asyncio.subprocess.PIPE,
705
869
  stderr=asyncio.subprocess.PIPE,
706
870
  cwd=str(cwd) if cwd else None,
@@ -1553,6 +1717,8 @@ class HeartbeatService:
1553
1717
  self._task: asyncio.Task | None = None
1554
1718
  self._active_tasks = 0
1555
1719
  self._agents: list[dict] = []
1720
+ self._on_success = lambda: None # Callbacks set by ServerConnection
1721
+ self._on_auth_failure = lambda: None
1556
1722
 
1557
1723
  def update(self, active_tasks: int, agents: list[dict]):
1558
1724
  self._active_tasks = active_tasks
@@ -1582,7 +1748,12 @@ class HeartbeatService:
1582
1748
  },
1583
1749
  timeout=10,
1584
1750
  )
1585
- if resp.status_code != 200:
1751
+ if resp.status_code == 200:
1752
+ self._on_success()
1753
+ elif resp.status_code in (401, 403):
1754
+ logger.warning("Heartbeat auth failed: %s %s", resp.status_code, resp.text)
1755
+ self._on_auth_failure()
1756
+ else:
1586
1757
  logger.warning("Heartbeat failed: %s %s", resp.status_code, resp.text)
1587
1758
  except Exception as e:
1588
1759
  logger.warning("Heartbeat error: %s", e)
@@ -1619,6 +1790,8 @@ class TaskPoller:
1619
1790
  self.server_url = server_url.rstrip("/")
1620
1791
  self.runtime_id = runtime_id
1621
1792
  self.interval = interval
1793
+ self._on_success = lambda: None # Callbacks set by ServerConnection
1794
+ self._on_auth_failure = lambda: None
1622
1795
 
1623
1796
  async def poll(self) -> list[TaskInfo]:
1624
1797
  """Poll for tasks assigned to this daemon."""
@@ -1627,7 +1800,12 @@ class TaskPoller:
1627
1800
  f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/tasks/poll",
1628
1801
  timeout=10,
1629
1802
  )
1630
- if resp.status_code != 200:
1803
+ if resp.status_code == 200:
1804
+ self._on_success()
1805
+ elif resp.status_code in (401, 403):
1806
+ self._on_auth_failure()
1807
+ return []
1808
+ else:
1631
1809
  return []
1632
1810
 
1633
1811
  data = resp.json()
@@ -1680,13 +1858,57 @@ class ServerConnection:
1680
1858
  self.heartbeat: HeartbeatService | None = None
1681
1859
  self.poller: TaskPoller | None = None
1682
1860
  self.reporter: ProgressReporter | None = None
1861
+ self._auth_failures = 0 # Consecutive auth failure count
1862
+ self._max_auth_failures = 3 # Trigger re-registration after this many
1683
1863
  # Short label for logging
1684
1864
  from urllib.parse import urlparse
1685
1865
  parsed = urlparse(server_url)
1686
1866
  self.label = parsed.hostname or server_url
1687
1867
 
1868
+ def refresh_token(self) -> bool:
1869
+ """Re-read the user JWT from ~/.forgexa/token and update client headers.
1870
+
1871
+ Returns True if a new token was loaded (different from current).
1872
+ """
1873
+ token_path = Path.home() / ".forgexa" / "token"
1874
+ try:
1875
+ new_token = token_path.read_text().strip() if token_path.exists() else ""
1876
+ except OSError:
1877
+ new_token = ""
1878
+
1879
+ if new_token and new_token != self.api_token:
1880
+ self.api_token = new_token
1881
+ self.client.headers["Authorization"] = f"Bearer {new_token}"
1882
+ logger.info("[%s] Refreshed daemon token from ~/.forgexa/token", self.label)
1883
+ return True
1884
+ return False
1885
+
1886
+ async def re_register(self, agents: list[DiscoveredAgent], max_concurrent: int):
1887
+ """Refresh token and re-register with the server.
1888
+
1889
+ Called when persistent auth failures indicate stale token/hash.
1890
+ After re-registration, update the runtime_id on heartbeat/poller/reporter
1891
+ in case the server assigned a different one.
1892
+ """
1893
+ self.refresh_token()
1894
+ try:
1895
+ await self.register(agents, max_concurrent)
1896
+ # Sync runtime_id to all services (may change after re-registration)
1897
+ if self.heartbeat and self.runtime_id:
1898
+ self.heartbeat.runtime_id = self.runtime_id
1899
+ if self.poller and self.runtime_id:
1900
+ self.poller.runtime_id = self.runtime_id
1901
+ if self.reporter and self.runtime_id:
1902
+ self.reporter.runtime_id = self.runtime_id
1903
+ self._auth_failures = 0
1904
+ logger.info("[%s] Re-registered successfully after token refresh", self.label)
1905
+ except Exception as e:
1906
+ logger.warning("[%s] Re-registration failed: %s", self.label, e)
1907
+
1688
1908
  async def register(self, agents: list[DiscoveredAgent], max_concurrent: int):
1689
1909
  """Register this daemon with the server."""
1910
+ self._agents = agents
1911
+ self._max_concurrent_cache = max_concurrent
1690
1912
  agent_dicts = [
1691
1913
  {
1692
1914
  "agent_id": a.agent_id,
@@ -1729,10 +1951,33 @@ class ServerConnection:
1729
1951
  agents: list[DiscoveredAgent],
1730
1952
  ):
1731
1953
  """Initialize heartbeat, poller, and reporter services."""
1954
+ self._agents = agents
1955
+ self._max_concurrent = getattr(self, '_max_concurrent_cache', 5)
1956
+
1957
+ def _on_auth_success():
1958
+ self._auth_failures = 0
1959
+
1960
+ def _on_auth_failure():
1961
+ self._auth_failures += 1
1962
+ if self._auth_failures >= self._max_auth_failures:
1963
+ logger.warning(
1964
+ "[%s] %d consecutive auth failures — scheduling re-registration",
1965
+ self.label, self._auth_failures,
1966
+ )
1967
+ # Schedule re-registration in the event loop (non-blocking)
1968
+ try:
1969
+ loop = asyncio.get_event_loop()
1970
+ if loop.is_running():
1971
+ loop.create_task(self.re_register(self._agents, self._max_concurrent))
1972
+ except Exception:
1973
+ pass
1974
+
1732
1975
  self.heartbeat = HeartbeatService(
1733
1976
  self.client, self.server_url, self.runtime_id, self.daemon_id,
1734
1977
  heartbeat_interval,
1735
1978
  )
1979
+ self.heartbeat._on_success = _on_auth_success
1980
+ self.heartbeat._on_auth_failure = _on_auth_failure
1736
1981
  self.heartbeat.update(0, [
1737
1982
  {
1738
1983
  "agent_id": a.agent_id,
@@ -1745,6 +1990,8 @@ class ServerConnection:
1745
1990
  self.poller = TaskPoller(
1746
1991
  self.client, self.server_url, self.runtime_id, poll_interval,
1747
1992
  )
1993
+ self.poller._on_success = _on_auth_success
1994
+ self.poller._on_auth_failure = _on_auth_failure
1748
1995
  self.reporter = ProgressReporter(
1749
1996
  self.client, self.server_url, self.runtime_id,
1750
1997
  )
@@ -1759,8 +2006,9 @@ class ServerConnection:
1759
2006
  await self.heartbeat.stop()
1760
2007
  if self.runtime_id:
1761
2008
  try:
1762
- await self.client.delete(
1763
- f"{self.server_url}/api/v1/runtimes/{self.runtime_id}",
2009
+ # Use deregister endpoint (no admin required) instead of DELETE
2010
+ await self.client.post(
2011
+ f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/deregister",
1764
2012
  timeout=5,
1765
2013
  )
1766
2014
  except Exception:
@@ -1791,6 +2039,14 @@ class RuntimeDaemon:
1791
2039
  self.daemon_id = settings.DAEMON_ID or self.hardware_id or platform.node()
1792
2040
  self.server_urls = settings.get_daemon_server_urls()
1793
2041
  self.api_token = settings.DAEMON_API_TOKEN
2042
+ # If no explicit token, try user JWT from ~/.forgexa/token (written by `forgexa login`)
2043
+ if not self.api_token:
2044
+ token_path = Path.home() / ".forgexa" / "token"
2045
+ try:
2046
+ self.api_token = token_path.read_text().strip() if token_path.exists() else ""
2047
+ except OSError:
2048
+ self.api_token = ""
2049
+ # _mint_local_dev_token() is called in start() if still empty
1794
2050
  self.max_concurrent = settings.DAEMON_MAX_CONCURRENT
1795
2051
  self.poll_interval = settings.DAEMON_POLL_INTERVAL
1796
2052
  self.heartbeat_interval = settings.DAEMON_HEARTBEAT_INTERVAL
@@ -1806,9 +2062,120 @@ class RuntimeDaemon:
1806
2062
  self.agent_discovery = AgentDiscovery()
1807
2063
  self.workspace_manager = WorkspaceManager(self.workspaces_root)
1808
2064
  self.process_manager = ProcessManager()
2065
+ self._lock_file = None # File lock to prevent multiple daemon instances
2066
+
2067
+ @staticmethod
2068
+ async def _mint_local_dev_token() -> str:
2069
+ """Mint a JWT for local development when no explicit token is configured.
2070
+
2071
+ Only works when running in-process with the backend package (``make daemon``).
2072
+ Queries the first active user and creates a long-lived access token so the
2073
+ runtime gets proper owner_id + organization_id assignment.
2074
+ """
2075
+ try:
2076
+ from app.core.security import create_access_token
2077
+ from app.database import engine
2078
+ from datetime import timedelta
2079
+ from sqlalchemy import text
2080
+
2081
+ async with engine.connect() as conn:
2082
+ row = await conn.execute(
2083
+ text("SELECT id FROM users WHERE is_active = true ORDER BY created_at LIMIT 1")
2084
+ )
2085
+ r = row.first()
2086
+ uid = str(r[0]) if r else None
2087
+
2088
+ if uid:
2089
+ token = create_access_token({"sub": uid}, expires_delta=timedelta(days=30))
2090
+ logger.info("Minted local-dev JWT for user %s (no DAEMON_API_TOKEN configured)", uid)
2091
+ return token
2092
+ logger.warning("No active users in database; cannot mint local-dev token")
2093
+ return ""
2094
+ except Exception as e:
2095
+ logger.debug("Cannot mint local-dev token (standalone mode?): %s", e)
2096
+ return ""
2097
+
2098
+ def _acquire_lock(self):
2099
+ """Acquire an exclusive file lock to prevent multiple daemon instances.
2100
+
2101
+ If another daemon is already running, try to kill it and take over.
2102
+ This handles orphaned processes from crashed desktop apps, duplicate
2103
+ CLI starts, etc.
2104
+ """
2105
+ if fcntl is None:
2106
+ # Windows: skip file locking (fcntl not available)
2107
+ logger.info("File locking not available on this platform; skipping")
2108
+ return
2109
+
2110
+ lock_path = Path.home() / ".forgexa" / "daemon" / "daemon.lock"
2111
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
2112
+
2113
+ self._lock_file = open(lock_path, "w")
2114
+
2115
+ try:
2116
+ fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
2117
+ except (IOError, OSError):
2118
+ # Lock held by another process — try to read its PID and kill it
2119
+ try:
2120
+ old_pid = int(lock_path.read_text().strip())
2121
+ logger.warning("Another daemon is running (PID %d). Sending SIGTERM...", old_pid)
2122
+ os.kill(old_pid, signal.SIGTERM)
2123
+ # Wait up to 5 seconds for it to exit
2124
+ for _ in range(50):
2125
+ try:
2126
+ os.kill(old_pid, 0) # Check if still alive
2127
+ time.sleep(0.1)
2128
+ except ProcessLookupError:
2129
+ break
2130
+ else:
2131
+ # Still alive after 5s — force kill
2132
+ logger.warning("Daemon PID %d did not exit, sending SIGKILL", old_pid)
2133
+ try:
2134
+ os.kill(old_pid, signal.SIGKILL)
2135
+ time.sleep(0.2)
2136
+ except ProcessLookupError:
2137
+ pass
2138
+ except (ValueError, ProcessLookupError, FileNotFoundError, PermissionError):
2139
+ pass
2140
+
2141
+ # Retry the lock
2142
+ try:
2143
+ fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
2144
+ except (IOError, OSError):
2145
+ logger.error("Cannot acquire daemon lock — another instance may still be running")
2146
+ raise SystemExit(1)
2147
+
2148
+ # Write our PID to the lock file for reference
2149
+ self._lock_file.seek(0)
2150
+ self._lock_file.truncate()
2151
+ self._lock_file.write(str(os.getpid()))
2152
+ self._lock_file.flush()
2153
+ logger.info("Acquired exclusive daemon lock (pid=%d)", os.getpid())
2154
+
2155
+ # Also clean up CLI daemon PID file if it points to a dead process
2156
+ cli_pid_file = Path.home() / ".forgexa-daemon.pid"
2157
+ if cli_pid_file.exists():
2158
+ try:
2159
+ cli_pid = int(cli_pid_file.read_text().strip())
2160
+ if cli_pid != os.getpid():
2161
+ os.kill(cli_pid, signal.SIGTERM)
2162
+ logger.info("Killed stale CLI daemon (PID %d)", cli_pid)
2163
+ except (ValueError, ProcessLookupError):
2164
+ pass
2165
+ try:
2166
+ cli_pid_file.unlink(missing_ok=True)
2167
+ except OSError:
2168
+ pass
1809
2169
 
1810
2170
  async def start(self):
1811
2171
  """Main entry point."""
2172
+ # Prevent multiple daemon instances on the same machine
2173
+ self._acquire_lock()
2174
+
2175
+ # Mint a local-dev token if no token is available yet
2176
+ if not self.api_token:
2177
+ self.api_token = await self._mint_local_dev_token()
2178
+
1812
2179
  logger.info("Starting RuntimeDaemon (id=%s)", self.daemon_id)
1813
2180
  logger.info("Server URLs: %s", ", ".join(self.server_urls))
1814
2181
  logger.info("Workspaces root: %s", self.workspaces_root)
@@ -2097,7 +2464,10 @@ class RuntimeDaemon:
2097
2464
 
2098
2465
  # 5. Auto-commit and push if changes exist
2099
2466
  if result.status == "success":
2100
- await self._auto_commit(workspace_path, task)
2467
+ commit_result = await self._auto_commit(workspace_path, task)
2468
+ if commit_result:
2469
+ # Propagate push/commit errors in metrics so they're visible
2470
+ result.metrics.update(commit_result)
2101
2471
  # Re-collect git info after commit (compare with parent)
2102
2472
  post_commit_git = await self.process_manager._collect_git_info_vs_parent(workspace_path)
2103
2473
  # Merge: use the pre-commit file list if post-commit is empty
@@ -2425,7 +2795,7 @@ class RuntimeDaemon:
2425
2795
  except Exception as e:
2426
2796
  logger.warning("Failed to read analysis artifact %s: %s", fname, e)
2427
2797
 
2428
- async def _auto_commit(self, workspace_path: Path, task: TaskInfo):
2798
+ async def _auto_commit(self, workspace_path: Path, task: TaskInfo) -> dict:
2429
2799
  """Auto-commit and push agent changes.
2430
2800
 
2431
2801
  Some agents (e.g. claude-code) commit changes internally, so we must
@@ -2436,6 +2806,8 @@ class RuntimeDaemon:
2436
2806
  merge conflicts. If rebase fails (true conflict), we fall back to a
2437
2807
  merge. If the merge also fails, we invoke the same AI agent to
2438
2808
  resolve the remaining conflicts automatically.
2809
+
2810
+ Returns a dict with commit/push status info (empty on success).
2439
2811
  """
2440
2812
  git = self.workspace_manager._git
2441
2813
  default_branch = task.project.get("default_branch", "main")
@@ -2506,11 +2878,35 @@ class RuntimeDaemon:
2506
2878
  # changes, dramatically reducing PR merge conflicts.
2507
2879
  await self._rebase_onto_latest(workspace_path, default_branch, task, project_key)
2508
2880
 
2881
+ # ── Verify we're on the correct branch before pushing ──
2882
+ current_branch = ""
2883
+ try:
2884
+ current_branch = (await git(
2885
+ "rev-parse", "--abbrev-ref", "HEAD", cwd=workspace_path,
2886
+ )).strip()
2887
+ except RuntimeError:
2888
+ pass
2889
+
2890
+ expected_branch = ""
2891
+ if task.requirement_key:
2892
+ expected_branch = f"feature/{task.requirement_key}"
2893
+ if current_branch == default_branch:
2894
+ logger.error(
2895
+ "CRITICAL: About to push to default branch '%s' instead of "
2896
+ "feature branch '%s' — aborting push to prevent pollution",
2897
+ current_branch, expected_branch or "(unknown)",
2898
+ )
2899
+ return {"push_error": f"Would push to default branch {current_branch}"}
2900
+
2509
2901
  # ── Push ──
2510
- await self._push_branch(workspace_path, project_key)
2902
+ push_error = await self._push_branch(workspace_path, project_key)
2903
+ if push_error:
2904
+ return {"push_error": push_error}
2905
+ return {}
2511
2906
 
2512
2907
  except Exception as e:
2513
2908
  logger.warning("Auto-commit failed: %s", e)
2909
+ return {"commit_error": str(e)}
2514
2910
 
2515
2911
  async def _collect_staged_diff_stats(self, cwd: Path) -> dict:
2516
2912
  """Collect staged diff stats for building a rich commit message."""
@@ -2973,8 +3369,8 @@ class RuntimeDaemon:
2973
3369
  except RuntimeError:
2974
3370
  await git("reset", "--hard", "HEAD", cwd=workspace_path)
2975
3371
 
2976
- async def _push_branch(self, workspace_path: Path, project_key: str = "default"):
2977
- """Push the current branch to origin."""
3372
+ async def _push_branch(self, workspace_path: Path, project_key: str = "default") -> str | None:
3373
+ """Push the current branch to origin. Returns error message on failure, None on success."""
2978
3374
  git = self.workspace_manager._git
2979
3375
  try:
2980
3376
  # Get current branch name
@@ -2999,11 +3395,14 @@ class RuntimeDaemon:
2999
3395
  )
3000
3396
  logger.info("Pushed branch %s to origin", branch)
3001
3397
  except RuntimeError as exc:
3002
- logger.warning("Push failed: %s", exc)
3398
+ logger.error("Push failed for branch %s: %s", branch, exc)
3399
+ return f"Push failed: {exc}"
3003
3400
  else:
3004
3401
  logger.info("No unpushed commits on %s", branch)
3402
+ return None
3005
3403
  except Exception as e:
3006
- logger.warning("Push failed: %s", e)
3404
+ logger.error("Push failed: %s", e)
3405
+ return f"Push failed: {e}"
3007
3406
 
3008
3407
  def _agents_as_dicts(self) -> list[dict]:
3009
3408
  return [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.1.6
3
+ Version: 1.2.2
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: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.1.6"
3
+ version = "1.2.2"
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 = { text = "MIT" }
File without changes
File without changes