forgexa-cli 1.11.1__tar.gz → 1.11.3__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.11.1
3
+ Version: 1.11.3
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.11.1"
2
+ __version__ = "1.11.3"
@@ -360,7 +360,41 @@ except (ImportError, ModuleNotFoundError):
360
360
 
361
361
  @property
362
362
  def GIT_CLONE_TIMEOUT(self) -> int:
363
- return int(os.environ.get("GIT_CLONE_TIMEOUT", "600"))
363
+ return int(os.environ.get("GIT_CLONE_TIMEOUT", "1800"))
364
+
365
+ @property
366
+ def GIT_FETCH_TIMEOUT(self) -> int:
367
+ """Timeout (seconds) for incremental git fetch operations.
368
+
369
+ Separate from GIT_CLONE_TIMEOUT because fetch on an existing repo must
370
+ download all accumulated delta objects since the last sync — this can
371
+ easily exceed 600 s for large active repos (e.g. HP SI develop branch).
372
+
373
+ Precedence:
374
+ 1. GIT_FETCH_TIMEOUT env var (explicit override)
375
+ 2. GIT_CLONE_TIMEOUT env var (backwards compat — if operator raised
376
+ the clone timeout they likely want fetches to match)
377
+ 3. 1800 s default (30 min — safe ceiling for large enterprise repos)
378
+ """
379
+ env_fetch = os.environ.get("GIT_FETCH_TIMEOUT")
380
+ if env_fetch:
381
+ return int(env_fetch)
382
+ env_clone = os.environ.get("GIT_CLONE_TIMEOUT")
383
+ if env_clone:
384
+ return int(env_clone)
385
+ return 1800
386
+
387
+ @property
388
+ def GIT_CLONE_FILTER(self) -> str:
389
+ """Optional partial-clone filter, e.g. 'blob:none' for blobless clones.
390
+
391
+ When set, git clone/fetch only downloads commits and trees — blobs are
392
+ fetched on demand during checkout. This dramatically reduces clone and
393
+ incremental-fetch time on large repos. Requires Git 2.22+ and a
394
+ compatible server (GitHub, GitLab, Bitbucket Server 7+).
395
+ Leave empty (default) to use a full clone.
396
+ """
397
+ return os.environ.get("GIT_CLONE_FILTER", "").strip()
364
398
 
365
399
  @property
366
400
  def AGENT_MAX_OUTPUT_SIZE(self) -> int:
@@ -404,7 +438,7 @@ except (ImportError, ModuleNotFoundError):
404
438
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
405
439
  # Kept in sync with pyproject.toml version via bump-version.sh.
406
440
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
407
- DAEMON_VERSION = "1.11.1"
441
+ DAEMON_VERSION = "1.11.3"
408
442
 
409
443
 
410
444
  def _detect_client_type() -> str:
@@ -1047,6 +1081,12 @@ class WorkspaceManager:
1047
1081
  self._ssh_keys: dict[str, str] = {} # project_key -> PEM key content
1048
1082
  self._http_auth: dict[str, dict[str, str]] = {} # project_key -> provider/token/username
1049
1083
 
1084
+ # Per-project asyncio locks: prevent concurrent _create_worktree calls for the
1085
+ # same project from racing on _main (shutil.rmtree / git clone / worktree add).
1086
+ # _worktree_locks_mu guards mutations of the dict itself.
1087
+ self._worktree_locks: dict[str, asyncio.Lock] = {}
1088
+ self._worktree_locks_mu: asyncio.Lock = asyncio.Lock()
1089
+
1050
1090
  # One-time migration: move legacy project dirs from root/ to projects/
1051
1091
  self._migrate_legacy_project_dirs()
1052
1092
 
@@ -1273,20 +1313,63 @@ class WorkspaceManager:
1273
1313
  await self._git("checkout", "-b", branch_name, cwd=ws_path)
1274
1314
  return ws_path
1275
1315
 
1276
- async def _is_healthy_worktree(self, ws_path: Path) -> bool:
1277
- """Check if a git worktree directory is valid and functional."""
1316
+ async def _is_healthy_worktree(self, ws_path: Path, main_repo: Path | None = None) -> bool:
1317
+ """Check if a git worktree directory is a valid *linked* worktree.
1318
+
1319
+ A linked git worktree has .git as a FILE containing a gitdir pointer to
1320
+ main_repo/.git/worktrees/<name>. A standalone clone has .git as a
1321
+ DIRECTORY (the full object store) — that is NOT a linked worktree and
1322
+ must not be treated as one, because:
1323
+ • it may point to a different remote (e.g. Bitbucket fallback clone
1324
+ while _main points to GitHub)
1325
+ • the default_branch may differ between remotes (develop vs master)
1326
+ • force-resetting it would leave the tree in a state where the entire
1327
+ codebase appears as "changed" in the analysis commit diff
1328
+ Returning False triggers removal + fresh worktree creation from _main.
1329
+
1330
+ When main_repo is provided, also verifies that the gitdir pointer is
1331
+ inside main_repo/.git/worktrees/, rejecting foreign-repo worktrees
1332
+ whose gitdir happens to exist but belongs to a different clone (L4 fix).
1333
+ """
1278
1334
  git_file = ws_path / ".git"
1279
1335
  if not git_file.exists():
1280
1336
  return False
1281
1337
  if git_file.is_dir():
1282
- # It's a real .git directory (init'd repo), not a worktree
1283
- return True
1338
+ # .git is a directory standalone clone, NOT a linked worktree.
1339
+ # Warn if the clone has uncommitted changes so the operator knows
1340
+ # what will be lost when it is removed (L3 fix).
1341
+ try:
1342
+ st_proc = await asyncio.create_subprocess_exec(
1343
+ "git", "status", "--porcelain",
1344
+ cwd=str(ws_path),
1345
+ stdout=asyncio.subprocess.PIPE,
1346
+ stderr=asyncio.subprocess.PIPE,
1347
+ )
1348
+ st_out, _ = await asyncio.wait_for(st_proc.communicate(), timeout=10)
1349
+ if st_proc.returncode == 0 and st_out.decode().strip():
1350
+ logger.warning(
1351
+ "Standalone clone at %s has uncommitted changes that WILL BE LOST "
1352
+ "when it is replaced by a linked worktree: %s",
1353
+ ws_path, st_out.decode().strip()[:300],
1354
+ )
1355
+ except Exception:
1356
+ pass
1357
+ return False
1284
1358
  # Worktree: .git is a file containing "gitdir: /path/to/main/.git/worktrees/..."
1285
1359
  try:
1286
1360
  content = git_file.read_text().strip()
1287
1361
  if content.startswith("gitdir: "):
1288
1362
  gitdir = Path(content.split("gitdir: ", 1)[1].strip())
1289
- return gitdir.exists()
1363
+ if not gitdir.exists():
1364
+ return False
1365
+ if main_repo is not None:
1366
+ # Reject gitdirs that point into a different repo's worktrees dir
1367
+ expected_wt_root = (main_repo / ".git" / "worktrees").resolve()
1368
+ try:
1369
+ gitdir.resolve().relative_to(expected_wt_root)
1370
+ except ValueError:
1371
+ return False
1372
+ return True
1290
1373
  except OSError:
1291
1374
  pass
1292
1375
  return False
@@ -1307,6 +1390,47 @@ class WorkspaceManager:
1307
1390
  # Remove the broken worktree directory
1308
1391
  shutil.rmtree(ws_path, ignore_errors=True)
1309
1392
 
1393
+ async def _safe_rmtree_main(self, main_repo: Path) -> None:
1394
+ """Remove _main repo, first evicting all linked worktrees to prevent orphans.
1395
+
1396
+ A linked worktree's .git file contains «gitdir: <main>/.git/worktrees/<n>».
1397
+ Deleting _main without first removing linked worktrees leaves those
1398
+ directories with a dangling .git pointer — `_is_healthy_worktree` then
1399
+ flags them broken and `_remove_broken_worktree` calls shutil.rmtree on
1400
+ any active workspace, silently discarding uncommitted work.
1401
+
1402
+ Strategy:
1403
+ 1. Use git worktree list --porcelain to enumerate linked worktrees.
1404
+ 2. shutil.rmtree each linked worktree directory (excluding _main itself).
1405
+ 3. shutil.rmtree _main.
1406
+ If git is unavailable (already corrupted), fall back to a directory scan.
1407
+ """
1408
+ import shutil as _shutil
1409
+ if not main_repo.exists():
1410
+ return
1411
+ linked: list[Path] = []
1412
+ try:
1413
+ raw = await self._git("worktree", "list", "--porcelain", cwd=main_repo)
1414
+ for line in raw.splitlines():
1415
+ if line.startswith("worktree "):
1416
+ wt_path = Path(line.split(" ", 1)[1].strip())
1417
+ if wt_path != main_repo and wt_path.exists():
1418
+ linked.append(wt_path)
1419
+ except Exception:
1420
+ # Fallback: scan project_dir for sibling directories that look like
1421
+ # linked worktrees (contain a .git file, not a .git directory).
1422
+ project_dir = main_repo.parent
1423
+ for child in project_dir.iterdir():
1424
+ if child == main_repo or not child.is_dir():
1425
+ continue
1426
+ git_file = child / ".git"
1427
+ if git_file.is_file(): # linked worktree marker
1428
+ linked.append(child)
1429
+ for wt in linked:
1430
+ logger.info("Evicting linked worktree %s before deleting _main", wt)
1431
+ _shutil.rmtree(wt, ignore_errors=True)
1432
+ _shutil.rmtree(main_repo, ignore_errors=True)
1433
+
1310
1434
  async def _detect_unrelated_histories(self, repo_path: Path, project_key: str) -> bool:
1311
1435
  """Detect whether local clone has diverged from remote due to history rewrite.
1312
1436
 
@@ -1315,14 +1439,20 @@ class WorkspaceManager:
1315
1439
  the old SHAs in its object store, making fetch/reset/merge fail in
1316
1440
  cryptic ways.
1317
1441
 
1318
- Strategy: ask git whether the local HEAD commit object is reachable in
1319
- the remote graph. We use `git ls-remote` to get the remote HEAD SHA,
1320
- then check if that SHA exists locally. If the remote HEAD does NOT
1321
- exist locally, histories are definitely unrelated.
1322
-
1323
- Additionally, if the repo has a shallow marker but the remote default
1324
- branch has diverged past the shallow grafts, `git fetch` itself will
1325
- indicate problems.
1442
+ Strategy:
1443
+ 1. Get the local HEAD SHA via ``git rev-parse HEAD``.
1444
+ 2. Guard against false positives: run ``git rev-list --count
1445
+ --right-only origin...HEAD`` to count local-only (unpushed) commits.
1446
+ If there are any local-only commits, the HEAD won't appear in remote
1447
+ tracking branches this is expected and NOT a history rewrite.
1448
+ 3. Run ``git branch -r --contains <local_head>`` to check whether the
1449
+ local HEAD is reachable from any remote tracking branch. If no
1450
+ branch contains it (and there are no local-only commits), the
1451
+ histories have diverged and this method returns True.
1452
+
1453
+ Note: ``git ls-remote`` is intentionally NOT used here because it
1454
+ requires a network round-trip and we can determine divergence entirely
1455
+ from the locally-cached remote-tracking refs.
1326
1456
  """
1327
1457
  try:
1328
1458
  # Get the local HEAD SHA
@@ -1339,26 +1469,69 @@ class WorkspaceManager:
1339
1469
  if not local_head:
1340
1470
  return False
1341
1471
 
1342
- # Get the remote HEAD SHA via ls-remote (no network for local check)
1343
- # Try to see if the remote HEAD is in local object store
1344
- # If git cat-file -e <remote_sha> succeeds, remote HEAD is known locally
1345
- # (histories still share commits). Otherwise, fully diverged.
1472
+ # Determine whether the local HEAD is reachable from any remote
1473
+ # tracking branch. We use `git branch -r --contains <local_head>`
1474
+ # which lists remote tracking branches that contain that commit.
1475
+ # If none, it's unrelated.
1346
1476
  #
1347
- # However, after a history rewrite the remote HEAD is a brand-new SHA,
1348
- # and the local object store only has old SHAs. So we check the other
1349
- # direction: does the local HEAD exist on the remote at all?
1350
- # We use `git branch -r --contains <local_head>` which lists remote
1351
- # tracking branches that contain that commit. If none, it's unrelated.
1477
+ # IMPORTANT: before calling this check we first verify that the local HEAD
1478
+ # is not a purely local (unpushed) commit. A fresh feature-branch commit
1479
+ # that has never been pushed will not appear in any remote tracking branch,
1480
+ # which would produce a false "unrelated histories" verdict. We detect
1481
+ # local-only commits by comparing HEAD against the merge-base with the
1482
+ # remote default branch; if HEAD itself IS the merge-base (or an ancestor),
1483
+ # we are on the default branch tip and there are no local-only commits.
1484
+ # If HEAD is ahead of the merge-base, we count local-only commits — any
1485
+ # count > 0 means we should NOT call the branch-r check.
1486
+ try:
1487
+ ahead_proc = await asyncio.create_subprocess_exec(
1488
+ "git", "rev-list", "--count", "--right-only", "origin...HEAD",
1489
+ cwd=str(repo_path),
1490
+ stdout=asyncio.subprocess.PIPE,
1491
+ stderr=asyncio.subprocess.PIPE,
1492
+ )
1493
+ ahead_out, _ = await asyncio.wait_for(ahead_proc.communicate(), timeout=10)
1494
+ if ahead_proc.returncode == 0:
1495
+ local_ahead = int(ahead_out.decode().strip() or "0")
1496
+ if local_ahead > 0:
1497
+ # There are commits in local HEAD that are not yet on any remote
1498
+ # tracking branch. This is NOT a history-rewrite scenario.
1499
+ logger.debug(
1500
+ "_detect_unrelated_histories: %s has %d local-only commit(s) — "
1501
+ "skipping unrelated-history check to avoid false positive",
1502
+ repo_path, local_ahead,
1503
+ )
1504
+ return False
1505
+ except Exception:
1506
+ pass # If the ahead count fails, proceed with the branch-r check
1507
+
1352
1508
  check_proc = await asyncio.create_subprocess_exec(
1353
1509
  "git", "branch", "-r", "--contains", local_head,
1354
1510
  cwd=str(repo_path),
1355
1511
  stdout=asyncio.subprocess.PIPE,
1356
1512
  stderr=asyncio.subprocess.PIPE,
1357
1513
  )
1358
- out, _ = await asyncio.wait_for(check_proc.communicate(), timeout=10)
1514
+ out, err = await asyncio.wait_for(check_proc.communicate(), timeout=10)
1359
1515
  if check_proc.returncode != 0:
1360
- # Command failed (e.g. invalid object) history is broken
1361
- return True
1516
+ # Distinguish genuine object-store corruption from transient
1517
+ # command errors (permission denied, IO, etc.).
1518
+ # Only treat the repo as broken if git explicitly reports a
1519
+ # bad/unknown object; other errors are not diagnostic (L6 fix).
1520
+ err_text = err.decode().lower()
1521
+ if any(kw in err_text for kw in ("bad object", "unknown object", "not a valid object", "malformed")):
1522
+ logger.warning(
1523
+ "_detect_unrelated_histories: 'git branch -r --contains' reports broken "
1524
+ "object at %s: %s",
1525
+ repo_path, err.decode().strip()[:200],
1526
+ )
1527
+ return True
1528
+ # Non-diagnostic error (IO, permissions, etc.) — safe to assume related
1529
+ logger.warning(
1530
+ "_detect_unrelated_histories: 'git branch -r --contains' failed at %s "
1531
+ "(non-diagnostic, assuming related histories): %s",
1532
+ repo_path, err.decode().strip()[:200],
1533
+ )
1534
+ return False
1362
1535
  remote_branches = out.decode().strip()
1363
1536
  if not remote_branches:
1364
1537
  # Local HEAD is not reachable from any remote branch — unrelated
@@ -1372,10 +1545,118 @@ class WorkspaceManager:
1372
1545
  pass
1373
1546
  return False
1374
1547
 
1548
+ async def _branch_has_impl_commits(
1549
+ self, ws_path: Path, branch_name: str, default_branch: str,
1550
+ ) -> bool:
1551
+ """Return True if the remote feature branch contains implementation commits.
1552
+
1553
+ An "implementation commit" is any commit whose message does NOT start with
1554
+ a known analysis-phase prefix. This distinguishes branches that only have
1555
+ analysis documents (safe to reset on fresh re-analysis) from branches that
1556
+ also carry design/coding/testing work (must never be reset).
1557
+
1558
+ Known analysis-only prefixes (case-insensitive):
1559
+ • "analysis(" — auto-commit from analysis node
1560
+ • "docs(analysis" — conventional-commit form (docs(analysis): …)
1561
+ • "cleanup: wipe analysis" — pre-analysis directory wipe
1562
+ • "chore(analysis)" — normalised cleanup variant
1563
+ • "initial commit" — repo bootstrap commit (not implementation)
1564
+
1565
+ Any commit whose subject does not match one of these prefixes is treated as
1566
+ an implementation commit (fail-safe: assume the worst to protect code).
1567
+ """
1568
+ _ANALYSIS_ONLY_PREFIXES = (
1569
+ "analysis(",
1570
+ "docs(analysis",
1571
+ "cleanup: wipe analysis",
1572
+ "chore(analysis)",
1573
+ "refactor(analysis",
1574
+ "style(analysis",
1575
+ "test(analysis",
1576
+ "ci(analysis",
1577
+ "build(analysis",
1578
+ "revert(analysis",
1579
+ "initial commit",
1580
+ )
1581
+ try:
1582
+ raw = await self._git(
1583
+ "log", "--format=%s",
1584
+ f"origin/{default_branch}..origin/{branch_name}",
1585
+ cwd=ws_path,
1586
+ )
1587
+ except RuntimeError:
1588
+ # Cannot determine — assume implementation commits exist (fail-safe).
1589
+ return True
1590
+
1591
+ subjects = [line.strip() for line in raw.splitlines() if line.strip()]
1592
+ if not subjects:
1593
+ return False # No commits ahead of default branch
1594
+
1595
+ for subject in subjects:
1596
+ lower = subject.lower()
1597
+ if not any(lower.startswith(p) for p in _ANALYSIS_ONLY_PREFIXES):
1598
+ return True # Found at least one implementation commit
1599
+
1600
+ # All subjects look analysis-only. Do a secondary full-body scan as a
1601
+ # safety net: some agents write the analysis subject but add implementation
1602
+ # markers (feat:, fix:, etc.) in the commit body paragraphs (L2 fix).
1603
+ _IMPL_BODY_PREFIXES = (
1604
+ "feat(", "feat:",
1605
+ "fix(", "fix:",
1606
+ "refactor(", "perf(", "perf:",
1607
+ )
1608
+ try:
1609
+ raw_body = await self._git(
1610
+ "log", "--format=---GITCOMMIT---%n%B",
1611
+ f"origin/{default_branch}..origin/{branch_name}",
1612
+ cwd=ws_path,
1613
+ )
1614
+ for commit_block in raw_body.split("---GITCOMMIT---"):
1615
+ lines = [l.strip() for l in commit_block.splitlines() if l.strip()]
1616
+ # Skip first non-empty line (subject — already checked above)
1617
+ for line in lines[1:]:
1618
+ lower_line = line.lower()
1619
+ for pfx in _IMPL_BODY_PREFIXES:
1620
+ if lower_line.startswith(pfx) and "(analysis" not in lower_line:
1621
+ logger.debug(
1622
+ "_branch_has_impl_commits: impl marker '%s' found in commit "
1623
+ "body of %s/%s", pfx, ws_path, branch_name,
1624
+ )
1625
+ return True
1626
+ except RuntimeError:
1627
+ pass # Body scan failed; trust subject-only result
1628
+
1629
+ return False # All commits are analysis-only
1630
+
1631
+ async def _get_project_lock(self, project_dir: Path) -> asyncio.Lock:
1632
+ """Return (creating if needed) the per-project asyncio.Lock for _create_worktree."""
1633
+ key = str(project_dir)
1634
+ async with self._worktree_locks_mu:
1635
+ if key not in self._worktree_locks:
1636
+ self._worktree_locks[key] = asyncio.Lock()
1637
+ return self._worktree_locks[key]
1638
+
1375
1639
  async def _create_worktree(
1376
1640
  self, project_dir: Path, repo_url: str, default_branch: str,
1377
1641
  workspace_key: str, branch_name: str, *, fresh_start: bool = False,
1378
1642
  project_key: str = "default", expect_branch: bool = False,
1643
+ ) -> Path:
1644
+ # Serialize all _create_worktree calls for the same project to prevent
1645
+ # concurrent tasks from racing on _main (shutil.rmtree / git clone / worktree add).
1646
+ project_lock = await self._get_project_lock(project_dir)
1647
+ async with project_lock:
1648
+ return await self._create_worktree_impl(
1649
+ project_dir, repo_url, default_branch,
1650
+ workspace_key, branch_name,
1651
+ fresh_start=fresh_start,
1652
+ project_key=project_key,
1653
+ expect_branch=expect_branch,
1654
+ )
1655
+
1656
+ async def _create_worktree_impl(
1657
+ self, project_dir: Path, repo_url: str, default_branch: str,
1658
+ workspace_key: str, branch_name: str, *, fresh_start: bool = False,
1659
+ project_key: str = "default", expect_branch: bool = False,
1379
1660
  ) -> Path:
1380
1661
  main_repo = project_dir / "_main"
1381
1662
  ws_path = project_dir / workspace_key
@@ -1383,7 +1664,7 @@ class WorkspaceManager:
1383
1664
  if ws_path.exists():
1384
1665
  # Validate that this worktree is healthy (its .git file points to a
1385
1666
  # valid main repo). Worktrees break when directories are moved.
1386
- if not await self._is_healthy_worktree(ws_path):
1667
+ if not await self._is_healthy_worktree(ws_path, main_repo=main_repo):
1387
1668
  logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
1388
1669
  await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
1389
1670
  else:
@@ -1394,7 +1675,8 @@ class WorkspaceManager:
1394
1675
  # origin/{branch_name} is available for checkout/reset.
1395
1676
  _last_sync_err: str = ""
1396
1677
  try:
1397
- await self._git("fetch", "origin", cwd=ws_path, project_key=project_key)
1678
+ await self._git("fetch", "origin", cwd=ws_path,
1679
+ timeout=settings.GIT_FETCH_TIMEOUT, project_key=project_key)
1398
1680
  except RuntimeError as _pre_fe:
1399
1681
  logger.warning(
1400
1682
  "fetch origin failed for worktree %s: %s "
@@ -1402,11 +1684,12 @@ class WorkspaceManager:
1402
1684
  ws_path, _pre_fe,
1403
1685
  )
1404
1686
  _last_sync_err = str(_pre_fe)[:300]
1687
+ _branch_fetch_ok = True
1405
1688
  try:
1406
1689
  await self._git(
1407
1690
  "fetch", "origin",
1408
1691
  f"{branch_name}:refs/remotes/origin/{branch_name}",
1409
- cwd=ws_path, project_key=project_key,
1692
+ cwd=ws_path, timeout=settings.GIT_FETCH_TIMEOUT, project_key=project_key,
1410
1693
  )
1411
1694
  except RuntimeError as _pre_fe2:
1412
1695
  logger.warning(
@@ -1415,15 +1698,36 @@ class WorkspaceManager:
1415
1698
  branch_name, ws_path, _pre_fe2,
1416
1699
  )
1417
1700
  _last_sync_err = str(_pre_fe2)[:300]
1701
+ _branch_fetch_ok = False
1702
+
1703
+ if fresh_start and not _branch_fetch_ok:
1704
+ # Cannot determine remote branch state — prohibit reset to avoid
1705
+ # silently destroying implementation commits that exist on the remote
1706
+ # but could not be fetched (auth/network failure). Conservative
1707
+ # stance: treat unverified state as "has implementation work".
1708
+ logger.warning(
1709
+ "Fresh start blocked for %s: branch fetch failed (%s) — "
1710
+ "remote state unknown, refusing to reset to avoid data loss",
1711
+ branch_name, _last_sync_err[:200],
1712
+ )
1713
+ fresh_start = False
1418
1714
 
1419
1715
  if fresh_start:
1420
- # Safety check: if the branch already exists on remote with
1421
- # commits beyond the default branch, do NOT reset to
1422
- # origin/default_branch — that would destroy prior work.
1423
- # This guards against accidental branch destruction when
1424
- # analysis_mode is incorrectly set to "fresh" for
1425
- # verification or iterative nodes.
1426
- branch_has_remote_commits = False
1716
+ # Safety check: if the branch already has IMPLEMENTATION commits
1717
+ # beyond the default branch, do NOT reset to origin/default_branch
1718
+ # — that would destroy prior design/coding/testing work.
1719
+ #
1720
+ # Previous behaviour blocked the reset whenever *any* remote commit
1721
+ # existed, including analysis-only commits (PRD.md, cleanup wipes).
1722
+ # That was overly conservative: a branch whose only commits are
1723
+ # analysis documents is safe to reset for a fresh re-analysis
1724
+ # because no code has been written yet.
1725
+ #
1726
+ # The new check distinguishes:
1727
+ # • analysis-only commits → safe to reset → allow fresh start
1728
+ # • implementation commits (design/coding/testing) → block reset
1729
+ branch_has_impl = False
1730
+ ahead_count = 0
1427
1731
  try:
1428
1732
  result = await self._git(
1429
1733
  "rev-list", "--count",
@@ -1431,19 +1735,30 @@ class WorkspaceManager:
1431
1735
  cwd=ws_path,
1432
1736
  )
1433
1737
  ahead_count = int(result.strip()) if result.strip() else 0
1434
- branch_has_remote_commits = ahead_count > 0
1435
1738
  except (RuntimeError, ValueError):
1436
1739
  pass # Branch may not exist on remote yet
1437
1740
 
1438
- if branch_has_remote_commits:
1741
+ if ahead_count > 0:
1742
+ branch_has_impl = await self._branch_has_impl_commits(
1743
+ ws_path, branch_name, default_branch,
1744
+ )
1745
+
1746
+ if branch_has_impl:
1439
1747
  logger.warning(
1440
- "Fresh start requested for %s but remote branch has %d commit(s) "
1441
- "ahead of %s — switching to safe sync instead of resetting to "
1442
- "avoid destroying prior work",
1443
- branch_name, ahead_count, default_branch,
1748
+ "Fresh start requested for %s but remote branch has implementation "
1749
+ "commit(s) ahead of %s — switching to safe sync to avoid "
1750
+ "destroying prior implementation work",
1751
+ branch_name, default_branch,
1444
1752
  )
1445
1753
  # Override: fall through to the non-fresh sync path
1446
1754
  fresh_start = False
1755
+ elif ahead_count > 0:
1756
+ # Branch has commits but they are all analysis-only (safe to reset).
1757
+ logger.info(
1758
+ "Fresh start for %s: remote branch has %d analysis-only commit(s) "
1759
+ "ahead of %s — resetting is safe (no implementation work present)",
1760
+ branch_name, ahead_count, default_branch,
1761
+ )
1447
1762
 
1448
1763
  if fresh_start:
1449
1764
  logger.info("Fresh start: resetting %s to origin/%s", ws_path, default_branch)
@@ -1561,7 +1876,7 @@ class WorkspaceManager:
1561
1876
  ws_path,
1562
1877
  )
1563
1878
  await self._remove_broken_worktree(main_repo, ws_path, workspace_key)
1564
- shutil.rmtree(main_repo, ignore_errors=True)
1879
+ await self._safe_rmtree_main(main_repo)
1565
1880
  raise RuntimeError(
1566
1881
  f"Repository history was rewritten (e.g. large-file cleanup). "
1567
1882
  f"Stale local clone discarded. "
@@ -1602,39 +1917,79 @@ class WorkspaceManager:
1602
1917
  if not main_repo.exists():
1603
1918
  await self._git(
1604
1919
  "clone", "--single-branch", "--no-tags",
1920
+ "--branch", default_branch,
1605
1921
  repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
1606
1922
  )
1607
1923
  else:
1608
- # Use targeted fetch instead of --all to avoid pulling every branch/tag
1609
- # from potentially large repos (avoids 300s timeout on big repos).
1610
- # Fetch default branch only; the feature branch is explicitly fetched below.
1924
+ # Before fetching, verify _main has a valid HEAD and at least one ref.
1925
+ # An interrupted initial clone (killed by the old 600 s timeout or
1926
+ # SIGKILL) leaves HEAD pointing to refs/heads/.invalid with no
1927
+ # packed-refs. In that state `git fetch` adds objects to the pack
1928
+ # but never sets up branch refs, so `git worktree add` always fails.
1929
+ # Detect this by running `git rev-parse HEAD`; failure means the
1930
+ # repo is unusable and must be deleted and re-cloned from scratch.
1931
+ _main_broken = False
1611
1932
  try:
1612
- await self._git(
1613
- "fetch", "origin", default_branch,
1614
- cwd=main_repo, timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
1933
+ await self._git("rev-parse", "HEAD", cwd=main_repo)
1934
+ # Also verify the commit object itself is readable (not just the ref).
1935
+ # An interrupted clone can write a valid HEAD ref but leave pack files
1936
+ # truncated — rev-parse HEAD succeeds (reads the ref) but HEAD^{tree}
1937
+ # fails (requires the commit object to be intact).
1938
+ await self._git("rev-parse", "HEAD^{tree}", cwd=main_repo)
1939
+ except RuntimeError:
1940
+ _main_broken = True
1941
+ if _main_broken:
1942
+ logger.warning(
1943
+ "_main repo at %s has no valid HEAD (likely interrupted initial clone) "
1944
+ "— deleting and re-cloning from scratch",
1945
+ main_repo,
1615
1946
  )
1616
- except RuntimeError as _fetch_err:
1617
- err_str = str(_fetch_err)
1618
- # Detect "unrelated histories" / history-rewrite scenarios:
1619
- # If the remote history was rewritten (e.g. BFG large-file removal),
1620
- # all commit SHAs change. The local clone becomes incompatible —
1621
- # fetch may succeed but the local refs are orphaned and unusable.
1622
- # Detection: check whether local HEAD exists in the remote graph.
1623
- is_unrelated = await self._detect_unrelated_histories(main_repo, project_key)
1624
- if is_unrelated or "not our ref" in err_str or "shallow" in err_str:
1625
- logger.warning(
1626
- "Detected repository history mismatch for %s (remote history likely "
1627
- "rewritten). Discarding stale local clone and re-cloning from scratch.",
1628
- main_repo,
1629
- )
1630
- shutil.rmtree(main_repo, ignore_errors=True)
1947
+ await self._safe_rmtree_main(main_repo)
1948
+ try:
1631
1949
  await self._git(
1632
1950
  "clone", "--single-branch", "--no-tags",
1633
- repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT,
1634
- project_key=project_key,
1951
+ "--branch", default_branch,
1952
+ repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
1635
1953
  )
1636
- else:
1954
+ except Exception:
1955
+ shutil.rmtree(main_repo, ignore_errors=True)
1637
1956
  raise
1957
+ # Use targeted fetch instead of --all to avoid pulling every branch/tag
1958
+ # from potentially large repos. Use GIT_FETCH_TIMEOUT (default 1800 s)
1959
+ # rather than GIT_CLONE_TIMEOUT: accumulated delta objects on an active
1960
+ # large repo (e.g. HP SI develop) easily exceed the 600 s clone timeout.
1961
+ try:
1962
+ await self._git(
1963
+ "fetch", "origin", default_branch,
1964
+ cwd=main_repo, timeout=settings.GIT_FETCH_TIMEOUT, project_key=project_key,
1965
+ )
1966
+ except RuntimeError as _fetch_err:
1967
+ err_str = str(_fetch_err)
1968
+ # Detect "unrelated histories" / history-rewrite scenarios:
1969
+ # If the remote history was rewritten (e.g. BFG large-file removal),
1970
+ # all commit SHAs change. The local clone becomes incompatible —
1971
+ # fetch may succeed but the local refs are orphaned and unusable.
1972
+ # Detection: check whether local HEAD exists in the remote graph.
1973
+ is_unrelated = await self._detect_unrelated_histories(main_repo, project_key)
1974
+ if is_unrelated or "not our ref" in err_str or "shallow" in err_str:
1975
+ logger.warning(
1976
+ "Detected repository history mismatch for %s (remote history likely "
1977
+ "rewritten). Discarding stale local clone and re-cloning from scratch.",
1978
+ main_repo,
1979
+ )
1980
+ await self._safe_rmtree_main(main_repo)
1981
+ try:
1982
+ await self._git(
1983
+ "clone", "--single-branch", "--no-tags",
1984
+ "--branch", default_branch,
1985
+ repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT,
1986
+ project_key=project_key,
1987
+ )
1988
+ except Exception:
1989
+ shutil.rmtree(main_repo, ignore_errors=True)
1990
+ raise
1991
+ else:
1992
+ raise
1638
1993
 
1639
1994
  # --single-branch clone only fetches the default branch.
1640
1995
  # Explicitly fetch the feature branch so origin/{branch_name}
@@ -1881,6 +2236,16 @@ class WorkspaceManager:
1881
2236
  if git_prefix_args:
1882
2237
  env = {**(env or os.environ), "GIT_TERMINAL_PROMPT": "0"}
1883
2238
 
2239
+ # Apply optional partial-clone filter for large-repo support.
2240
+ # Injecting here centralises the setting across all call sites: callers
2241
+ # only need "clone" in args; the filter (e.g. "blob:none") is appended
2242
+ # automatically when GIT_CLONE_FILTER is configured.
2243
+ _clone_filter = getattr(settings, "GIT_CLONE_FILTER", "")
2244
+ if args and args[0] == "clone" and _clone_filter:
2245
+ # Insert --filter=<filter> immediately after "clone" so that
2246
+ # remaining args (--single-branch, URL, dir) stay in their expected order.
2247
+ args = (args[0], f"--filter={_clone_filter}") + args[1:]
2248
+
1884
2249
  # Always enable long-path support. On Windows this removes git's own
1885
2250
  # 260-char path limit (Windows also needs HKLM LongPathsEnabled=1 or
1886
2251
  # the Win10 1607+ Group Policy, but at a minimum we ensure git won't
@@ -2077,6 +2442,19 @@ class ProcessManager:
2077
2442
  "apiexception:",
2078
2443
  "api error: 5", # 5xx errors like "API error: 503", "API error: 502"
2079
2444
  "api error: connection",
2445
+ # Kimi Code 0.18.0 auth/model errors.
2446
+ # Kimi Code 0.18.0 streams error text character-by-character with the
2447
+ # model name "kimi-for-coding" prepended to each char, producing a
2448
+ # garbled string. After stripping those tokens the real message is
2449
+ # "Not found the model or Permission denied". We match:
2450
+ # a) the raw garbled prefix pattern (first few chars: "kimi-for-codingN")
2451
+ # b) the decoded phrases (produced by our degarble helper in _run_kimi_code)
2452
+ # c) the Kimi SDK error prefix "provider.api_error"
2453
+ # d) our own pre-flight "kimi authentication required" sentinel
2454
+ "kimi-for-codingnkimi-for-coding", # garbled "Not" in 404 response
2455
+ "not found the model", # decoded Kimi 404 body
2456
+ "provider.api_error", # Kimi SDK error prefix
2457
+ "kimi authentication required", # pre-flight oauth check sentinel
2080
2458
  ]
2081
2459
 
2082
2460
  def __init__(self):
@@ -3046,11 +3424,50 @@ class ProcessManager:
3046
3424
  We therefore stream stderr through ``on_chunk`` so the user sees
3047
3425
  output in real time instead of a blank panel until process exit.
3048
3426
  """
3427
+ # ── Pre-flight: verify Kimi Code auth is configured ──────────────────
3428
+ # Kimi Code changed its on-disk auth storage in 0.18.0:
3429
+ # - legacy: $KIMI_CODE_HOME/oauth/kimi-code
3430
+ # - current: $KIMI_CODE_HOME/credentials/kimi-code.json
3431
+ # The legacy oauth file can now legitimately remain empty while the
3432
+ # credentials JSON contains valid bearer/refresh tokens. Be
3433
+ # conservative: only fail fast when BOTH known stores are missing or
3434
+ # empty. Otherwise let the CLI decide, and rely on the decoded
3435
+ # provider.api_error handling below if the server still rejects the
3436
+ # credentials.
3437
+ kimi_home = Path(os.environ.get("KIMI_CODE_HOME", Path.home() / ".kimi-code"))
3438
+ auth_ok, auth_detail = self._get_kimi_auth_state(kimi_home)
3439
+ if not auth_ok:
3440
+ credentials_file = kimi_home / "credentials" / "kimi-code.json"
3441
+ oauth_file = kimi_home / "oauth" / "kimi-code"
3442
+ logger.warning(
3443
+ "Kimi Code auth not configured (%s); skipping Kimi Code and "
3444
+ "triggering agent fallback. Run 'kimi' in a terminal to log in.",
3445
+ auth_detail,
3446
+ )
3447
+ return TaskResult(
3448
+ status="failed", exit_code=-1, stdout="", stderr="",
3449
+ error=(
3450
+ "Kimi authentication required: no usable auth state was found under "
3451
+ f"{credentials_file} or {oauth_file} ({auth_detail}). Please run "
3452
+ "'kimi' in a terminal and log in, then retry the task."
3453
+ ),
3454
+ )
3455
+
3049
3456
  cmd = [agent.command, "-p", prompt]
3050
3457
  result = await self._run_cli(
3051
3458
  cmd, cwd, timeout, task_id, on_chunk=on_chunk,
3052
3459
  stream_stderr=True,
3053
3460
  )
3461
+
3462
+ # ── Post-run: degarble Kimi Code 0.18.0 error messages ───────────────
3463
+ # Kimi Code 0.18.0 has a bug where it streams 404/auth error text
3464
+ # character-by-character, prepending the model name "kimi-for-coding"
3465
+ # before every character. The resulting output is unreadable.
3466
+ # Strip those tokens to expose the real error message so that:
3467
+ # (a) humans can read it in the UI
3468
+ # (b) AGENT_UNAVAILABLE_PATTERNS can match "not found the model"
3469
+ result = self._degarble_kimi_error(result)
3470
+
3054
3471
  parsed_metrics = self._parse_agent_jsonl_output(result.stdout)
3055
3472
  result.metrics.update(parsed_metrics)
3056
3473
  # Kimi 0.18.0+ stores token usage in the session wire.jsonl file,
@@ -3062,6 +3479,91 @@ class ProcessManager:
3062
3479
  result.metrics.update(wire_metrics)
3063
3480
  return result
3064
3481
 
3482
+ @staticmethod
3483
+ def _get_kimi_auth_state(kimi_home: Path) -> tuple[bool, str]:
3484
+ """Return whether Kimi Code appears authenticated on disk.
3485
+
3486
+ Kimi Code has used two storage layouts:
3487
+ - legacy oauth token file: ``oauth/kimi-code``
3488
+ - current credential cache: ``credentials/kimi-code.json``
3489
+
3490
+ The 0.18.0 release may leave the legacy oauth file empty even when the
3491
+ CLI is fully authenticated, so checking only that file creates false
3492
+ negatives. This pre-flight helper therefore errs on the side of not
3493
+ blocking execution:
3494
+
3495
+ - any non-empty legacy oauth file counts as configured
3496
+ - any non-empty credentials JSON counts as configured
3497
+ - if the JSON is parseable and exposes ``access_token`` or
3498
+ ``refresh_token``, include that in the detail for debugging
3499
+ - only return ``False`` when both known stores are absent or empty
3500
+ """
3501
+ credentials_file = kimi_home / "credentials" / "kimi-code.json"
3502
+ oauth_file = kimi_home / "oauth" / "kimi-code"
3503
+
3504
+ if credentials_file.exists() and credentials_file.stat().st_size > 0:
3505
+ try:
3506
+ payload = json.loads(credentials_file.read_text())
3507
+ except Exception:
3508
+ return True, f"non-empty credentials file {credentials_file}"
3509
+
3510
+ if any(str(payload.get(key, "")).strip() for key in ("access_token", "refresh_token")):
3511
+ return True, f"token fields present in {credentials_file}"
3512
+ return True, f"non-empty credentials file {credentials_file}"
3513
+
3514
+ if oauth_file.exists() and oauth_file.stat().st_size > 0:
3515
+ return True, f"legacy oauth token present in {oauth_file}"
3516
+
3517
+ problems: list[str] = []
3518
+ if not credentials_file.exists():
3519
+ problems.append(f"missing {credentials_file}")
3520
+ elif credentials_file.stat().st_size == 0:
3521
+ problems.append(f"empty {credentials_file}")
3522
+ if not oauth_file.exists():
3523
+ problems.append(f"missing {oauth_file}")
3524
+ elif oauth_file.stat().st_size == 0:
3525
+ problems.append(f"empty {oauth_file}")
3526
+ return False, "; ".join(problems)
3527
+
3528
+ @staticmethod
3529
+ def _degarble_kimi_error(result: "TaskResult") -> "TaskResult":
3530
+ """Strip Kimi Code 0.18.0 character-level model-name interleaving from errors.
3531
+
3532
+ Kimi Code 0.18.0 has a bug where API error response text is emitted as
3533
+ a character-by-character SSE stream, with the model identifier
3534
+ ("kimi-for-coding") prepended to every character. The result looks
3535
+ like::
3536
+
3537
+ provider.api_error: 404 kimi-for-codingNkimi-for-codingokimi-for-coding…
3538
+
3539
+ Stripping "kimi-for-coding" from that string yields the readable message::
3540
+
3541
+ provider.api_error: 404 Not found the model or Permission denied
3542
+
3543
+ We apply this only when the garble signature is present in stderr or
3544
+ error to avoid mutating normal successful output.
3545
+ """
3546
+ def _clean(text: str | None) -> str | None:
3547
+ # Real 0.18.0 failures interleave the model name before nearly
3548
+ # every character, e.g. ``kimi-for-codingN`` ``kimi-for-codingo``.
3549
+ # Counting repeated model-name tokens is more robust than a strict
3550
+ # regex because the stream can contain spaces/newlines between
3551
+ # individual characters.
3552
+ if not text or text.count("kimi-for-coding") < 2:
3553
+ return text
3554
+ cleaned = text.replace("kimi-for-coding", "")
3555
+ logger.debug("Degarbled Kimi error output: %r → %r", text[:120], cleaned[:120])
3556
+ return cleaned
3557
+
3558
+ stderr_clean = _clean(result.stderr)
3559
+ error_clean = _clean(result.error)
3560
+ if stderr_clean is result.stderr and error_clean is result.error:
3561
+ return result # nothing changed
3562
+ # TaskResult is a non-frozen dataclass — mutate fields in place
3563
+ result.stderr = stderr_clean or result.stderr
3564
+ result.error = error_clean or result.error
3565
+ return result
3566
+
3065
3567
  async def _run_copilot(
3066
3568
  self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
3067
3569
  on_chunk: Any = None,
@@ -4819,12 +5321,23 @@ class RuntimeDaemon:
4819
5321
  output_dir_norm = str(output_dir_raw).replace("\\", "/").lstrip("./").rstrip("/")
4820
5322
  if output_dir_norm:
4821
5323
  dir_to_wipe = workspace_path / output_dir_norm
4822
- if dir_to_wipe.is_dir():
5324
+ # Guard against path-traversal: resolved path must remain
5325
+ # inside the workspace (e.g. "docs/../../.." would escape).
5326
+ try:
5327
+ if not dir_to_wipe.resolve().is_relative_to(workspace_path.resolve()):
5328
+ logger.warning(
5329
+ "wipe_analysis_dir path %r resolves outside workspace %s — skipping",
5330
+ output_dir_norm, workspace_path,
5331
+ )
5332
+ dir_to_wipe = None
5333
+ except Exception:
5334
+ dir_to_wipe = None
5335
+ if dir_to_wipe and dir_to_wipe.is_dir():
4823
5336
  existing_files = [f for f in dir_to_wipe.iterdir() if f.is_file()]
4824
5337
  if existing_files:
4825
5338
  try:
4826
5339
  # Stage all deletions with git rm
4827
- await self._git(
5340
+ await self.workspace_manager._git(
4828
5341
  "rm", "-r", "--cached", "--ignore-unmatch",
4829
5342
  output_dir_norm,
4830
5343
  cwd=workspace_path,
@@ -4833,7 +5346,7 @@ class RuntimeDaemon:
4833
5346
  shutil.rmtree(str(dir_to_wipe), ignore_errors=True)
4834
5347
  # Commit the wipe so the branch diff is clean
4835
5348
  _git_name, _git_email = _resolve_git_author(task.project)
4836
- await self._git(
5349
+ await self.workspace_manager._git(
4837
5350
  "-c", f"user.name={_git_name}",
4838
5351
  "-c", f"user.email={_git_email}",
4839
5352
  "commit", "-m",
@@ -4844,12 +5357,20 @@ class RuntimeDaemon:
4844
5357
  "Wiped %d analysis doc(s) from %s for task %s (fresh analysis)",
4845
5358
  len(existing_files), output_dir_norm, task.task_id,
4846
5359
  )
4847
- except Exception:
4848
- logger.warning(
4849
- "Could not wipe analysis dir %s for task %s "
4850
- "(proceeding anyway agent will overwrite)",
4851
- output_dir_norm, task.task_id, exc_info=True,
5360
+ except Exception as _wipe_err:
5361
+ # Wipe failure on a fresh analysis is not safe to ignore:
5362
+ # stale analysis docs could contaminate the new analysis
5363
+ # (agent may treat them as context and refine instead of
5364
+ # regenerating from scratch). Abort this task so the
5365
+ # orchestrator can retry on a clean workspace.
5366
+ logger.error(
5367
+ "Failed to wipe analysis dir %s for task %s — "
5368
+ "aborting to avoid stale-file contamination: %s",
5369
+ output_dir_norm, task.task_id, _wipe_err, exc_info=True,
4852
5370
  )
5371
+ raise RuntimeError(
5372
+ f"wipe_analysis_dir failed for {output_dir_norm}: {_wipe_err}"
5373
+ ) from _wipe_err
4853
5374
  else:
4854
5375
  logger.debug(
4855
5376
  "Analysis dir %s is already empty for task %s",
@@ -5441,8 +5962,8 @@ class RuntimeDaemon:
5441
5962
  if not intents:
5442
5963
  issues.append("test-intent.json contains no test intents")
5443
5964
  for ti in intents[:20]:
5444
- if not ti.get("id") or not ti.get("title"):
5445
- issues.append(f"Test intent missing 'id' or 'title': {ti.get('id', '?')}")
5965
+ if not ti.get("id") or not (ti.get("title") or ti.get("description")):
5966
+ issues.append(f"Test intent missing 'id' or 'title'/'description': {ti.get('id', '?')}")
5446
5967
  break
5447
5968
  except _json.JSONDecodeError as e:
5448
5969
  issues.append(f"test-intent.json is not valid JSON: {e}")
@@ -6511,14 +7032,19 @@ class RuntimeDaemon:
6511
7032
  branch: str,
6512
7033
  project_key: str = "default",
6513
7034
  *,
6514
- timeout: int = 120,
7035
+ timeout: int | None = None,
6515
7036
  ) -> None:
6516
- """Refresh origin/<branch> explicitly, even inside single-branch clones."""
7037
+ """Refresh origin/<branch> explicitly, even inside single-branch clones.
7038
+
7039
+ Defaults to GIT_FETCH_TIMEOUT so that large active repos (e.g. HP SI)
7040
+ don't hit a hard 120 s ceiling on per-branch incremental fetches.
7041
+ """
7042
+ _timeout = timeout if timeout is not None else settings.GIT_FETCH_TIMEOUT
6517
7043
  await self.workspace_manager._git(
6518
7044
  "fetch", "origin",
6519
7045
  f"{branch}:refs/remotes/origin/{branch}",
6520
7046
  cwd=workspace_path,
6521
- timeout=timeout,
7047
+ timeout=_timeout,
6522
7048
  project_key=project_key,
6523
7049
  )
6524
7050
 
@@ -7076,7 +7602,8 @@ class RuntimeDaemon:
7076
7602
 
7077
7603
  # Fetch latest remote state
7078
7604
  try:
7079
- await git("fetch", "origin", cwd=workspace_path, timeout=300, project_key=project_key)
7605
+ await git("fetch", "origin", cwd=workspace_path,
7606
+ timeout=settings.GIT_FETCH_TIMEOUT, project_key=project_key)
7080
7607
  except RuntimeError as exc:
7081
7608
  logger.warning("Pre-push fetch failed: %s — skipping integration", exc)
7082
7609
  return
@@ -7346,7 +7873,7 @@ class RuntimeDaemon:
7346
7873
  workspace_path,
7347
7874
  branch,
7348
7875
  project_key,
7349
- timeout=300,
7876
+ # timeout omitted — defaults to GIT_FETCH_TIMEOUT
7350
7877
  )
7351
7878
  except RuntimeError as _pre_push_fetch_exc:
7352
7879
  logger.warning(
@@ -7415,6 +7942,7 @@ class RuntimeDaemon:
7415
7942
  await git(
7416
7943
  "push", "--force-with-lease", "-u", "origin", branch,
7417
7944
  cwd=workspace_path, project_key=project_key,
7945
+ timeout=settings.GIT_PUSH_TIMEOUT,
7418
7946
  )
7419
7947
  logger.info("Force-pushed (with lease) branch %s to origin", branch)
7420
7948
  return None
@@ -7477,6 +8005,7 @@ class RuntimeDaemon:
7477
8005
  await git(
7478
8006
  "push", "-u", "origin", branch,
7479
8007
  cwd=workspace_path, project_key=project_key,
8008
+ timeout=settings.GIT_PUSH_TIMEOUT,
7480
8009
  )
7481
8010
  logger.info(
7482
8011
  "Recovery push succeeded for branch %s "
@@ -7527,6 +8056,7 @@ class RuntimeDaemon:
7527
8056
  await git(
7528
8057
  "push", "-u", "origin", branch,
7529
8058
  cwd=workspace_path, project_key=project_key,
8059
+ timeout=settings.GIT_PUSH_TIMEOUT,
7530
8060
  )
7531
8061
  logger.info(
7532
8062
  "Fetch+rebase recovery succeeded for branch %s "
@@ -7554,6 +8084,7 @@ class RuntimeDaemon:
7554
8084
  await git(
7555
8085
  "push", "--force-with-lease", "-u", "origin", branch,
7556
8086
  cwd=workspace_path, project_key=project_key,
8087
+ timeout=settings.GIT_PUSH_TIMEOUT,
7557
8088
  )
7558
8089
  logger.info(
7559
8090
  "Force-with-lease fallback push succeeded for branch %s",
@@ -7581,6 +8112,7 @@ class RuntimeDaemon:
7581
8112
  await git(
7582
8113
  "push", "-u", "origin", branch,
7583
8114
  cwd=workspace_path, project_key=project_key,
8115
+ timeout=settings.GIT_PUSH_TIMEOUT,
7584
8116
  )
7585
8117
  logger.info("Pushed branch %s to origin (attempt %d)", branch, attempt)
7586
8118
  last_push_exc = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.11.1
3
+ Version: 1.11.3
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.11.1"
3
+ version = "1.11.3"
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