claude-smart 0.2.49 → 0.2.50

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.
Files changed (177) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +10 -43
  3. package/bin/claude-smart.js +105 -0
  4. package/package.json +3 -2
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/.coverage +0 -0
  8. package/plugin/README.md +4 -3
  9. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
  10. package/plugin/dashboard/app/dashboard/page.tsx +6 -1
  11. package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
  12. package/plugin/dashboard/app/preferences/page.tsx +32 -35
  13. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
  14. package/plugin/dashboard/app/sessions/page.tsx +2 -0
  15. package/plugin/dashboard/app/skills/page.tsx +65 -50
  16. package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
  17. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
  18. package/plugin/dashboard/components/common/host-badge.tsx +118 -0
  19. package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
  20. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  21. package/plugin/dashboard/components/common/page-header.tsx +3 -3
  22. package/plugin/dashboard/lib/config-file.ts +5 -1
  23. package/plugin/dashboard/lib/host-attribution.ts +62 -0
  24. package/plugin/dashboard/lib/session-reader.ts +40 -2
  25. package/plugin/dashboard/lib/types.ts +7 -1
  26. package/plugin/pyproject.toml +1 -1
  27. package/plugin/scripts/_lib.sh +197 -1
  28. package/plugin/scripts/backend-python-runner.py +46 -0
  29. package/plugin/scripts/backend-service.sh +757 -119
  30. package/plugin/scripts/codex-hook.js +63 -225
  31. package/plugin/scripts/dashboard-open.sh +6 -4
  32. package/plugin/scripts/dashboard-service.sh +117 -136
  33. package/plugin/scripts/hook_entry.sh +3 -0
  34. package/plugin/scripts/smart-install.sh +15 -1
  35. package/plugin/src/claude_smart/cli.py +14 -0
  36. package/plugin/src/claude_smart/context_inject.py +3 -0
  37. package/plugin/src/claude_smart/env_config.py +4 -1
  38. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  39. package/plugin/src/claude_smart/events/session_end.py +2 -1
  40. package/plugin/src/claude_smart/events/stop.py +3 -0
  41. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  42. package/plugin/src/claude_smart/internal_call.py +5 -2
  43. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  44. package/plugin/src/claude_smart/publish.py +59 -7
  45. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  46. package/plugin/src/claude_smart/runtime.py +15 -6
  47. package/plugin/src/claude_smart/state.py +211 -52
  48. package/plugin/uv.lock +1 -1
  49. package/plugin/vendor/reflexio/.env.example +13 -0
  50. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  51. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  52. package/plugin/vendor/reflexio/reflexio/client/client.py +126 -3
  53. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  54. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  55. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  56. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +177 -25
  57. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  58. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  59. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  60. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  61. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  62. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  63. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  64. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  65. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/api.py +66 -3
  67. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  68. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  69. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  70. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  71. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +62 -5
  72. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  73. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  74. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  75. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  76. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  77. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +20 -5
  78. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  79. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  80. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  81. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  82. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  83. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  84. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  85. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  86. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  90. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  91. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  92. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  93. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  94. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  95. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  96. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  99. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  100. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  101. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  103. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  104. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  105. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  106. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +85 -16
  109. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  110. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  111. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  112. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  113. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  114. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  115. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  116. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +510 -63
  118. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  120. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  122. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  124. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  125. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  126. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  127. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  128. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  129. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  130. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  131. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  132. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  133. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +122 -44
  134. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  135. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  139. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +1 -1
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +50 -0
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +36 -1
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +43 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +45 -7
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +146 -6
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +42 -7
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +1 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +50 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +59 -0
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +52 -2
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  172. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  173. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  174. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  175. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  176. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  177. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
@@ -101,6 +101,23 @@ class OperationStateManager:
101
101
  parts.append(version)
102
102
  return "::".join(parts)
103
103
 
104
+ def _normalize_lock_request_id(
105
+ self,
106
+ lock_request_id: str | None,
107
+ *,
108
+ request_id: str | None = None,
109
+ ) -> str:
110
+ """Accept legacy ``request_id`` callers while keeping local lock naming."""
111
+ if lock_request_id is not None:
112
+ if request_id is not None and request_id != lock_request_id:
113
+ raise TypeError(
114
+ "lock_request_id and request_id must match when both are provided"
115
+ )
116
+ return lock_request_id
117
+ if request_id is not None:
118
+ return request_id
119
+ raise TypeError("lock_request_id is required")
120
+
104
121
  # ── Use Case 1: Progress Tracking ──
105
122
  # (Batch operations: rerun + manual)
106
123
 
@@ -380,10 +397,11 @@ class OperationStateManager:
380
397
 
381
398
  def acquire_lock(
382
399
  self,
383
- request_id: str,
400
+ lock_request_id: str | None = None,
384
401
  scope_id: str | None = None,
385
402
  stale_seconds: int = GENERATION_STALE_LOCK_SECONDS,
386
403
  payload: dict | None = None,
404
+ request_id: str | None = None,
387
405
  ) -> bool:
388
406
  """Atomically check and acquire in-progress lock.
389
407
 
@@ -396,7 +414,7 @@ class OperationStateManager:
396
414
  no valid lock exists or the lock is stale, acquires the lock.
397
415
 
398
416
  Args:
399
- request_id: Current request ID
417
+ lock_request_id: Request ID for the current lock holder
400
418
  scope_id: Optional scope identifier (e.g., user_id)
401
419
  stale_seconds: Seconds after which a lock is considered stale
402
420
  payload: Optional serialized request payload to enqueue with this
@@ -404,17 +422,22 @@ class OperationStateManager:
404
422
  rerun loop to operate on the SAME interactions the original
405
423
  publish enqueued, not whatever the bookmark currently points at
406
424
  (R2).
425
+ request_id: Legacy alias for ``lock_request_id``. When both are
426
+ provided they must match.
407
427
 
408
428
  Returns:
409
429
  bool: True if lock acquired (proceed with generation), False if skipped
410
430
  """
431
+ lock_request_id = self._normalize_lock_request_id(
432
+ lock_request_id, request_id=request_id
433
+ )
411
434
  state_key = self._lock_key(scope_id)
412
435
  # Pass ``payload`` as a kwarg so storage backends that haven't been
413
436
  # updated to the new signature still error loudly rather than silently
414
437
  # dropping it on the floor.
415
438
  result = self.storage.try_acquire_in_progress_lock(
416
439
  state_key,
417
- request_id,
440
+ lock_request_id,
418
441
  stale_seconds,
419
442
  payload=payload,
420
443
  )
@@ -423,25 +446,26 @@ class OperationStateManager:
423
446
 
424
447
  if acquired:
425
448
  logger.info(
426
- "Acquired in-progress lock for %s: state_key=%s, request_id=%s",
449
+ "Acquired in-progress lock for %s: state_key=%s, lock_request_id=%s",
427
450
  self.service_name,
428
451
  state_key,
429
- request_id,
452
+ lock_request_id,
430
453
  )
431
454
  else:
432
455
  logger.info(
433
456
  "Skipping %s - another operation is in progress (state_key=%s). "
434
- "Enqueued request_id=%s for drain on release",
457
+ "Enqueued lock_request_id=%s for drain on release",
435
458
  self.service_name,
436
459
  state_key,
437
- request_id,
460
+ lock_request_id,
438
461
  )
439
462
  return acquired
440
463
 
441
464
  def release_lock(
442
465
  self,
443
- request_id: str,
466
+ lock_request_id: str | None = None,
444
467
  scope_id: str | None = None,
468
+ request_id: str | None = None,
445
469
  ) -> str | None:
446
470
  """Release the in-progress lock and check if a new request came in.
447
471
 
@@ -449,12 +473,17 @@ class OperationStateManager:
449
473
  the caller can re-run. Otherwise clears the lock.
450
474
 
451
475
  Args:
452
- request_id: The request ID of the current operation
476
+ lock_request_id: Request ID for the current lock holder
453
477
  scope_id: Optional scope identifier (e.g., user_id)
478
+ request_id: Legacy alias for ``lock_request_id``. When both are
479
+ provided they must match.
454
480
 
455
481
  Returns:
456
482
  Optional[str]: pending_request_id if a new request needs processing, None otherwise
457
483
  """
484
+ lock_request_id = self._normalize_lock_request_id(
485
+ lock_request_id, request_id=request_id
486
+ )
458
487
  state_key = self._lock_key(scope_id)
459
488
  state_record = self.storage.get_operation_state(state_key)
460
489
 
@@ -473,8 +502,8 @@ class OperationStateManager:
473
502
  current_request_id = state.get("current_request_id") or state.get("request_id")
474
503
 
475
504
  # Only process if we still own the lock
476
- if current_request_id == request_id:
477
- if pending_request_id and pending_request_id != request_id:
505
+ if current_request_id == lock_request_id:
506
+ if pending_request_id and pending_request_id != lock_request_id:
478
507
  # Another request came in, transfer ownership and signal re-run
479
508
  self.storage.upsert_operation_state(
480
509
  state_key,
@@ -502,10 +531,10 @@ class OperationStateManager:
502
531
  },
503
532
  )
504
533
  logger.info(
505
- "Released in-progress lock for %s: state_key=%s, request_id=%s",
534
+ "Released in-progress lock for %s: state_key=%s, lock_request_id=%s",
506
535
  self.service_name,
507
536
  state_key,
508
- request_id,
537
+ lock_request_id,
509
538
  )
510
539
 
511
540
  return None
@@ -537,14 +566,18 @@ class OperationStateManager:
537
566
 
538
567
  def clear_lock_if_owner(
539
568
  self,
540
- request_id: str,
569
+ lock_request_id: str | None = None,
541
570
  scope_id: str | None = None,
571
+ request_id: str | None = None,
542
572
  ) -> bool:
543
- """Atomically clear a lock only if ``request_id`` still owns it."""
573
+ """Atomically clear a lock only if ``lock_request_id`` still owns it."""
574
+ lock_request_id = self._normalize_lock_request_id(
575
+ lock_request_id, request_id=request_id
576
+ )
544
577
  state_key = self._lock_key(scope_id)
545
578
  cleared = self.storage.clear_in_progress_lock_if_owner(
546
579
  state_key,
547
- request_id,
580
+ lock_request_id,
548
581
  {
549
582
  "in_progress": False,
550
583
  "current_request_id": None,
@@ -554,17 +587,18 @@ class OperationStateManager:
554
587
  )
555
588
  if cleared:
556
589
  logger.debug(
557
- "Cleared in-progress lock for %s: state_key=%s, request_id=%s",
590
+ "Cleared in-progress lock for %s: state_key=%s, lock_request_id=%s",
558
591
  self.service_name,
559
592
  state_key,
560
- request_id,
593
+ lock_request_id,
561
594
  )
562
595
  return cleared
563
596
 
564
597
  def release_lock_pop_queue(
565
598
  self,
566
- request_id: str,
599
+ lock_request_id: str | None = None,
567
600
  scope_id: str | None = None,
601
+ request_id: str | None = None,
568
602
  ) -> dict | None:
569
603
  """Release the lock and pop the next queued pending request, if any.
570
604
 
@@ -579,14 +613,19 @@ class OperationStateManager:
579
613
  to the original request rather than dropping it on the floor.
580
614
 
581
615
  Args:
582
- request_id: The request ID of the current operation (the holder)
616
+ lock_request_id: Request ID for the current lock holder
583
617
  scope_id: Optional scope identifier (e.g., user_id)
618
+ request_id: Legacy alias for ``lock_request_id``. When both are
619
+ provided they must match.
584
620
 
585
621
  Returns:
586
622
  dict | None: ``{"request_id": str, "payload": dict | None}`` for
587
623
  the next queued request, or None if the queue is empty / we're
588
624
  not the lock holder.
589
625
  """
626
+ lock_request_id = self._normalize_lock_request_id(
627
+ lock_request_id, request_id=request_id
628
+ )
590
629
  state_key = self._lock_key(scope_id)
591
630
  state_record = self.storage.get_operation_state(state_key)
592
631
 
@@ -603,7 +642,7 @@ class OperationStateManager:
603
642
  current_request_id = state.get("current_request_id") or state.get("request_id")
604
643
 
605
644
  # Only drain if we still own the lock.
606
- if current_request_id != request_id:
645
+ if current_request_id != lock_request_id:
607
646
  return None
608
647
 
609
648
  queue = list(state.get("pending_request_queue") or [])
@@ -621,7 +660,7 @@ class OperationStateManager:
621
660
  # Legacy fallback: storage row lacks the queue field but the old
622
661
  # pending_request_id slot was set by an in-flight pre-fix server.
623
662
  legacy_pending = state.get("pending_request_id")
624
- if legacy_pending and legacy_pending != request_id:
663
+ if legacy_pending and legacy_pending != lock_request_id:
625
664
  next_entry = {"request_id": legacy_pending, "payload": None}
626
665
 
627
666
  if next_entry is None:
@@ -636,16 +675,16 @@ class OperationStateManager:
636
675
  },
637
676
  )
638
677
  logger.info(
639
- "Released in-progress lock for %s: state_key=%s, request_id=%s",
678
+ "Released in-progress lock for %s: state_key=%s, lock_request_id=%s",
640
679
  self.service_name,
641
680
  state_key,
642
- request_id,
681
+ lock_request_id,
643
682
  )
644
683
  return None
645
684
 
646
- # Transfer ownership to the popped request it becomes the new holder.
647
- # Mirror the head request_id into the legacy single slot for the
648
- # release window during a server upgrade.
685
+ # Transfer ownership to the popped request. The storage boundary still
686
+ # persists queued holders under legacy ``request_id`` keys, plus the
687
+ # mirrored ``pending_request_id`` slot during upgrade windows.
649
688
  new_holder_request_id = next_entry["request_id"]
650
689
  legacy_mirror = queue[0]["request_id"] if queue else None
651
690
  self.storage.upsert_operation_state(
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import threading
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Any, Literal
8
+
9
+ from reflexio.server.api_endpoints.request_context import RequestContext
10
+ from reflexio.server.extensions import get_service
11
+ from reflexio.server.operation_limiter import run_with_operation_limit
12
+ from reflexio.server.services.playbook.aggregation_prompt_processing import (
13
+ AGGREGATION_PROMPT_PROCESSOR,
14
+ )
15
+ from reflexio.server.services.playbook.components.aggregator import PlaybookAggregator
16
+ from reflexio.server.services.playbook.playbook_service_utils import (
17
+ PlaybookAggregatorRequest,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ AggregationTriggerStatus = Literal[
23
+ "queued",
24
+ "triggered",
25
+ "skipped_no_config",
26
+ "skipped_saturated",
27
+ "failed",
28
+ ]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class AggregationTriggerResult:
33
+ status: AggregationTriggerStatus
34
+ reason: str
35
+ agent_version: str
36
+ error_type: str | None = None
37
+
38
+
39
+ def _failed_result(
40
+ *,
41
+ exc: Exception,
42
+ reason: str,
43
+ agent_version: str,
44
+ message: str,
45
+ ) -> AggregationTriggerResult:
46
+ logger.exception(
47
+ "%s agent_version=%s reason=%s",
48
+ message,
49
+ agent_version,
50
+ reason,
51
+ )
52
+ return AggregationTriggerResult(
53
+ status="failed",
54
+ reason=reason,
55
+ agent_version=agent_version,
56
+ error_type=type(exc).__name__,
57
+ )
58
+
59
+
60
+ def has_user_playbook_aggregation_config(request_context: RequestContext) -> bool:
61
+ root_config = request_context.configurator.get_config()
62
+ playbook_config = getattr(root_config, "user_playbook_extractor_config", None)
63
+ return bool(playbook_config and playbook_config.aggregation_config)
64
+
65
+
66
+ def _run_aggregation_task(
67
+ *,
68
+ request_context: RequestContext,
69
+ aggregator: PlaybookAggregator,
70
+ request: PlaybookAggregatorRequest,
71
+ reason: str,
72
+ ) -> AggregationTriggerResult:
73
+ agent_version = request.agent_version
74
+ try:
75
+ run_with_operation_limit(
76
+ org_id=request_context.org_id,
77
+ operation="aggregation",
78
+ fn=lambda: aggregator.run(request),
79
+ )
80
+ except TimeoutError:
81
+ logger.info(
82
+ "Skipping background aggregation for agent_version=%s reason=%s: aggregation limiter is saturated",
83
+ agent_version,
84
+ reason,
85
+ )
86
+ return AggregationTriggerResult(
87
+ status="skipped_saturated",
88
+ reason=reason,
89
+ agent_version=agent_version,
90
+ error_type="TimeoutError",
91
+ )
92
+ except Exception as exc: # noqa: BLE001
93
+ return _failed_result(
94
+ exc=exc,
95
+ reason=reason,
96
+ agent_version=agent_version,
97
+ message="User playbook aggregation failed after successor commit",
98
+ )
99
+ return AggregationTriggerResult(
100
+ status="triggered",
101
+ reason=reason,
102
+ agent_version=agent_version,
103
+ )
104
+
105
+
106
+ def _start_aggregation_thread(task: Callable[[], AggregationTriggerResult]) -> None:
107
+ thread = threading.Thread(
108
+ target=task,
109
+ name="reflexio-user-playbook-aggregation",
110
+ daemon=True,
111
+ )
112
+ thread.start()
113
+
114
+
115
+ def maybe_trigger_user_playbook_aggregation(
116
+ *,
117
+ request_context: RequestContext,
118
+ llm_client: Any,
119
+ agent_version: str,
120
+ reason: str,
121
+ ) -> AggregationTriggerResult:
122
+ try:
123
+ if not has_user_playbook_aggregation_config(request_context):
124
+ return AggregationTriggerResult(
125
+ status="skipped_no_config",
126
+ reason=reason,
127
+ agent_version=agent_version,
128
+ )
129
+
130
+ logger.info(
131
+ "Triggering user playbook aggregation agent_version=%s reason=%s",
132
+ agent_version,
133
+ reason,
134
+ )
135
+ aggregator_kwargs: dict[str, Any] = {}
136
+ aggregation_prompt_processor = get_service(AGGREGATION_PROMPT_PROCESSOR)
137
+ if aggregation_prompt_processor is not None:
138
+ aggregator_kwargs["aggregation_prompt_processor"] = (
139
+ aggregation_prompt_processor
140
+ )
141
+
142
+ aggregator = PlaybookAggregator(
143
+ llm_client=llm_client,
144
+ request_context=request_context,
145
+ agent_version=agent_version,
146
+ **aggregator_kwargs,
147
+ )
148
+ request = PlaybookAggregatorRequest(agent_version=agent_version)
149
+ try:
150
+ _start_aggregation_thread(
151
+ lambda: _run_aggregation_task(
152
+ request_context=request_context,
153
+ aggregator=aggregator,
154
+ request=request,
155
+ reason=reason,
156
+ )
157
+ )
158
+ except Exception as exc: # noqa: BLE001
159
+ return _failed_result(
160
+ exc=exc,
161
+ reason=reason,
162
+ agent_version=agent_version,
163
+ message="User playbook aggregation dispatch failed after successor commit",
164
+ )
165
+ except Exception as exc: # noqa: BLE001
166
+ return _failed_result(
167
+ exc=exc,
168
+ reason=reason,
169
+ agent_version=agent_version,
170
+ message="User playbook aggregation setup failed",
171
+ )
172
+
173
+ return AggregationTriggerResult(
174
+ status="queued",
175
+ reason=reason,
176
+ agent_version=agent_version,
177
+ )
@@ -4,8 +4,8 @@ import logging
4
4
  import os
5
5
  import time
6
6
  import uuid
7
- from collections.abc import Sequence
8
- from typing import TYPE_CHECKING
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import TYPE_CHECKING, Any
9
9
 
10
10
  if TYPE_CHECKING:
11
11
  import numpy as np
@@ -728,6 +728,17 @@ class PlaybookAggregator:
728
728
  duration_ms=int((time.perf_counter() - aggregation_start) * 1000),
729
729
  metadata=stats,
730
730
  )
731
+ self._record_learnings_generated(
732
+ learning_ids=[
733
+ str(saved.agent_playbook_id)
734
+ for saved in saved_playbook_list
735
+ if getattr(saved, "agent_playbook_id", None)
736
+ ],
737
+ playbook_name=playbook_name,
738
+ request_id=_run_id,
739
+ metadata=stats,
740
+ total_count=len(saved_playbook_list),
741
+ )
731
742
  return stats
732
743
 
733
744
  except Exception as e:
@@ -760,6 +771,61 @@ class PlaybookAggregator:
760
771
  # Re-raise the exception after restoring
761
772
  raise
762
773
 
774
+ def _record_learnings_generated(
775
+ self,
776
+ *,
777
+ learning_ids: list[str],
778
+ playbook_name: str,
779
+ request_id: str,
780
+ metadata: Mapping[str, Any],
781
+ total_count: int | None = None,
782
+ ) -> None:
783
+ """Emit ``learnings_generated`` for a completed aggregation run.
784
+
785
+ Prefers one event per learning id (entity-backed) when every saved
786
+ playbook in this run carries a durable ``agent_playbook_id`` — the
787
+ common case, since ``save_agent_playbook_with_aggregate_event``
788
+ raises rather than returning a partial row. Falls back to the
789
+ count-based aggregate event when ``learning_ids`` is short of
790
+ ``total_count`` (a falsy/unset id slipped through), mirroring
791
+ ``ExtractionResumeWorker._record_finalized_learnings`` — this avoids
792
+ emitting a colliding ``learn:agent_playbook:0`` key. ``total_count``
793
+ defaults to ``len(learning_ids)`` so callers that already guarantee a
794
+ complete id list (e.g. existing tests) are unaffected.
795
+ """
796
+ from reflexio.server.billing_meter import (
797
+ emit_learnings_generated,
798
+ emit_learnings_generated_records,
799
+ )
800
+
801
+ total = len(learning_ids) if total_count is None else total_count
802
+ if len(learning_ids) == total:
803
+ emit_learnings_generated_records(
804
+ org_id=self.request_context.org_id,
805
+ configurator=self.configurator,
806
+ learning_ids=learning_ids,
807
+ source="aggregation",
808
+ pipeline="playbook",
809
+ request_id=request_id,
810
+ agent_version=self.agent_version,
811
+ playbook_name=playbook_name,
812
+ entity_type="agent_playbook",
813
+ metadata=metadata,
814
+ )
815
+ return
816
+ emit_learnings_generated(
817
+ org_id=self.request_context.org_id,
818
+ configurator=self.configurator,
819
+ count=total,
820
+ source="aggregation",
821
+ pipeline="playbook",
822
+ request_id=request_id,
823
+ agent_version=self.agent_version,
824
+ playbook_name=playbook_name,
825
+ entity_type="agent_playbook",
826
+ metadata=metadata,
827
+ )
828
+
763
829
  def get_clusters(
764
830
  self,
765
831
  user_playbooks: list[UserPlaybook],