claude-smart 0.2.48 → 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 (193) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +11 -40
  3. package/bin/claude-smart.js +393 -55
  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/opencode/dist/server.mjs +60 -2
  27. package/plugin/opencode/server.mts +64 -2
  28. package/plugin/pyproject.toml +2 -2
  29. package/plugin/scripts/_lib.sh +255 -7
  30. package/plugin/scripts/backend-python-runner.py +46 -0
  31. package/plugin/scripts/backend-service.sh +766 -123
  32. package/plugin/scripts/codex-hook.js +79 -229
  33. package/plugin/scripts/dashboard-open.sh +6 -4
  34. package/plugin/scripts/dashboard-service.sh +118 -137
  35. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  36. package/plugin/scripts/hook_entry.sh +3 -0
  37. package/plugin/scripts/opencode-claude-compat.js +8 -1
  38. package/plugin/scripts/smart-install.sh +116 -21
  39. package/plugin/src/README.md +1 -1
  40. package/plugin/src/claude_smart/cli.py +93 -29
  41. package/plugin/src/claude_smart/context_inject.py +3 -0
  42. package/plugin/src/claude_smart/env_config.py +56 -11
  43. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  44. package/plugin/src/claude_smart/events/session_end.py +2 -1
  45. package/plugin/src/claude_smart/events/stop.py +3 -0
  46. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  47. package/plugin/src/claude_smart/internal_call.py +5 -2
  48. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  49. package/plugin/src/claude_smart/publish.py +59 -7
  50. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  51. package/plugin/src/claude_smart/runtime.py +15 -6
  52. package/plugin/src/claude_smart/state.py +211 -52
  53. package/plugin/uv.lock +5 -5
  54. package/plugin/vendor/reflexio/.env.example +13 -0
  55. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  56. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  57. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  58. package/plugin/vendor/reflexio/reflexio/client/client.py +166 -9
  59. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  60. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  61. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  62. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +195 -25
  63. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  64. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  65. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  66. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  67. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  68. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  69. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  70. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  71. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  72. package/plugin/vendor/reflexio/reflexio/server/api.py +79 -5
  73. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  74. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  75. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  76. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  77. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +63 -6
  78. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  79. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1 -0
  80. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  81. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  82. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  83. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  84. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +199 -2
  85. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  86. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  90. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  91. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  92. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  93. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  94. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  95. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  96. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  97. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  98. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  99. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +63 -2
  100. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  101. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  102. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  103. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  107. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  108. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  109. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  111. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  112. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  113. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  114. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  115. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  116. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  118. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +262 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  120. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  121. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  122. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  123. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  124. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  125. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  126. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +1 -3
  127. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +640 -80
  129. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  131. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  133. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  135. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  136. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  137. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  138. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  139. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  140. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  141. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  142. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  143. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  144. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +166 -66
  145. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  146. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  150. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +2 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +190 -2
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +414 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -5
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +86 -2
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +59 -7
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +57 -12
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +197 -12
  172. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +70 -11
  173. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +17 -0
  174. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  175. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  176. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +269 -0
  177. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  178. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  179. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  180. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  181. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  182. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  183. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  184. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  185. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +82 -1
  186. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +74 -2
  187. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  188. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  189. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  190. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  191. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  192. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  193. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
@@ -0,0 +1,270 @@
1
+ """Write-plan dataclasses for the durable-learning compute/persist split (gate b).
2
+
3
+ Compute resolves these in-memory plans issuing **no** learning DB write; persist
4
+ applies them inside one short fenced ``commit_scope``. This module starts with the
5
+ extractor stride-bookmark advance and grows additional write-plan dataclasses in the
6
+ later gate-(b) tasks (profile/playbook/reflection/generation/deferred plans).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ if TYPE_CHECKING:
15
+ from reflexio.models.api_schema.domain.entities import (
16
+ UserPlaybook,
17
+ UserProfile,
18
+ )
19
+ from reflexio.models.api_schema.service_schemas import Interaction
20
+ from reflexio.server.llm.token_accounting import RunTokenTotals
21
+ from reflexio.server.services.base_generation_service import (
22
+ BaseGenerationService,
23
+ PreparedGenerationRun,
24
+ )
25
+ from reflexio.server.services.reflection.reflection_service_utils import (
26
+ ReflectionDecision,
27
+ ReflectionResult,
28
+ ReflectionServiceRequest,
29
+ )
30
+ from reflexio.server.services.reflection.service import ReflectionService
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ExtractorBookmarkAdvance:
35
+ """The extractor stride-bookmark advance, deferred out of the extractor (F1).
36
+
37
+ The extractor no longer self-advances its bookmark inside ``run()`` (a DB
38
+ write). It emits this on the ``ExtractionOutcome`` instead, so the advance is
39
+ applied later — inside the persist fence on the durable path, or right after
40
+ result-processing on the synchronous ``.run()`` path — keeping the bookmark
41
+ advance atomic with the row writes it corresponds to.
42
+ """
43
+
44
+ extractor_name: str
45
+ processed_interactions: list[Interaction]
46
+ # str | None (not str): the playbook extractor runs org-level with
47
+ # user_id=None (PlaybookGenerationServiceConfig.user_id is Optional), which
48
+ # maps to an unscoped bookmark key. update_extractor_bookmark accepts None.
49
+ user_id: str | None
50
+
51
+
52
+ @dataclass
53
+ class ProfileWritePlan:
54
+ """Resolved profile write-plan for the compute/persist split (gate b).
55
+
56
+ ``ProfileGenerationService._resolve_write_plan`` produces this in compute
57
+ (dedup 2nd-LLM call + existing-row reads + source/status assignment +
58
+ precomputed ``.embedding`` via ``precompute_profile_embeddings``), issuing
59
+ **no** learning DB write. ``_persist_write_plan`` applies it inside the
60
+ fence: ``add_user_profile(..., skip_embedding=True)`` (embeddings already
61
+ set) then ``supersede_profiles_by_ids``.
62
+
63
+ Attributes:
64
+ user_id: Owning user id for the row writes.
65
+ request_id: Generation request id — the lineage key
66
+ ``supersede_profiles_by_ids`` records. Compute drops any supersede
67
+ ids when this is empty (unreconstructable), so persist only ever
68
+ supersedes with a non-empty request_id.
69
+ new_profiles: New profile rows with ``source``/``status`` set and
70
+ ``.embedding`` precomputed in compute.
71
+ superseded_ids: Existing profile ids to soft-supersede on the dedup
72
+ path.
73
+ """
74
+
75
+ user_id: str
76
+ request_id: str
77
+ new_profiles: list[UserProfile]
78
+ superseded_ids: list[str]
79
+
80
+
81
+ @dataclass
82
+ class PlaybookWritePlan:
83
+ """Resolved playbook write-plan for the compute/persist split (gate b).
84
+
85
+ ``PlaybookGenerationService._resolve_write_plan`` produces this in compute
86
+ (``dedupe_and_drop_empty`` + the deduplicator's 2nd-LLM call + existing-row
87
+ reads + source/status assignment + precomputed ``.embedding`` via
88
+ ``precompute_user_playbook_embeddings``), issuing **no** learning DB write.
89
+ ``_persist_write_plan`` applies it inside the fence:
90
+ ``save_user_playbooks(..., skip_embedding=True)`` (which assigns survivor
91
+ ids) then ``_apply_consolidation_lineage`` (which MUST see those ids, so it
92
+ runs AFTER the save).
93
+
94
+ The off-thread schedulers (``_enqueue_user_playbook_optimization`` +
95
+ ``_trigger_playbook_aggregation``) are NOT part of persist — they fire
96
+ post-commit in ``emit_generation_side_effects`` (durable / ``.run()`` path)
97
+ or right after persist in the permanent ``_finalize_extracted_items``
98
+ wrapper (synchronous resume/manual path). ``output_pending_status`` /
99
+ ``skip_aggregation`` are snapshotted here so the scheduler dispatch reads the
100
+ plan rather than the reused service instance.
101
+
102
+ Attributes:
103
+ request_id: Generation request id — the lineage key
104
+ ``_apply_consolidation_lineage`` records on merges/supersedes.
105
+ output_pending_status: Whether the run emits PENDING rows (rerun mode);
106
+ when True the aggregation trigger is suppressed.
107
+ skip_aggregation: Whether aggregation is skipped (extract-only); when
108
+ True the aggregation trigger is suppressed.
109
+ new_playbooks: New playbook rows with ``source``/``status`` set and
110
+ ``.embedding`` precomputed in compute. Survivor ids are assigned by
111
+ ``save_user_playbooks`` in persist, before lineage reads them.
112
+ superseded_ids: ALL archived existing ids (merge sources + leftovers)
113
+ routed through ``_apply_consolidation_lineage``.
114
+ merge_groups: ``(survivor_index_into_new_playbooks, source_existing_ids)``
115
+ per dedup merge group.
116
+ """
117
+
118
+ request_id: str
119
+ output_pending_status: bool
120
+ skip_aggregation: bool
121
+ new_playbooks: list[UserPlaybook]
122
+ superseded_ids: list[int]
123
+ merge_groups: list[tuple[int, list[int]]]
124
+
125
+
126
+ @dataclass
127
+ class ReflectionWritePlan:
128
+ """Resolved reflection write-plan for the compute/persist split (F5, V2).
129
+
130
+ ``ReflectionService.compute`` produces this issuing **no** learning DB write;
131
+ ``persist`` applies the whole cap/validate/CAS apply loop verbatim inside the
132
+ fence, then advances the reflection stride-bookmark; ``emit_side_effects``
133
+ fires the post-commit billing + aggregation triggers. The apply loop stays a
134
+ single monolith (no per-revision ``ResolvedReflectionRevision`` dataclass).
135
+
136
+ Attributes:
137
+ request: The originating reflection request (user_id / request_id /
138
+ agent_version).
139
+ result: The reflection result. Compute seeds it with the gate/citation
140
+ counters resolved so far; persist's apply loop mutates it in place
141
+ (revised/failed/skipped/... counters) as it applies each decision.
142
+ decisions: ``ReflectionOutput.decisions`` to apply — ``[]`` on the four
143
+ early bookmark-advance paths (nothing to apply, only advance).
144
+ profiles_by_id: Cited profile rows resolved in compute, keyed by
145
+ ``profile_id`` (the apply loop's ``_apply_revision`` input).
146
+ playbooks_by_id: Cited playbook rows resolved in compute, keyed by
147
+ ``user_playbook_id``.
148
+ max_revisions_per_pass: Per-pass revision cap applied inside the loop.
149
+ bookmark_interactions: The window the reflection stride-bookmark advances
150
+ over in persist (``[]`` on the empty-window advance path).
151
+ advance_bookmark: ``True`` on all five bookmark-advance paths (the four
152
+ early-advance returns + the normal end). Persist advances the
153
+ reflection bookmark iff this is ``True``.
154
+ record_learnings: ``True`` only on the normal-end path — gates the
155
+ post-commit ``_record_learnings_generated`` billing event in emit.
156
+ replacement_profiles: Replacement ``UserProfile`` rows whose embeddings
157
+ were precomputed in compute (V2), keyed by the **cited** profile_id
158
+ they replace. Persist inserts these with ``skip_embedding=True`` so
159
+ no embedding runs inside the fence. Empty on non-compute (direct)
160
+ ``_replace_*`` calls, which then build+embed the row themselves.
161
+ replacement_playbooks: Same, keyed by the cited ``user_playbook_id``.
162
+ aggregation_successor_ids: Playbook successor ids collected during the
163
+ persist apply loop; their aggregation trigger is dispatched in
164
+ ``emit_side_effects`` (post-commit) so it never fires on a
165
+ rolled-back job.
166
+ """
167
+
168
+ request: ReflectionServiceRequest
169
+ result: ReflectionResult
170
+ decisions: list[ReflectionDecision]
171
+ profiles_by_id: dict[str, UserProfile]
172
+ playbooks_by_id: dict[int, UserPlaybook]
173
+ max_revisions_per_pass: int
174
+ bookmark_interactions: list[Interaction]
175
+ advance_bookmark: bool
176
+ record_learnings: bool
177
+ replacement_profiles: dict[str, UserProfile] = field(default_factory=dict)
178
+ replacement_playbooks: dict[int, UserPlaybook] = field(default_factory=dict)
179
+ aggregation_successor_ids: list[int] = field(default_factory=list)
180
+
181
+
182
+ @dataclass
183
+ class GenerationComputePlan:
184
+ """Resolved compute output of one ``BaseGenerationService`` run (gate b).
185
+
186
+ ``compute_generation`` runs the prepare gate + extractor + dedup/embedding
187
+ resolution (``_resolve_write_plan``) and drives the ``agent_run`` rows to
188
+ their terminal state (``_finalize_extraction_runs`` — agent_run only, §4.3),
189
+ issuing **no** learning DB write. ``persist_generation`` applies
190
+ ``write_plan`` + the extractor bookmark advance inside the fence;
191
+ ``emit_generation_side_effects`` fires the post-commit telemetry + billing.
192
+
193
+ The billing inputs (``extraction_run_ids`` / ``token_totals`` /
194
+ ``generated_count`` / ``prepared``) are **snapshotted at compute time** so
195
+ the fence-crossing emit reads this plan rather than the reused service
196
+ instance's mutable ``_last_*`` accumulators (purity contract, plan §File
197
+ Structure). See ``emit_generation_side_effects`` for the single-use-instance
198
+ invariant that also keeps the money helper's ``self._last_*`` reads safe.
199
+
200
+ Attributes:
201
+ prepared: The prepared generation run (identifier / extractor_name /
202
+ extractor_config), reused by emit for telemetry + billing input.
203
+ generated_count: Learnings produced by this extraction run.
204
+ write_plan: The resolved write-plan (``ProfileWritePlan`` /
205
+ ``PlaybookWritePlan`` in Tasks 6-7, a ``_LegacyItems`` shim marker
206
+ until then) or ``None`` when the extractor produced nothing.
207
+ bookmark_advance: The deferred extractor stride-bookmark advance (F1),
208
+ applied inside the persist scope. ``None`` when the extractor
209
+ produced no output or the service has no stride bookmark.
210
+ generation_start: ``perf_counter`` captured at compute start; emit reads
211
+ it for the ``generation_succeeded`` ``duration_ms`` (parity).
212
+ extraction_run_ids: Snapshot of the run's ``agent_run`` ids.
213
+ token_totals: Snapshot of the run's LLM token totals (billing cost
214
+ facet), or ``None`` when the extractor reported none.
215
+ """
216
+
217
+ prepared: PreparedGenerationRun[Any]
218
+ generated_count: int
219
+ write_plan: Any
220
+ bookmark_advance: ExtractorBookmarkAdvance | None
221
+ generation_start: float
222
+ extraction_run_ids: list[str]
223
+ token_totals: RunTokenTotals | None
224
+
225
+
226
+ @dataclass
227
+ class DeferredLearningPlan:
228
+ """One durable-learning job's resolved compute output (gate b, Task 8).
229
+
230
+ ``GenerationService.compute_deferred_learning`` acquires the same-user guard
231
+ (F4), then runs reflection + profile + playbook compute holding **no**
232
+ ``commit_scope``, and assembles this plan. It carries each present half as a
233
+ ``(held_service_instance, its_compute_plan)`` pair so
234
+ ``persist_deferred_learning`` can apply the fence-critical writes and
235
+ ``emit_deferred_learning_side_effects`` can fire the post-commit telemetry on
236
+ the very instances that produced the plans (the single-use-instance invariant
237
+ that keeps the money helpers' ``self._last_*`` reads valid — see
238
+ ``BaseGenerationService.emit_generation_side_effects``).
239
+
240
+ Attributes:
241
+ request_id: The job's generation request id (lineage key).
242
+ user_id: The user the job learns for; also the per-user F4 lock scope.
243
+ agent_version: Resolved agent version — carried so the post-commit
244
+ ``schedule_tagging`` dispatch in emit has it (the plan is all emit
245
+ receives).
246
+ lock_acquired: ``False`` when the F4 same-user guard denied this job
247
+ (another durable job for the same user is mid-flight). On ``False``
248
+ the three plan halves are ``None`` and NO LLM/compute ran — the
249
+ worker (Task 9) must leave the job reclaimable and must NOT
250
+ ``complete_learning_job`` it.
251
+ reflection: ``(ReflectionService, ReflectionWritePlan)`` when reflection
252
+ resolved something to persist, else ``None`` (gate closed / disabled
253
+ / compute error captured into ``warnings``).
254
+ profile: ``(ProfileGenerationService, GenerationComputePlan)`` when the
255
+ profile extractor produced a plan, else ``None``.
256
+ playbook: ``(PlaybookGenerationService, GenerationComputePlan)`` when the
257
+ playbook extractor produced a plan, else ``None``.
258
+ warnings: Best-effort per-half compute failures (mirrors
259
+ ``GenerationServiceResult.warnings``) — a failed half is dropped from
260
+ the plan, the others still persist.
261
+ """
262
+
263
+ request_id: str
264
+ user_id: str
265
+ agent_version: str
266
+ lock_acquired: bool
267
+ reflection: tuple[ReflectionService, ReflectionWritePlan] | None
268
+ profile: tuple[BaseGenerationService, GenerationComputePlan] | None
269
+ playbook: tuple[BaseGenerationService, GenerationComputePlan] | None
270
+ warnings: list[str] = field(default_factory=list)
@@ -0,0 +1,13 @@
1
+ """Durable learning worker: claim → process in commit_scope → fenced completion."""
2
+
3
+ from reflexio.server.services.durable_learning.scheduler import (
4
+ DurableLearningScheduler,
5
+ maybe_start_durable_learning,
6
+ )
7
+ from reflexio.server.services.durable_learning.worker import DurableLearningWorker
8
+
9
+ __all__ = [
10
+ "DurableLearningScheduler",
11
+ "DurableLearningWorker",
12
+ "maybe_start_durable_learning",
13
+ ]
@@ -0,0 +1,142 @@
1
+ """Process-local scheduler for the durable learning-job queue (Task 6).
2
+
3
+ Each tick discovers the orgs with actionable learning jobs (via an injected
4
+ ``org_ids_provider``) and drains each through a :class:`DurableLearningWorker`.
5
+ The worker's claims are org-scoped and the fenced complete/fail transitions make
6
+ the drain exactly-once, so racing scheduler instances across processes are safe.
7
+
8
+ Gating: startup is gated on ``REFLEXIO_DURABLE_LEARNING_QUEUE`` (a rollout flag,
9
+ scalability design 2026-07-04 §8 stage 2). When off, :meth:`start` is a no-op.
10
+
11
+ Multi-ref capability (design §3.1, per-ref polling): the mechanism supports
12
+ per-data-ref fan-out — inject an ``org_ids_provider`` that yields orgs across
13
+ refs plus a ``request_context_factory`` that routes each org to its ref's
14
+ storage. The default provider (single-ref: bootstrap-storage discovery) is wired
15
+ in :func:`maybe_start_durable_learning`; the enterprise cross-ref enumerator is a
16
+ deferred follow-up (tracked with the pre-Task-8 gates).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from collections.abc import Callable, Iterable
23
+
24
+ from reflexio.server.api_endpoints.request_context import RequestContext
25
+ from reflexio.server.env_utils import env_str, env_truthy
26
+ from reflexio.server.scheduling import ThreadedScheduler
27
+ from reflexio.server.services.durable_learning.worker import DurableLearningWorker
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class DurableLearningScheduler(ThreadedScheduler):
33
+ """Polling daemon that drains the durable learning queue per org.
34
+
35
+ Args:
36
+ request_context_factory: Builds an org-scoped :class:`RequestContext`.
37
+ Passed straight to the worker; a multi-ref deployment routes each org
38
+ to its data ref's storage here.
39
+ org_ids_provider: Called once per tick; yields the org_ids to drain. The
40
+ single-ref default (see :func:`maybe_start_durable_learning`) reads
41
+ them from the bootstrap storage's cross-org discovery query.
42
+ instance_id: Stable label written to ``claimed_by`` on each claim.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ request_context_factory: Callable[[str], RequestContext],
49
+ org_ids_provider: Callable[[], Iterable[str]],
50
+ instance_id: str | None = None,
51
+ ) -> None:
52
+ super().__init__(thread_name="reflexio-durable-learning-scheduler")
53
+ self._worker = DurableLearningWorker(
54
+ request_context_factory, instance_id=instance_id
55
+ )
56
+ self._org_ids_provider = org_ids_provider
57
+ # Read env INSIDE __init__ so tests can set the values before construction.
58
+ self._poll = float(env_str("REFLEXIO_DURABLE_LEARNING_POLL_SECONDS", "2.0"))
59
+ self._batch = int(env_str("REFLEXIO_DURABLE_LEARNING_BATCH", "16"))
60
+ self._lease = int(env_str("REFLEXIO_DURABLE_LEARNING_LEASE_SECONDS", "300"))
61
+
62
+ def _should_start(self) -> bool:
63
+ if env_truthy(env_str("REFLEXIO_DURABLE_LEARNING_QUEUE", "false")):
64
+ return True
65
+ logger.info(
66
+ "event=durable_learning_scheduler_disabled "
67
+ "REFLEXIO_DURABLE_LEARNING_QUEUE not set — not starting"
68
+ )
69
+ return False
70
+
71
+ def _on_started(self) -> None:
72
+ logger.info("event=durable_learning_scheduler_started")
73
+
74
+ def _on_stopped(self) -> None:
75
+ logger.info("event=durable_learning_scheduler_stopped")
76
+
77
+ def _run_once(self) -> float:
78
+ """Drain every discovered org once; per-org error isolation.
79
+
80
+ A single org raising never aborts the tick, and a failure to enumerate
81
+ orgs never kills the daemon thread. Returns the poll interval.
82
+ """
83
+ try:
84
+ for org_id in self._org_ids_provider():
85
+ if self._stop_event.is_set():
86
+ break
87
+ try:
88
+ self._worker.drain_org(org_id, self._batch, self._lease)
89
+ except Exception:
90
+ logger.exception(
91
+ "event=durable_learning_drain_failed org_id=%s", org_id
92
+ )
93
+ except Exception:
94
+ logger.exception("event=durable_learning_tick_failed")
95
+ return self._poll
96
+
97
+
98
+ def maybe_start_durable_learning(
99
+ request_context_factory: Callable[[str], RequestContext],
100
+ *,
101
+ bootstrap_org_id: str,
102
+ org_ids_provider: Callable[[], Iterable[str]] | None = None,
103
+ ) -> DurableLearningScheduler | None:
104
+ """Start the durable-learning scheduler when the rollout flag is on.
105
+
106
+ Returns ``None`` when ``REFLEXIO_DURABLE_LEARNING_QUEUE`` is off (mirrors
107
+ ``maybe_start_resume_scheduler``). The default ``org_ids_provider`` performs
108
+ single-ref discovery: it queries the bootstrap org's storage for every org
109
+ with actionable work (covers OSS/local and the shared managed DATA ref). The
110
+ enterprise cross-ref enumerator is a deferred follow-up.
111
+
112
+ Args:
113
+ request_context_factory: Builds an org-scoped :class:`RequestContext`.
114
+ bootstrap_org_id: Org used to reach a storage for cross-org discovery.
115
+ org_ids_provider: Optional explicit provider (e.g. a future enterprise
116
+ cross-ref enumerator); when ``None`` the single-ref default is used.
117
+ """
118
+ if not env_truthy(env_str("REFLEXIO_DURABLE_LEARNING_QUEUE", "false")):
119
+ return None
120
+
121
+ def _default_provider() -> list[str]:
122
+ try:
123
+ ctx = request_context_factory(bootstrap_org_id)
124
+ storage = getattr(ctx, "storage", None)
125
+ if storage is None:
126
+ return []
127
+ return list(storage.list_org_ids_with_pending_learning_jobs())
128
+ except NotImplementedError:
129
+ return []
130
+ except Exception:
131
+ logger.exception(
132
+ "event=durable_learning_discovery_failed bootstrap_org_id=%s",
133
+ bootstrap_org_id,
134
+ )
135
+ return []
136
+
137
+ scheduler = DurableLearningScheduler(
138
+ request_context_factory=request_context_factory,
139
+ org_ids_provider=org_ids_provider or _default_provider,
140
+ )
141
+ scheduler.start()
142
+ return scheduler
@@ -0,0 +1,262 @@
1
+ """DurableLearningWorker: claim jobs from the durable queue, split each into
2
+ compute (no writer transaction) -> persist (short fenced commit_scope) ->
3
+ post-commit side-effects so exactly one of N racing workers commits its outputs.
4
+
5
+ Design constraints (gate b — do not remove):
6
+ - LLM extraction + dedup + embeddings run in compute_deferred_learning, OUTSIDE
7
+ the commit_scope. Only the fence-critical writes (profile/playbook rows +
8
+ bookmark advances + complete_learning_job) run inside the scope; billing /
9
+ telemetry / tagging / off-thread schedulers + the per-user lock release run
10
+ AFTER the scope commits (emit_deferred_learning_side_effects). Holding the
11
+ writer transaction across the ~30-120s LLM window is exactly what this split
12
+ removes.
13
+ - No heartbeat thread (deferred; would deadlock a SQLite scope anyway).
14
+ - Exactly-once is guaranteed by the fenced complete_learning_job:
15
+ rowcount == 0 -> lease was stolen -> _SupersededError -> rollback.
16
+ rowcount == 1 -> we own the lease -> commit succeeds.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import uuid
23
+ from collections.abc import Callable
24
+
25
+ from reflexio.server.api_endpoints.request_context import RequestContext
26
+ from reflexio.server.cache.reflexio_cache import get_reflexio
27
+ from reflexio.server.services.generation_service import GenerationService
28
+ from reflexio.server.services.storage.storage_base._learning_jobs import LearningJob
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class _SupersededError(Exception):
34
+ """Raised inside commit_scope when complete_learning_job returns 0.
35
+
36
+ Propagating this exception causes commit_scope to roll back all writes
37
+ (profiles, playbooks, bookmark advances) made by persist_deferred_learning
38
+ for the superseded worker — guaranteeing exactly-once side effects. The
39
+ post-commit emit is skipped, so a superseded job phantom-bills nothing.
40
+ """
41
+
42
+ def __init__(self, job_id: str) -> None:
43
+ super().__init__(f"learning job {job_id} superseded (lease stolen)")
44
+
45
+
46
+ class DurableLearningWorker:
47
+ """Claim durable learning jobs and process each in a fenced commit_scope.
48
+
49
+ Two instances racing the same job produce profile/playbook side effects
50
+ identical to a single run: the loser's complete_learning_job returns 0,
51
+ which raises _SupersededError inside the scope, rolling back its writes.
52
+
53
+ Args:
54
+ request_context_factory: Callable that returns a RequestContext for a
55
+ given org_id. Called once per drain_org invocation.
56
+ instance_id: Stable label written to claimed_by on each claim.
57
+ Defaults to a random short UUID suffix.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ request_context_factory: Callable[[str], RequestContext],
63
+ *,
64
+ instance_id: str | None = None,
65
+ ) -> None:
66
+ self._factory = request_context_factory
67
+ self._instance_id = instance_id or str(uuid.uuid4())[:8]
68
+
69
+ def drain_org(self, org_id: str, batch_size: int, lease_seconds: int) -> int:
70
+ """Claim up to batch_size of this org's jobs and process each.
71
+
72
+ Args:
73
+ org_id: Organisation to drain.
74
+ batch_size: Maximum number of jobs to claim in one call.
75
+ lease_seconds: Lease duration passed to claim_learning_jobs.
76
+
77
+ Returns:
78
+ Number of jobs successfully completed (not counting superseded or failed).
79
+ """
80
+ ctx = self._factory(org_id)
81
+ storage = ctx.storage
82
+ if storage is None:
83
+ logger.error("event=learning_worker_no_storage org_id=%s", org_id)
84
+ return 0
85
+ jobs = storage.claim_learning_jobs(
86
+ claimed_by=self._instance_id,
87
+ limit=batch_size,
88
+ lease_seconds=lease_seconds,
89
+ )
90
+ processed = 0
91
+ for job in jobs:
92
+ if self._process_job(ctx, job):
93
+ processed += 1
94
+ return processed
95
+
96
+ def _process_job(self, ctx: RequestContext, job: LearningJob) -> bool:
97
+ """Process a single claimed job.
98
+
99
+ Args:
100
+ ctx: RequestContext for the job's org.
101
+ job: The claimed LearningJob (claim_token set).
102
+
103
+ Returns:
104
+ True if the job was successfully completed; False if superseded or failed.
105
+ """
106
+ storage = ctx.storage
107
+ if storage is None:
108
+ logger.error(
109
+ "event=learning_job_no_storage job_id=%s org_id=%s",
110
+ job.job_id,
111
+ job.org_id,
112
+ )
113
+ return False
114
+
115
+ # claim_learning_jobs always sets claim_token on a returned job.
116
+ claim_token = job.claim_token
117
+ if claim_token is None:
118
+ logger.error(
119
+ "event=learning_job_no_claim_token job_id=%s org_id=%s",
120
+ job.job_id,
121
+ job.org_id,
122
+ )
123
+ return False
124
+
125
+ dead = job.attempts >= job.max_attempts
126
+
127
+ # The job does not carry agent_version / session_id / source.
128
+ # These must be read from the durably-persisted Request.
129
+ if job.latest_request_id is None:
130
+ logger.error(
131
+ "event=learning_job_no_request_id job_id=%s org_id=%s user_id=%s",
132
+ job.job_id,
133
+ job.org_id,
134
+ job.user_id,
135
+ )
136
+ storage.fail_learning_job(
137
+ job_id=job.job_id, claim_token=claim_token, dead=dead
138
+ )
139
+ return False
140
+
141
+ request = storage.get_request(job.latest_request_id)
142
+ if request is None:
143
+ logger.error(
144
+ "event=learning_job_missing_request job_id=%s request_id=%s",
145
+ job.job_id,
146
+ job.latest_request_id,
147
+ )
148
+ storage.fail_learning_job(
149
+ job_id=job.job_id, claim_token=claim_token, dead=dead
150
+ )
151
+ return False
152
+
153
+ gen: GenerationService | None = None
154
+ try:
155
+ reflexio = get_reflexio(
156
+ org_id=ctx.org_id, storage_base_dir=ctx.storage_base_dir
157
+ )
158
+ gen = GenerationService(llm_client=reflexio.llm_client, request_context=ctx)
159
+
160
+ # COMPUTE — LLM extraction + dedup + embeddings, NO writer transaction
161
+ # held. Acquires the per-user F4 lock; issues no learning DB write.
162
+ plan = gen.compute_deferred_learning(
163
+ user_id=job.user_id,
164
+ request_id=job.latest_request_id,
165
+ session_id=request.session_id,
166
+ source=request.source,
167
+ agent_version=request.agent_version,
168
+ force_extraction=job.force_extraction,
169
+ skip_aggregation=job.skip_aggregation,
170
+ )
171
+
172
+ # Same-user contention (F4): another same-user durable job holds the
173
+ # per-user lock. Leave THIS job reclaimable (dead=False) — do NOT
174
+ # complete it — so the queue re-claims it once the holder finishes.
175
+ # REFUND the attempt (refund_attempt=True): claim_learning_jobs did
176
+ # attempts += 1 on this claim, but no real work ran, and the ~2s poll
177
+ # would otherwise re-claim (and re-increment) every couple of seconds
178
+ # while the holder is in its ~60s compute — inflating attempts past
179
+ # max_attempts in seconds so the eventual winner dead-letters on its
180
+ # first transient error with zero real retries. The refund nets each
181
+ # contention cycle (claim +1, release -1) to zero. No lock to release
182
+ # (compute never acquired it).
183
+ if not plan.lock_acquired:
184
+ storage.fail_learning_job(
185
+ job_id=job.job_id,
186
+ claim_token=claim_token,
187
+ dead=False,
188
+ refund_attempt=True,
189
+ )
190
+ return False
191
+
192
+ # PERSIST — short fenced scope: fence-critical writes + bookmark
193
+ # advances only, then the claim-token fence. rows == 0 -> lease
194
+ # stolen -> _SupersededError -> the scope rolls persist back.
195
+ with storage.commit_scope():
196
+ gen.persist_deferred_learning(plan)
197
+ rows = storage.complete_learning_job(
198
+ job_id=job.job_id, claim_token=claim_token
199
+ )
200
+ if rows == 0:
201
+ raise _SupersededError(job.job_id)
202
+
203
+ # POST-COMMIT — billing / telemetry / tagging / off-thread schedulers
204
+ # + the per-user lock release, only for the winning worker.
205
+ gen.emit_deferred_learning_side_effects(plan)
206
+ logger.info(
207
+ "event=learning_job_done job_id=%s org_id=%s user_id=%s",
208
+ job.job_id,
209
+ job.org_id,
210
+ job.user_id,
211
+ )
212
+ return True
213
+ except _SupersededError:
214
+ logger.info(
215
+ "event=learning_job_superseded job_id=%s org_id=%s",
216
+ job.job_id,
217
+ job.org_id,
218
+ )
219
+ # The persist rolled back and emit never ran, so the per-user lock is
220
+ # still held by this compute — release it so the reclaim isn't blocked.
221
+ self._release_user_lock(gen, job)
222
+ return False
223
+ except Exception:
224
+ logger.exception(
225
+ "event=learning_job_failed job_id=%s org_id=%s user_id=%s",
226
+ job.job_id,
227
+ job.org_id,
228
+ job.user_id,
229
+ )
230
+ # emit (which releases the lock) never ran on this path — release the
231
+ # per-user lock so a failed job doesn't strand it.
232
+ self._release_user_lock(gen, job)
233
+ # attempts was incremented by claim_learning_jobs; go dead when we've
234
+ # exhausted max_attempts total claim attempts.
235
+ storage.fail_learning_job(
236
+ job_id=job.job_id, claim_token=claim_token, dead=dead
237
+ )
238
+ return False
239
+
240
+ def _release_user_lock(
241
+ self, gen: GenerationService | None, job: LearningJob
242
+ ) -> None:
243
+ """Best-effort release of the per-user F4 lock after a rolled-back/failed
244
+ job, so a job whose emit (the normal release site) never ran does not
245
+ strand the lock and block the same user's re-claim.
246
+
247
+ Safe to call even when compute never acquired the lock: the release is a
248
+ CAS on the holder (``clear_in_progress_lock_if_owner``) — a no-op unless
249
+ this compute's ``request_id`` still owns it. Never raises.
250
+ """
251
+ if gen is None or job.latest_request_id is None:
252
+ return
253
+ try:
254
+ gen._release_durable_learning_lock(
255
+ user_id=job.user_id, request_id=job.latest_request_id
256
+ )
257
+ except Exception:
258
+ logger.exception(
259
+ "event=learning_job_lock_release_failed job_id=%s org_id=%s",
260
+ job.job_id,
261
+ job.org_id,
262
+ )