loki-mode 6.62.0 → 6.63.0
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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/hooks/migration-hooks.sh +10 -1
- package/autonomy/issue-providers.sh +7 -2
- package/autonomy/run.sh +444 -83
- package/autonomy/sandbox.sh +5 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +11 -2
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/memory/engine.py +1 -0
- package/package.json +1 -1
- package/state/manager.py +127 -32
- package/web-app/server.py +437 -43
package/autonomy/sandbox.sh
CHANGED
|
@@ -904,7 +904,9 @@ except: pass
|
|
|
904
904
|
local container_path="${parts[1]}"
|
|
905
905
|
local mode="${parts[2]:-ro}"
|
|
906
906
|
|
|
907
|
-
# Safe tilde expansion (no eval)
|
|
907
|
+
# Safe tilde expansion (no eval) - SC2088 disabled: tilde is intentionally
|
|
908
|
+
# treated as a literal string here for safe expansion without eval
|
|
909
|
+
# shellcheck disable=SC2088
|
|
908
910
|
if [[ "$host_path" == "~/"* ]]; then
|
|
909
911
|
host_path="$HOME/${host_path#\~/}"
|
|
910
912
|
elif [[ "$host_path" == "~" ]]; then
|
|
@@ -1121,7 +1123,8 @@ start_sandbox() {
|
|
|
1121
1123
|
local c_container="${mount_parts[1]:-}"
|
|
1122
1124
|
local c_mode="${mount_parts[2]:-ro}"
|
|
1123
1125
|
if [[ -n "$c_host" ]] && [[ -n "$c_container" ]]; then
|
|
1124
|
-
# Safe tilde expansion (no eval)
|
|
1126
|
+
# Safe tilde expansion (no eval) - SC2088 disabled: intentional literal match
|
|
1127
|
+
# shellcheck disable=SC2088
|
|
1125
1128
|
if [[ "$c_host" == "~/"* ]]; then
|
|
1126
1129
|
c_host="$HOME/${c_host#\~/}"
|
|
1127
1130
|
elif [[ "$c_host" == "~" ]]; then
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -1085,7 +1085,7 @@ async def list_tasks(
|
|
|
1085
1085
|
# Skip if already in all_tasks
|
|
1086
1086
|
if any(t["id"] == tid for t in all_tasks):
|
|
1087
1087
|
continue
|
|
1088
|
-
|
|
1088
|
+
task_entry = {
|
|
1089
1089
|
"id": tid,
|
|
1090
1090
|
"title": item.get("title", item.get("action", "Task")),
|
|
1091
1091
|
"description": item.get("description", ""),
|
|
@@ -1093,7 +1093,16 @@ async def list_tasks(
|
|
|
1093
1093
|
"priority": item.get("priority", "medium"),
|
|
1094
1094
|
"type": item.get("type", "task"),
|
|
1095
1095
|
"position": i,
|
|
1096
|
-
}
|
|
1096
|
+
}
|
|
1097
|
+
if item.get("acceptance_criteria"):
|
|
1098
|
+
task_entry["acceptance_criteria"] = item["acceptance_criteria"]
|
|
1099
|
+
if item.get("user_story"):
|
|
1100
|
+
task_entry["user_story"] = item["user_story"]
|
|
1101
|
+
if item.get("project"):
|
|
1102
|
+
task_entry["project"] = item["project"]
|
|
1103
|
+
if item.get("source"):
|
|
1104
|
+
task_entry["source"] = item["source"]
|
|
1105
|
+
all_tasks.append(task_entry)
|
|
1097
1106
|
except (json.JSONDecodeError, KeyError):
|
|
1098
1107
|
pass
|
|
1099
1108
|
|
package/docs/INSTALLATION.md
CHANGED
package/mcp/__init__.py
CHANGED
package/memory/engine.py
CHANGED
|
@@ -360,6 +360,7 @@ class MemoryEngine:
|
|
|
360
360
|
pattern_id = self.storage.save_pattern(pattern)
|
|
361
361
|
|
|
362
362
|
# Update index
|
|
363
|
+
pattern_dict = pattern.model_dump() if hasattr(pattern, "model_dump") else pattern.__dict__
|
|
363
364
|
self._update_index_with_pattern(pattern_dict)
|
|
364
365
|
|
|
365
366
|
return pattern_id
|
package/package.json
CHANGED
package/state/manager.py
CHANGED
|
@@ -22,6 +22,7 @@ from pathlib import Path
|
|
|
22
22
|
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
|
23
23
|
from enum import Enum
|
|
24
24
|
from contextlib import contextmanager
|
|
25
|
+
import copy
|
|
25
26
|
import uuid
|
|
26
27
|
import glob as glob_module
|
|
27
28
|
|
|
@@ -102,11 +103,11 @@ class VersionVector:
|
|
|
102
103
|
def concurrent_with(self, other: "VersionVector") -> bool:
|
|
103
104
|
"""Check if two vectors are concurrent (neither dominates).
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
Identical vectors represent the same causal point, NOT
|
|
107
|
+
concurrent operations, so they return False (BUG-ST-006).
|
|
107
108
|
"""
|
|
108
109
|
if self.versions == other.versions:
|
|
109
|
-
return
|
|
110
|
+
return False
|
|
110
111
|
return not self.dominates(other) and not other.dominates(self)
|
|
111
112
|
|
|
112
113
|
def to_dict(self) -> Dict[str, int]:
|
|
@@ -796,6 +797,9 @@ class StateManager:
|
|
|
796
797
|
"""
|
|
797
798
|
Merge updates into existing state.
|
|
798
799
|
|
|
800
|
+
Holds a file lock across the entire read-modify-write to prevent
|
|
801
|
+
lost updates from concurrent callers (BUG-ST-002).
|
|
802
|
+
|
|
799
803
|
Args:
|
|
800
804
|
file_ref: File reference
|
|
801
805
|
updates: Dictionary of updates to merge
|
|
@@ -804,9 +808,57 @@ class StateManager:
|
|
|
804
808
|
Returns:
|
|
805
809
|
StateChange object
|
|
806
810
|
"""
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
811
|
+
path = self._resolve_path(file_ref)
|
|
812
|
+
|
|
813
|
+
with self._file_lock(path, exclusive=True):
|
|
814
|
+
# Read current state under the lock (bypass cache to get
|
|
815
|
+
# the true on-disk value while we hold the exclusive lock)
|
|
816
|
+
old_value = None
|
|
817
|
+
if path.exists():
|
|
818
|
+
try:
|
|
819
|
+
with open(path, "r") as f:
|
|
820
|
+
old_value = json.load(f)
|
|
821
|
+
except (json.JSONDecodeError, IOError):
|
|
822
|
+
old_value = None
|
|
823
|
+
|
|
824
|
+
current = old_value if old_value is not None else {}
|
|
825
|
+
merged = {**current, **updates}
|
|
826
|
+
change_type = "create" if old_value is None else "update"
|
|
827
|
+
|
|
828
|
+
# Save version before writing new data (SYN-015)
|
|
829
|
+
if self.enable_versioning and old_value is not None:
|
|
830
|
+
self._save_version(file_ref, old_value, source, change_type)
|
|
831
|
+
|
|
832
|
+
# Write atomically (temp file + rename) while still under lock
|
|
833
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
834
|
+
fd, temp_path = tempfile.mkstemp(
|
|
835
|
+
dir=path.parent, prefix=".tmp_", suffix=".json"
|
|
836
|
+
)
|
|
837
|
+
try:
|
|
838
|
+
with os.fdopen(fd, "w") as f:
|
|
839
|
+
json.dump(merged, f, indent=2, default=str)
|
|
840
|
+
shutil.move(temp_path, path)
|
|
841
|
+
except Exception:
|
|
842
|
+
if os.path.exists(temp_path):
|
|
843
|
+
os.unlink(temp_path)
|
|
844
|
+
raise
|
|
845
|
+
|
|
846
|
+
# Update cache
|
|
847
|
+
self._put_in_cache(path, merged)
|
|
848
|
+
|
|
849
|
+
# Create change object
|
|
850
|
+
change = StateChange(
|
|
851
|
+
file_path=str(path.relative_to(self.loki_dir)),
|
|
852
|
+
old_value=old_value,
|
|
853
|
+
new_value=merged,
|
|
854
|
+
change_type=change_type,
|
|
855
|
+
source=source
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
# Broadcast change
|
|
859
|
+
self._broadcast(change)
|
|
860
|
+
|
|
861
|
+
return change
|
|
810
862
|
|
|
811
863
|
def delete_state(
|
|
812
864
|
self,
|
|
@@ -1147,16 +1199,38 @@ class StateManager:
|
|
|
1147
1199
|
return states
|
|
1148
1200
|
|
|
1149
1201
|
def refresh_cache(self) -> None:
|
|
1150
|
-
"""Refresh all cached entries from disk.
|
|
1202
|
+
"""Refresh all cached entries from disk.
|
|
1203
|
+
|
|
1204
|
+
Collects paths under _cache_lock, releases it, reads files
|
|
1205
|
+
(which acquire file locks), then re-acquires _cache_lock to
|
|
1206
|
+
update entries. This avoids an ABBA deadlock between the
|
|
1207
|
+
cache lock and file locks (BUG-ST-001).
|
|
1208
|
+
"""
|
|
1209
|
+
# Step 1: collect paths under the cache lock
|
|
1210
|
+
with self._cache_lock:
|
|
1211
|
+
paths_to_refresh = list(self._cache.keys())
|
|
1212
|
+
|
|
1213
|
+
# Step 2: read files WITHOUT holding _cache_lock
|
|
1214
|
+
refreshed: dict = {}
|
|
1215
|
+
gone: list = []
|
|
1216
|
+
for path_str in paths_to_refresh:
|
|
1217
|
+
path = Path(path_str)
|
|
1218
|
+
if path.exists():
|
|
1219
|
+
data = self._read_file(path)
|
|
1220
|
+
if data:
|
|
1221
|
+
refreshed[path_str] = data
|
|
1222
|
+
else:
|
|
1223
|
+
gone.append(path_str)
|
|
1224
|
+
|
|
1225
|
+
# Step 3: re-acquire _cache_lock and update entries
|
|
1151
1226
|
with self._cache_lock:
|
|
1152
|
-
for path_str in
|
|
1227
|
+
for path_str, data in refreshed.items():
|
|
1153
1228
|
path = Path(path_str)
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
del self._cache[path_str]
|
|
1229
|
+
data_hash = self._compute_hash(data)
|
|
1230
|
+
mtime = path.stat().st_mtime if path.exists() else 0
|
|
1231
|
+
self._cache[path_str] = (data, data_hash, mtime)
|
|
1232
|
+
for path_str in gone:
|
|
1233
|
+
self._cache.pop(path_str, None)
|
|
1160
1234
|
|
|
1161
1235
|
# -------------------------------------------------------------------------
|
|
1162
1236
|
# Optimistic Updates (SYN-014)
|
|
@@ -1231,8 +1305,9 @@ class StateManager:
|
|
|
1231
1305
|
self._pending_updates[path_str] = []
|
|
1232
1306
|
self._pending_updates[path_str].append(pending)
|
|
1233
1307
|
|
|
1234
|
-
# Apply optimistically to local state
|
|
1235
|
-
|
|
1308
|
+
# Apply optimistically to local state -- deepcopy to avoid
|
|
1309
|
+
# mutating the cached dict in-place (BUG-ST-011)
|
|
1310
|
+
current_state = copy.deepcopy(self.get_state(file_ref, default={}))
|
|
1236
1311
|
current_state[key] = value
|
|
1237
1312
|
current_state["_version_vector"] = version_vector.to_dict()
|
|
1238
1313
|
current_state["_last_source"] = source
|
|
@@ -1394,14 +1469,23 @@ class StateManager:
|
|
|
1394
1469
|
return merged
|
|
1395
1470
|
|
|
1396
1471
|
elif isinstance(local, list) and isinstance(remote, list):
|
|
1397
|
-
# Concatenate and deduplicate (preserving order)
|
|
1472
|
+
# Concatenate and deduplicate (preserving order).
|
|
1473
|
+
# Use try/except to handle unhashable types gracefully
|
|
1474
|
+
# by falling back to JSON serialization (BUG-ST-013).
|
|
1398
1475
|
seen = set()
|
|
1399
1476
|
merged = []
|
|
1400
1477
|
for item in local + remote:
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
seen
|
|
1404
|
-
|
|
1478
|
+
try:
|
|
1479
|
+
item_key = json.dumps(item, sort_keys=True, default=str) if isinstance(item, (dict, list)) else item
|
|
1480
|
+
if item_key not in seen:
|
|
1481
|
+
seen.add(item_key)
|
|
1482
|
+
merged.append(item)
|
|
1483
|
+
except TypeError:
|
|
1484
|
+
# Unhashable type -- fall back to JSON string as key
|
|
1485
|
+
item_key = json.dumps(item, sort_keys=True, default=str)
|
|
1486
|
+
if item_key not in seen:
|
|
1487
|
+
seen.add(item_key)
|
|
1488
|
+
merged.append(item)
|
|
1405
1489
|
return merged
|
|
1406
1490
|
|
|
1407
1491
|
else:
|
|
@@ -1598,23 +1682,27 @@ class StateManager:
|
|
|
1598
1682
|
return version
|
|
1599
1683
|
|
|
1600
1684
|
def _cleanup_old_versions(self, file_ref: Union[str, ManagedFile]) -> None:
|
|
1601
|
-
"""Remove versions beyond the retention limit.
|
|
1685
|
+
"""Remove versions beyond the retention limit.
|
|
1686
|
+
|
|
1687
|
+
Only considers files with purely numeric stems so that orphan
|
|
1688
|
+
temp files (e.g. .tmp_xxx.json) are not counted toward the
|
|
1689
|
+
retention limit (BUG-ST-010).
|
|
1690
|
+
"""
|
|
1602
1691
|
history_dir = self._get_history_dir(file_ref)
|
|
1603
1692
|
if not history_dir.exists():
|
|
1604
1693
|
return
|
|
1605
1694
|
|
|
1606
1695
|
version_files = glob_module.glob(str(history_dir / "*.json"))
|
|
1607
|
-
if len(version_files) <= self.version_retention:
|
|
1608
|
-
return
|
|
1609
1696
|
|
|
1610
|
-
#
|
|
1697
|
+
# Filter to numeric stems only (skip temp/orphan files)
|
|
1611
1698
|
version_nums = []
|
|
1612
1699
|
for vf in version_files:
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
version_nums.append((
|
|
1616
|
-
|
|
1617
|
-
|
|
1700
|
+
stem = Path(vf).stem
|
|
1701
|
+
if stem.isdigit():
|
|
1702
|
+
version_nums.append((int(stem), vf))
|
|
1703
|
+
|
|
1704
|
+
if len(version_nums) <= self.version_retention:
|
|
1705
|
+
return
|
|
1618
1706
|
|
|
1619
1707
|
version_nums.sort(key=lambda x: x[0])
|
|
1620
1708
|
to_remove = version_nums[:-self.version_retention]
|
|
@@ -1777,17 +1865,24 @@ class StateManager:
|
|
|
1777
1865
|
|
|
1778
1866
|
# Singleton instance for convenience
|
|
1779
1867
|
_default_manager: Optional[StateManager] = None
|
|
1868
|
+
_default_manager_lock = threading.Lock()
|
|
1780
1869
|
|
|
1781
1870
|
|
|
1782
1871
|
def get_state_manager(
|
|
1783
1872
|
loki_dir: Optional[Union[str, Path]] = None,
|
|
1784
1873
|
**kwargs
|
|
1785
1874
|
) -> StateManager:
|
|
1786
|
-
"""Get the default state manager instance.
|
|
1875
|
+
"""Get the default state manager instance.
|
|
1876
|
+
|
|
1877
|
+
Uses double-checked locking to avoid a race where two threads
|
|
1878
|
+
could both create a StateManager simultaneously (BUG-ST-007).
|
|
1879
|
+
"""
|
|
1787
1880
|
global _default_manager
|
|
1788
1881
|
|
|
1789
1882
|
if _default_manager is None:
|
|
1790
|
-
|
|
1883
|
+
with _default_manager_lock:
|
|
1884
|
+
if _default_manager is None:
|
|
1885
|
+
_default_manager = StateManager(loki_dir, **kwargs)
|
|
1791
1886
|
|
|
1792
1887
|
return _default_manager
|
|
1793
1888
|
|