loki-mode 7.66.0 → 7.67.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/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.66.0'
60
+ __version__ = '7.67.0'
@@ -223,7 +223,17 @@ class ConsolidationPipeline:
223
223
  merged = False
224
224
  for idx, existing in enumerate(existing_patterns):
225
225
  if self._patterns_similar(new_pattern, existing):
226
- merged_pattern = self.merge_with_existing(new_pattern, [existing])
226
+ # Re-read the target pattern fresh immediately before
227
+ # merging (BUG-MEM C1, lost-update). The whole-run
228
+ # snapshot at step 4 can be stale by now: a concurrent
229
+ # engine.increment_pattern_usage() (load_pattern then
230
+ # save_pattern) may have bumped usage_count/last_used
231
+ # AFTER the snapshot. merge_with_existing() builds the
232
+ # merged record from best_match.usage_count/last_used,
233
+ # so merging from the stale snapshot clobbers that bump.
234
+ # Re-reading narrows the window to this single write.
235
+ merge_base = self._reload_pattern(existing)
236
+ merged_pattern = self.merge_with_existing(new_pattern, [merge_base])
227
237
  self.storage.update_pattern(merged_pattern)
228
238
  # Refresh the in-memory copy so a later new pattern in
229
239
  # this same run that also merges into this existing
@@ -262,7 +272,11 @@ class ConsolidationPipeline:
262
272
  for idx, existing in enumerate(existing_patterns):
263
273
  if (existing.incorrect_approach and
264
274
  self._patterns_similar(anti_pattern, existing, threshold=0.6)):
265
- merged_pattern = self.merge_with_existing(anti_pattern, [existing])
275
+ # Re-read fresh before merge (same C1 lost-update guard as the
276
+ # cluster merge loop above): merge from current on-disk state,
277
+ # not the potentially-stale step-4 snapshot.
278
+ merge_base = self._reload_pattern(existing)
279
+ merged_pattern = self.merge_with_existing(anti_pattern, [merge_base])
266
280
  self.storage.update_pattern(merged_pattern)
267
281
  # Refresh in-memory copy (same data-loss guard as the cluster
268
282
  # merge loop above): a later anti-pattern merging into this same
@@ -305,6 +319,35 @@ class ConsolidationPipeline:
305
319
  result.duration_seconds = time.time() - start_time
306
320
  return result
307
321
 
322
+ def _reload_pattern(self, fallback: SemanticPattern) -> SemanticPattern:
323
+ """Re-read a pattern fresh from storage immediately before merging.
324
+
325
+ Used by the merge branches to avoid the C1 lost-update: the step-4
326
+ snapshot may be stale (a concurrent usage bump can land after it), and
327
+ merge_with_existing() copies usage_count/last_used from the base. Reading
328
+ the current on-disk record makes the merge build on live state.
329
+
330
+ Mirrors the snapshot's dict/object handling. If load_pattern returns
331
+ nothing (e.g. the record vanished), fall back to the in-memory copy so the
332
+ merge still proceeds rather than crashing.
333
+
334
+ Residual limitation (honest): load_pattern and update_pattern are SEPARATE
335
+ lock acquisitions, so a bump landing between this re-read and the write is
336
+ still lost. This narrows the race window from the whole run to a single
337
+ write; it is a mitigation, not cross-process atomicity. A full fix needs a
338
+ compare-and-set or merge-callback update in storage, which is out of scope
339
+ for this file (storage.py is frozen this batch).
340
+ """
341
+ try:
342
+ fresh = self.storage.load_pattern(fallback.id)
343
+ except Exception:
344
+ return fallback
345
+ if not fresh:
346
+ return fallback
347
+ if isinstance(fresh, dict):
348
+ return SemanticPattern.from_dict(fresh)
349
+ return fresh
350
+
308
351
  # -------------------------------------------------------------------------
309
352
  # Clustering Methods
310
353
  # -------------------------------------------------------------------------
@@ -475,7 +518,10 @@ class ConsolidationPipeline:
475
518
 
476
519
  def _episode_to_text(self, episode: EpisodeTrace) -> str:
477
520
  """Convert episode to text for embedding."""
478
- parts = [episode.goal]
521
+ # Guard explicit-null goal (C2): from_dict's .get(key, default) returns
522
+ # None on a JSON null, and the " ".join(parts) below crashes on a None
523
+ # member. Mirror the wave-6 (episode.goal or "") idiom.
524
+ parts = [episode.goal or ""]
479
525
 
480
526
  # Add action summaries (handle both ActionEntry objects and dicts)
481
527
  for action in episode.action_log[:5]: # Limit to first 5 actions
@@ -505,7 +551,8 @@ class ConsolidationPipeline:
505
551
  # Find common words in goals
506
552
  all_words: Dict[str, int] = defaultdict(int)
507
553
  for episode in episodes:
508
- for word in episode.goal.lower().split():
554
+ # Guard explicit-null goal (C2): None has no .lower().
555
+ for word in (episode.goal or "").lower().split():
509
556
  if len(word) > 3:
510
557
  all_words[word] += 1
511
558
 
@@ -562,7 +609,11 @@ class ConsolidationPipeline:
562
609
  tool_counts: Dict[str, int] = defaultdict(int)
563
610
  for episode in cluster:
564
611
  for action in episode.action_log:
565
- tool_counts[action.tool] += 1
612
+ # Skip explicit-null tools (C2): an explicit JSON null tool would
613
+ # become a None dict key here, harmless until ", ".join(common_tools)
614
+ # at the end of _extract_correct_approach crashes on it.
615
+ if action.tool:
616
+ tool_counts[action.tool] += 1
566
617
 
567
618
  # Filter to tools used in most episodes
568
619
  common_tools = [
@@ -641,8 +692,10 @@ class ConsolidationPipeline:
641
692
  for episode in episodes:
642
693
  # Get actions before error
643
694
  if episode.action_log:
695
+ # Filter explicit-null tools (C2): pre_error_actions feeds
696
+ # _summarize_actions, whose ", ".join crashes on a None member.
644
697
  pre_error_actions.extend(
645
- [a.tool for a in episode.action_log[-3:]] # Last 3 actions
698
+ [a.tool for a in episode.action_log[-3:] if a.tool] # Last 3 actions
646
699
  )
647
700
 
648
701
  # Collect resolutions
@@ -718,7 +771,9 @@ class ConsolidationPipeline:
718
771
  for i, tool in enumerate(common_seq[:5], 1):
719
772
  steps.append(f"{i}. Use {tool}")
720
773
 
721
- return "; ".join(steps) if steps else f"Use: {', '.join(common_tools)}"
774
+ # Defensively drop any None tool before join (C2): callers filter Nones,
775
+ # but guard here too since this join crashes on a None member.
776
+ return "; ".join(steps) if steps else f"Use: {', '.join(t for t in common_tools if t)}"
722
777
 
723
778
  def _summarize_actions(self, actions: List[str]) -> str:
724
779
  """Summarize a list of actions into a description."""
@@ -981,7 +1036,8 @@ def compress_episode_to_summary(episode: EpisodeTrace) -> str:
981
1036
  action_count = len(episode.action_log)
982
1037
  error_count = len(episode.errors_encountered)
983
1038
 
984
- summary = f"Task '{episode.goal[:50]}' {outcome}"
1039
+ # Guard explicit-null goal (C2): None is not subscriptable.
1040
+ summary = f"Task '{(episode.goal or '')[:50]}' {outcome}"
985
1041
 
986
1042
  if action_count > 0:
987
1043
  summary += f" after {action_count} actions"
@@ -1012,10 +1068,13 @@ def compress_episodes_to_pattern_desc(episodes: List[EpisodeTrace]) -> str:
1012
1068
  return "Unknown pattern"
1013
1069
 
1014
1070
  if len(episodes) == 1:
1015
- return f"Pattern from: {episodes[0].goal}"
1071
+ # No slice here, so a None goal would f-string as "None" without crashing;
1072
+ # normalize to "" for cleaner output (C2).
1073
+ return f"Pattern from: {episodes[0].goal or ''}"
1016
1074
 
1017
1075
  # Find common goal elements
1018
- goals = [ep.goal.lower() for ep in episodes]
1076
+ # Guard explicit-null goal (C2): None has no .lower().
1077
+ goals = [(ep.goal or "").lower() for ep in episodes]
1019
1078
 
1020
1079
  # Find common words
1021
1080
  word_counts: Dict[str, int] = defaultdict(int)
@@ -1035,4 +1094,5 @@ def compress_episodes_to_pattern_desc(episodes: List[EpisodeTrace]) -> str:
1035
1094
  return f"Pattern for {theme} tasks ({len(episodes)} instances)"
1036
1095
 
1037
1096
  # Fallback to first episode's goal
1038
- return f"Pattern: {episodes[0].goal[:100]} (and {len(episodes)-1} similar)"
1097
+ # Guard explicit-null goal (C2): None is not subscriptable.
1098
+ return f"Pattern: {(episodes[0].goal or '')[:100]} (and {len(episodes)-1} similar)"
package/memory/storage.py CHANGED
@@ -317,10 +317,16 @@ class MemoryStorage:
317
317
  if lock_file is not None:
318
318
  fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
319
319
  lock_file.close()
320
- try:
321
- os.remove(lock_path)
322
- except OSError:
323
- pass
320
+ # Do NOT os.remove(lock_path) here. Unlinking the lock file on
321
+ # release is a flock+unlink inode-replacement race: with 3+
322
+ # contenders, holder A unlinks inode-1 after B (blocked on it)
323
+ # acquires it, then C opens the path, finds it gone, creates
324
+ # inode-2, and flocks inode-2 -- entering the critical section
325
+ # while B is still inside. That dropped index.json topics under
326
+ # concurrent store_pattern/store_episode (reproduced on Linux
327
+ # py3.13, 16 threads). Persistent lock files are the standard
328
+ # flock pattern; stale ones are GC'd by _cleanup_stale_locks,
329
+ # which is itself flock-safe (probe-before-unlink, wave-6).
324
330
 
325
331
  def _atomic_write(self, path: Path, data: dict) -> None:
326
332
  """
@@ -589,8 +595,32 @@ class MemoryStorage:
589
595
  lock_path.unlink()
590
596
  except OSError:
591
597
  pass
592
- # Clean up any remaining lock files before checking if dir is empty
598
+ # Clean up any remaining lock files before checking if dir
599
+ # is empty. A blanket unlink of every *.lock here is the same
600
+ # flock+unlink inode-replacement race fixed in _file_lock and
601
+ # _cleanup_stale_locks: a lock held by a concurrent writer of
602
+ # a DIFFERENT episode in this same date dir would have its
603
+ # inode unlinked, letting a third writer create a new inode
604
+ # and enter the critical section concurrently (data loss).
605
+ # Only unlink a lock we can take ourselves (nobody holds it);
606
+ # held locks are left in place (their writer is still active).
593
607
  for stale_lock in date_dir.glob("*.lock"):
608
+ probe_fd = None
609
+ try:
610
+ probe_fd = open(stale_lock, "a")
611
+ fcntl.flock(probe_fd.fileno(),
612
+ fcntl.LOCK_EX | fcntl.LOCK_NB)
613
+ except (OSError, BlockingIOError):
614
+ # Held by a live writer -- leave it alone.
615
+ continue
616
+ finally:
617
+ if probe_fd is not None:
618
+ try:
619
+ fcntl.flock(probe_fd.fileno(),
620
+ fcntl.LOCK_UN)
621
+ except OSError:
622
+ pass
623
+ probe_fd.close()
594
624
  try:
595
625
  stale_lock.unlink()
596
626
  except OSError:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "7.66.0",
4
+ "version": "7.67.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "7.66.0",
5
+ "version": "7.67.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",