collab-runtime 0.8.0__tar.gz → 0.9.0__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.
- {collab_runtime-0.8.0/collab_runtime.egg-info → collab_runtime-0.9.0}/PKG-INFO +1 -1
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/README.md +3 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/live_locks_watcher.py +186 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/lock_client.py +258 -18
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/main.py +3 -3
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/overlap.py +217 -14
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/pr_overlap.py +167 -7
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/safe_subprocess.py +1 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0/collab_runtime.egg-info}/PKG-INFO +1 -1
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/pyproject.toml +1 -1
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/LICENSE +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/__init__.py +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/__main__.py +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/agent_hooks.py +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/agent_identity.py +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/dashboard/dashboard-charts.js +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/dashboard/dashboard-filters.js +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/dashboard/dashboard-format.js +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/dashboard/index.html +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/dashboard_server.py +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/errors.py +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/githooks.py +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/hook_templates/commit-msg +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/hook_templates/post-checkout +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/hook_templates/post-commit +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/hook_templates/post-merge +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/hook_templates/pre-commit +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/hook_templates/pre-push +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/logging_config.py +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/platform_probe.py +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab/subprocess_bridge.py +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab_runtime.egg-info/SOURCES.txt +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab_runtime.egg-info/dependency_links.txt +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab_runtime.egg-info/entry_points.txt +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab_runtime.egg-info/requires.txt +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/collab_runtime.egg-info/top_level.txt +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/docs/pypi/README.md +0 -0
- {collab_runtime-0.8.0 → collab_runtime-0.9.0}/setup.cfg +0 -0
|
@@ -261,6 +261,10 @@ _local_owned_locks: set[str] = set()
|
|
|
261
261
|
# Guard to prevent _graceful_shutdown from running more than once
|
|
262
262
|
_shutdown_done: bool = False
|
|
263
263
|
|
|
264
|
+
# Track when each lock was acquired so we can enforce a minimum hold time
|
|
265
|
+
# and avoid rapid acquire/release cycles when git status is transient.
|
|
266
|
+
_lock_acquired_at: dict[str, datetime] = {}
|
|
267
|
+
|
|
264
268
|
# URL of the running dashboard server (set in main after _start_dashboard_server).
|
|
265
269
|
# Used by interactive conflict menus so users can review all active locks.
|
|
266
270
|
_dashboard_url: str | None = None
|
|
@@ -473,6 +477,15 @@ def _normalize_path(path: str, project_root: str) -> str:
|
|
|
473
477
|
return path.replace("\\", "/")
|
|
474
478
|
|
|
475
479
|
|
|
480
|
+
def _min_auto_lock_hold_seconds() -> int:
|
|
481
|
+
"""Minimum seconds an auto-watch lock must be held before auto-release.
|
|
482
|
+
|
|
483
|
+
Configurable via ``COLLAB_MIN_AUTO_LOCK_HOLD_SECONDS`` (default 300 = 5 min).
|
|
484
|
+
Wrapped in a callable so tests can monkeypatch without import-time coupling.
|
|
485
|
+
"""
|
|
486
|
+
return int(os.getenv("COLLAB_MIN_AUTO_LOCK_HOLD_SECONDS", "300"))
|
|
487
|
+
|
|
488
|
+
|
|
476
489
|
def _should_ignore_path(path: str) -> bool:
|
|
477
490
|
"""Return True for paths the watcher should skip."""
|
|
478
491
|
norm = path.replace("\\", "/")
|
|
@@ -741,9 +754,23 @@ def _process_new_files(client, branch: str, new_files: set[str]) -> None:
|
|
|
741
754
|
)
|
|
742
755
|
log_msg = _color(msg, Fore.GREEN) if _HAS_COLORAMA else msg
|
|
743
756
|
logger.info(log_msg)
|
|
757
|
+
# Cross-branch advisory (#150): warn when renewing a lock
|
|
758
|
+
# held by the same developer on a different branch.
|
|
759
|
+
existing_branch = data[0].get("existing_branch") if data else None
|
|
760
|
+
if existing_branch and existing_branch != br_local:
|
|
761
|
+
warn_msg = (
|
|
762
|
+
f"CROSS-BRANCH: {fp} is also locked by you on "
|
|
763
|
+
f"branch '{existing_branch}' (current: '{br_local}'). "
|
|
764
|
+
"Concurrent edits may cause merge conflicts."
|
|
765
|
+
)
|
|
766
|
+
logger.warning(
|
|
767
|
+
_color(warn_msg, Fore.YELLOW) if _HAS_COLORAMA else warn_msg
|
|
768
|
+
)
|
|
744
769
|
# Track locks this watcher created so remote scans do not
|
|
745
770
|
# report them as 'remote added' later.
|
|
746
771
|
_local_owned_locks.add(fp)
|
|
772
|
+
# Record acquisition time for minimum-hold enforcement.
|
|
773
|
+
_lock_acquired_at[fp] = datetime.now()
|
|
747
774
|
except Exception:
|
|
748
775
|
# Log full traceback so errors during acquire are visible in errors.log
|
|
749
776
|
logger.exception("Failed to acquire lock for %s", fp)
|
|
@@ -754,8 +781,26 @@ def _process_releases(client, released: set[str]) -> None:
|
|
|
754
781
|
|
|
755
782
|
Extracted so tests can simulate exceptions when removing locks from the local-owned
|
|
756
783
|
set without running the entire main loop.
|
|
784
|
+
|
|
785
|
+
Enforces a minimum hold time to avoid rapid acquire/release cycles when git status
|
|
786
|
+
is transient.
|
|
757
787
|
"""
|
|
758
788
|
for fp in released:
|
|
789
|
+
# Enforce minimum lock hold time: skip files whose lock was acquired
|
|
790
|
+
# too recently to avoid thrashing when git status fluctuates.
|
|
791
|
+
acquired = _lock_acquired_at.get(fp)
|
|
792
|
+
if acquired is not None:
|
|
793
|
+
age = (datetime.now() - acquired).total_seconds()
|
|
794
|
+
if age < _min_auto_lock_hold_seconds():
|
|
795
|
+
logger.debug(
|
|
796
|
+
"⏳ [KEPT] %s — lock is only %ds old "
|
|
797
|
+
"(< %ds minimum); deferring auto-release",
|
|
798
|
+
fp,
|
|
799
|
+
int(age),
|
|
800
|
+
_min_auto_lock_hold_seconds(),
|
|
801
|
+
)
|
|
802
|
+
continue
|
|
803
|
+
|
|
759
804
|
# Was this file in conflict?
|
|
760
805
|
if fp in _active_conflicts:
|
|
761
806
|
_active_conflicts.discard(fp)
|
|
@@ -916,6 +961,74 @@ def _resolve_lock_diff_base_ref() -> str | None:
|
|
|
916
961
|
return None
|
|
917
962
|
|
|
918
963
|
|
|
964
|
+
def _get_sibling_worktree_dirty_files() -> set[str]:
|
|
965
|
+
"""Return dirty files from sibling git worktrees.
|
|
966
|
+
|
|
967
|
+
Uses ``git worktree list --porcelain`` to discover sibling worktrees, then runs
|
|
968
|
+
``git status --porcelain`` in each. Paths are normalised to project- relative form.
|
|
969
|
+
Used by :func:`_get_modified_and_unpushed_files` so that concurrent edits across
|
|
970
|
+
worktrees are not released as stale (#150).
|
|
971
|
+
"""
|
|
972
|
+
sibling_files: set[str] = set()
|
|
973
|
+
|
|
974
|
+
captured = safe_subprocess.capture(
|
|
975
|
+
["git", "worktree", "list", "--porcelain"],
|
|
976
|
+
policy="git",
|
|
977
|
+
cwd=_PROJECT_ROOT,
|
|
978
|
+
timeout=10.0,
|
|
979
|
+
)
|
|
980
|
+
if not captured.ok or captured.timed_out:
|
|
981
|
+
return sibling_files
|
|
982
|
+
|
|
983
|
+
out = safe_subprocess.decode_output(captured.stdout)
|
|
984
|
+
worktrees: list[tuple[str, str]] = [] # (path, branch)
|
|
985
|
+
current_path = ""
|
|
986
|
+
for line in out.splitlines():
|
|
987
|
+
line = line.strip()
|
|
988
|
+
if line.startswith("worktree "):
|
|
989
|
+
current_path = line[len("worktree ") :]
|
|
990
|
+
elif line.startswith("branch ") and current_path:
|
|
991
|
+
branch_ref = line[len("branch ") :]
|
|
992
|
+
branch = branch_ref.replace("refs/heads/", "", 1)
|
|
993
|
+
worktrees.append((current_path, branch))
|
|
994
|
+
current_path = ""
|
|
995
|
+
|
|
996
|
+
our_root = os.path.abspath(_PROJECT_ROOT).rstrip("\\/").lower()
|
|
997
|
+
|
|
998
|
+
for wt_path, wt_branch in worktrees:
|
|
999
|
+
wt_abs = os.path.abspath(wt_path).rstrip("\\/").lower()
|
|
1000
|
+
if wt_abs == our_root:
|
|
1001
|
+
continue
|
|
1002
|
+
if not os.path.isdir(wt_path):
|
|
1003
|
+
continue
|
|
1004
|
+
|
|
1005
|
+
try:
|
|
1006
|
+
wt_captured = safe_subprocess.capture(
|
|
1007
|
+
["git", "status", "--porcelain"],
|
|
1008
|
+
policy="git",
|
|
1009
|
+
cwd=wt_path,
|
|
1010
|
+
timeout=10.0,
|
|
1011
|
+
)
|
|
1012
|
+
if not wt_captured.ok or wt_captured.timed_out:
|
|
1013
|
+
continue
|
|
1014
|
+
wt_out = safe_subprocess.decode_output(wt_captured.stdout).strip("\r\n")
|
|
1015
|
+
for line in wt_out.splitlines():
|
|
1016
|
+
if len(line) > 3:
|
|
1017
|
+
p = _normalize_path(_parse_git_status_path(line), _PROJECT_ROOT)
|
|
1018
|
+
if p and not _should_ignore_path(p):
|
|
1019
|
+
sibling_files.add(p)
|
|
1020
|
+
logger.debug(
|
|
1021
|
+
"👥 [WORKTREE] %s — dirty in sibling '%s' (%s)",
|
|
1022
|
+
p,
|
|
1023
|
+
wt_branch,
|
|
1024
|
+
wt_path,
|
|
1025
|
+
)
|
|
1026
|
+
except Exception as exc:
|
|
1027
|
+
logger.debug("Failed to scan sibling worktree %s: %s", wt_path, exc)
|
|
1028
|
+
|
|
1029
|
+
return sibling_files
|
|
1030
|
+
|
|
1031
|
+
|
|
919
1032
|
def _get_modified_and_unpushed_files() -> set[str]:
|
|
920
1033
|
"""Return the set of files that are 'in progress' for this developer.
|
|
921
1034
|
|
|
@@ -957,6 +1070,15 @@ def _get_modified_and_unpushed_files() -> set[str]:
|
|
|
957
1070
|
# just won't lock committed-but-unpushed files, which beats crashing.
|
|
958
1071
|
pass
|
|
959
1072
|
|
|
1073
|
+
# Part 3: sibling worktree dirty files (#150) — prevent stale-lock release
|
|
1074
|
+
# when another worktree is actively editing the same files.
|
|
1075
|
+
try:
|
|
1076
|
+
sibling_dirty = _get_sibling_worktree_dirty_files()
|
|
1077
|
+
if sibling_dirty:
|
|
1078
|
+
result.update(sibling_dirty)
|
|
1079
|
+
except Exception as exc:
|
|
1080
|
+
logger.debug("Sibling worktree scan failed: %s", exc)
|
|
1081
|
+
|
|
960
1082
|
return result
|
|
961
1083
|
|
|
962
1084
|
|
|
@@ -1003,6 +1125,18 @@ def _reconcile_on_startup(client) -> None:
|
|
|
1003
1125
|
fp = lock.get("file_path", "")
|
|
1004
1126
|
if fp:
|
|
1005
1127
|
lock_map[fp] = lock
|
|
1128
|
+
# Populate _lock_acquired_at from existing locks so the minimum-hold
|
|
1129
|
+
# check works correctly across watcher restarts.
|
|
1130
|
+
acquired_str = lock.get("acquired_at")
|
|
1131
|
+
if acquired_str and fp not in _lock_acquired_at:
|
|
1132
|
+
try:
|
|
1133
|
+
_lock_acquired_at[fp] = (
|
|
1134
|
+
datetime.fromisoformat(acquired_str)
|
|
1135
|
+
.astimezone()
|
|
1136
|
+
.replace(tzinfo=None)
|
|
1137
|
+
)
|
|
1138
|
+
except (ValueError, TypeError):
|
|
1139
|
+
pass
|
|
1006
1140
|
|
|
1007
1141
|
locked_paths = set(lock_map.keys())
|
|
1008
1142
|
branch = _get_current_branch()
|
|
@@ -1071,6 +1205,26 @@ def _reconcile_on_startup(client) -> None:
|
|
|
1071
1205
|
msg = f"[RESUMED] {fp} - lock re-adopted from this machine"
|
|
1072
1206
|
logger.info(_color(msg, Fore.GREEN) if _HAS_COLORAMA else msg)
|
|
1073
1207
|
else:
|
|
1208
|
+
# File is clean — but skip if the lock was acquired too recently.
|
|
1209
|
+
# This prevents the watcher from releasing locks it just acquired
|
|
1210
|
+
# during the same reconciliation pass (e.g. when git status is
|
|
1211
|
+
# transient).
|
|
1212
|
+
acquired = _lock_acquired_at.get(fp)
|
|
1213
|
+
if acquired is not None:
|
|
1214
|
+
age = (datetime.now() - acquired).total_seconds()
|
|
1215
|
+
if age < _min_auto_lock_hold_seconds():
|
|
1216
|
+
logger.debug(
|
|
1217
|
+
"⏳ [KEPT] %s — lock is only %ds old "
|
|
1218
|
+
"(< %ds minimum); skipping auto-release",
|
|
1219
|
+
fp,
|
|
1220
|
+
int(age),
|
|
1221
|
+
_min_auto_lock_hold_seconds(),
|
|
1222
|
+
)
|
|
1223
|
+
# Keep the lock in _local_owned_locks so the main loop
|
|
1224
|
+
# continues to track it for eventual release.
|
|
1225
|
+
_local_owned_locks.add(fp)
|
|
1226
|
+
continue
|
|
1227
|
+
|
|
1074
1228
|
# File is clean - stale lock, release it
|
|
1075
1229
|
try:
|
|
1076
1230
|
_scoped_owned_query(
|
|
@@ -1114,6 +1268,7 @@ def _reconcile_on_startup(client) -> None:
|
|
|
1114
1268
|
_handle_post_restart_conflict(client, fp, data[0])
|
|
1115
1269
|
else:
|
|
1116
1270
|
_local_owned_locks.add(fp)
|
|
1271
|
+
_lock_acquired_at[fp] = datetime.now()
|
|
1117
1272
|
n_newly_locked += 1
|
|
1118
1273
|
msg = f"[LOCKED] {fp} - acquired lock for dirty file at startup"
|
|
1119
1274
|
logger.debug(_color(msg, Fore.GREEN) if _HAS_COLORAMA else msg)
|
|
@@ -1367,6 +1522,22 @@ def _graceful_shutdown() -> None:
|
|
|
1367
1522
|
msg = f"[KEPT] {fp} - still has local edits, lock preserved"
|
|
1368
1523
|
logger.debug(_color(msg, Fore.GREEN) if _HAS_COLORAMA else msg)
|
|
1369
1524
|
else:
|
|
1525
|
+
# Enforce minimum hold time even during shutdown:
|
|
1526
|
+
# keep locks that were acquired too recently.
|
|
1527
|
+
acquired = _lock_acquired_at.get(fp)
|
|
1528
|
+
if acquired is not None:
|
|
1529
|
+
age = (datetime.now() - acquired).total_seconds()
|
|
1530
|
+
if age < _min_auto_lock_hold_seconds():
|
|
1531
|
+
n_kept += 1
|
|
1532
|
+
logger.debug(
|
|
1533
|
+
"⏳ [KEPT] %s — lock is only %ds old "
|
|
1534
|
+
"(< %ds minimum); preserving",
|
|
1535
|
+
fp,
|
|
1536
|
+
int(age),
|
|
1537
|
+
_min_auto_lock_hold_seconds(),
|
|
1538
|
+
)
|
|
1539
|
+
continue
|
|
1540
|
+
|
|
1370
1541
|
try:
|
|
1371
1542
|
_scope_agent(
|
|
1372
1543
|
client.table("file_locks")
|
|
@@ -1400,6 +1571,21 @@ def _graceful_shutdown() -> None:
|
|
|
1400
1571
|
]
|
|
1401
1572
|
for fp in db_locks:
|
|
1402
1573
|
if fp and fp not in still_dirty:
|
|
1574
|
+
# Enforce minimum hold time
|
|
1575
|
+
acquired = _lock_acquired_at.get(fp)
|
|
1576
|
+
if acquired is not None:
|
|
1577
|
+
age = (datetime.now() - acquired).total_seconds()
|
|
1578
|
+
if age < _min_auto_lock_hold_seconds():
|
|
1579
|
+
n_kept += 1
|
|
1580
|
+
logger.debug(
|
|
1581
|
+
"⏳ [KEPT] %s — lock is only %ds old "
|
|
1582
|
+
"(< %ds minimum); preserving",
|
|
1583
|
+
fp,
|
|
1584
|
+
int(age),
|
|
1585
|
+
_min_auto_lock_hold_seconds(),
|
|
1586
|
+
)
|
|
1587
|
+
continue
|
|
1588
|
+
|
|
1403
1589
|
_scope_agent(
|
|
1404
1590
|
client.table("file_locks")
|
|
1405
1591
|
.delete()
|