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.
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/PKG-INFO +1 -1
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/forgexa_cli/daemon.py +615 -83
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/pyproject.toml +1 -1
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/README.md +0 -0
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.11.1 → forgexa_cli-1.11.3}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.11.
|
|
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", "
|
|
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.
|
|
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
|
|
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
|
-
#
|
|
1283
|
-
|
|
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
|
-
|
|
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:
|
|
1319
|
-
the
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
-
#
|
|
1343
|
-
#
|
|
1344
|
-
#
|
|
1345
|
-
#
|
|
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
|
-
#
|
|
1348
|
-
#
|
|
1349
|
-
#
|
|
1350
|
-
#
|
|
1351
|
-
#
|
|
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,
|
|
1514
|
+
out, err = await asyncio.wait_for(check_proc.communicate(), timeout=10)
|
|
1359
1515
|
if check_proc.returncode != 0:
|
|
1360
|
-
#
|
|
1361
|
-
|
|
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,
|
|
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
|
|
1421
|
-
#
|
|
1422
|
-
#
|
|
1423
|
-
#
|
|
1424
|
-
#
|
|
1425
|
-
#
|
|
1426
|
-
|
|
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
|
|
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
|
|
1441
|
-
"ahead of %s — switching to safe sync
|
|
1442
|
-
"
|
|
1443
|
-
branch_name,
|
|
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
|
-
|
|
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
|
-
#
|
|
1609
|
-
#
|
|
1610
|
-
#
|
|
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
|
-
|
|
1614
|
-
|
|
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
|
-
|
|
1617
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
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 =
|
|
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=
|
|
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,
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|