loki-mode 7.67.0 → 7.69.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.
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.67.0"
10
+ __version__ = "7.69.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -122,32 +122,43 @@ class _RateLimiter:
122
122
  self._window = window_seconds
123
123
  self._max_keys = max_keys
124
124
  self._calls: dict[str, list[float]] = defaultdict(list)
125
+ # Sync route handlers (plain `def`) run in Starlette's threadpool, so
126
+ # check() can be entered by several threads at once against this one
127
+ # shared instance. Without a guard, one thread iterating self._calls
128
+ # (the empty-key prune or the LRU-eviction sort) while another inserts
129
+ # or deletes a key raises "dictionary changed size during iteration",
130
+ # which surfaces to the caller as a 500 on a trivial rate-limit guard.
131
+ # The lock is held only around the in-memory bookkeeping (no I/O, no
132
+ # await), so contention is negligible and it cannot deadlock async
133
+ # callers that reach this via run_in_threadpool.
134
+ self._lock = threading.Lock()
125
135
 
126
136
  def check(self, key: str) -> bool:
127
137
  now = time.time()
128
- # Prune old timestamps for this key
129
- self._calls[key] = [t for t in self._calls[key] if now - t < self._window]
130
-
131
- # Remove keys with empty timestamp lists
132
- empty_keys = [k for k, v in self._calls.items() if not v]
133
- for k in empty_keys:
134
- del self._calls[k]
135
-
136
- # Evict least-recently-accessed keys if max_keys exceeded
137
- if len(self._calls) > self._max_keys:
138
- # Sort by last-access time (most recent timestamp), evict least recent
139
- sorted_keys = sorted(
140
- self._calls.items(),
141
- key=lambda x: max(x[1]) if x[1] else 0
142
- )
143
- keys_to_remove = len(self._calls) - self._max_keys
144
- for k, _ in sorted_keys[:keys_to_remove]:
138
+ with self._lock:
139
+ # Prune old timestamps for this key
140
+ self._calls[key] = [t for t in self._calls[key] if now - t < self._window]
141
+
142
+ # Remove keys with empty timestamp lists
143
+ empty_keys = [k for k, v in self._calls.items() if not v]
144
+ for k in empty_keys:
145
145
  del self._calls[k]
146
146
 
147
- if len(self._calls[key]) >= self._max_calls:
148
- return False
149
- self._calls[key].append(now)
150
- return True
147
+ # Evict least-recently-accessed keys if max_keys exceeded
148
+ if len(self._calls) > self._max_keys:
149
+ # Sort by last-access time (most recent timestamp), evict least recent
150
+ sorted_keys = sorted(
151
+ self._calls.items(),
152
+ key=lambda x: max(x[1]) if x[1] else 0
153
+ )
154
+ keys_to_remove = len(self._calls) - self._max_keys
155
+ for k, _ in sorted_keys[:keys_to_remove]:
156
+ del self._calls[k]
157
+
158
+ if len(self._calls[key]) >= self._max_calls:
159
+ return False
160
+ self._calls[key].append(now)
161
+ return True
151
162
 
152
163
 
153
164
  _control_limiter = _RateLimiter(max_calls=10, window_seconds=60)
@@ -8675,6 +8686,17 @@ def _get_migration_imports():
8675
8686
  return _migration_imports
8676
8687
 
8677
8688
 
8689
+ def _get_migration_terminal_phase():
8690
+ """Return the last phase in the migration PHASE_ORDER (the terminal phase),
8691
+ or None if the migration engine is unavailable. Used to let the terminal
8692
+ phase be advanced/completed without a successor to_phase (WAVE9 F1)."""
8693
+ try:
8694
+ from dashboard.migration_engine import PHASE_ORDER
8695
+ return PHASE_ORDER[-1] if PHASE_ORDER else None
8696
+ except (ImportError, IndexError):
8697
+ return None
8698
+
8699
+
8678
8700
  @app.get("/api/migration/list", dependencies=[Depends(auth.require_scope("read"))])
8679
8701
  def list_migrations_endpoint():
8680
8702
  """List all migrations."""
@@ -8825,7 +8847,16 @@ def advance_migration(migration_id: str, request_body: dict):
8825
8847
  MigrationPipeline, list_migrations = imports
8826
8848
  from_phase = request_body.get("from_phase")
8827
8849
  to_phase = request_body.get("to_phase")
8828
- if not from_phase or not to_phase:
8850
+ # The terminal phase (the last in PHASE_ORDER, e.g. "verify") has no
8851
+ # successor, so check_phase_gate can never pass for it and to_phase is
8852
+ # meaningless. Without this carve-out the terminal phase could never be
8853
+ # completed via the API, so overall_status could never reach "completed"
8854
+ # (WAVE9 migration-F1). For the terminal phase we require only from_phase
8855
+ # and skip the gate; advance_phase still validates the phase and the
8856
+ # (ValueError, RuntimeError) -> 409 handler below preserves idempotency.
8857
+ terminal_phase = _get_migration_terminal_phase()
8858
+ is_terminal = terminal_phase is not None and from_phase == terminal_phase
8859
+ if not from_phase or (not to_phase and not is_terminal):
8829
8860
  raise HTTPException(status_code=400, detail="from_phase and to_phase are required")
8830
8861
  # Load pipeline and check phase gate before the try/except to let
8831
8862
  # HTTPException and FileNotFoundError propagate naturally.
@@ -8833,9 +8864,10 @@ def advance_migration(migration_id: str, request_body: dict):
8833
8864
  pipeline = MigrationPipeline.load(migration_id)
8834
8865
  except FileNotFoundError:
8835
8866
  raise HTTPException(status_code=404, detail=f"Migration not found: {migration_id}")
8836
- passed, reason = pipeline.check_phase_gate(from_phase, to_phase)
8837
- if not passed:
8838
- raise HTTPException(status_code=409, detail=reason)
8867
+ if not is_terminal:
8868
+ passed, reason = pipeline.check_phase_gate(from_phase, to_phase)
8869
+ if not passed:
8870
+ raise HTTPException(status_code=409, detail=reason)
8839
8871
  try:
8840
8872
  result = pipeline.advance_phase(from_phase)
8841
8873
  return asdict(result) if hasattr(result, '__dataclass_fields__') else result
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v7.67.0
5
+ **Version:** v7.69.0
6
6
 
7
7
  ---
8
8
 
@@ -395,7 +395,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
395
395
  # Run Loki Mode in Docker (Claude provider, API-key auth)
396
396
  docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
397
397
  -v $(pwd):/workspace -w /workspace \
398
- asklokesh/loki-mode:7.67.0 start ./my-spec.md
398
+ asklokesh/loki-mode:7.69.0 start ./my-spec.md
399
399
  ```
400
400
 
401
401
  ##### docker compose + .env (no host install)