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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.md +11 -40
- package/bin/claude-smart.js +393 -55
- package/package.json +3 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.coverage +0 -0
- package/plugin/README.md +4 -3
- package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
- package/plugin/dashboard/app/dashboard/page.tsx +6 -1
- package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
- package/plugin/dashboard/app/preferences/page.tsx +32 -35
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
- package/plugin/dashboard/app/sessions/page.tsx +2 -0
- package/plugin/dashboard/app/skills/page.tsx +65 -50
- package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
- package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
- package/plugin/dashboard/components/common/host-badge.tsx +118 -0
- package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
- package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
- package/plugin/dashboard/components/common/page-header.tsx +3 -3
- package/plugin/dashboard/lib/config-file.ts +5 -1
- package/plugin/dashboard/lib/host-attribution.ts +62 -0
- package/plugin/dashboard/lib/session-reader.ts +40 -2
- package/plugin/dashboard/lib/types.ts +7 -1
- package/plugin/opencode/dist/server.mjs +60 -2
- package/plugin/opencode/server.mts +64 -2
- package/plugin/pyproject.toml +2 -2
- package/plugin/scripts/_lib.sh +255 -7
- package/plugin/scripts/backend-python-runner.py +46 -0
- package/plugin/scripts/backend-service.sh +766 -123
- package/plugin/scripts/codex-hook.js +79 -229
- package/plugin/scripts/dashboard-open.sh +6 -4
- package/plugin/scripts/dashboard-service.sh +118 -137
- package/plugin/scripts/ensure-plugin-root.sh +106 -8
- package/plugin/scripts/hook_entry.sh +3 -0
- package/plugin/scripts/opencode-claude-compat.js +8 -1
- package/plugin/scripts/smart-install.sh +116 -21
- package/plugin/src/README.md +1 -1
- package/plugin/src/claude_smart/cli.py +93 -29
- package/plugin/src/claude_smart/context_inject.py +3 -0
- package/plugin/src/claude_smart/env_config.py +56 -11
- package/plugin/src/claude_smart/events/post_tool.py +2 -1
- package/plugin/src/claude_smart/events/session_end.py +2 -1
- package/plugin/src/claude_smart/events/stop.py +3 -0
- package/plugin/src/claude_smart/events/user_prompt.py +2 -1
- package/plugin/src/claude_smart/internal_call.py +5 -2
- package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
- package/plugin/src/claude_smart/publish.py +59 -7
- package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
- package/plugin/src/claude_smart/runtime.py +15 -6
- package/plugin/src/claude_smart/state.py +211 -52
- package/plugin/uv.lock +5 -5
- package/plugin/vendor/reflexio/.env.example +13 -0
- package/plugin/vendor/reflexio/reflexio/README.md +7 -3
- package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
- package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
- package/plugin/vendor/reflexio/reflexio/client/client.py +166 -9
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
- package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
- package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +195 -25
- package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
- package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
- package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
- package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
- package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
- package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/api.py +79 -5
- package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
- package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +63 -6
- package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
- package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +199 -2
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
- package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
- package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
- package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +63 -2
- package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
- package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
- package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
- package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
- package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
- package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +262 -0
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
- package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +1 -3
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +640 -80
- package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
- package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
- package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
- package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
- package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
- package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
- package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +166 -66
- package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
- package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
- package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
- package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +2 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +190 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +414 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -5
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +86 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +59 -7
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +57 -12
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +197 -12
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +70 -11
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +17 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +269 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +82 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +74 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
- package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
- package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
- package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
- package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
- package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
- 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
|
+
)
|