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
|
@@ -7,12 +7,12 @@ import os
|
|
|
7
7
|
import threading
|
|
8
8
|
import time
|
|
9
9
|
import uuid
|
|
10
|
-
from collections.abc import Callable
|
|
10
|
+
from collections.abc import Callable, Mapping
|
|
11
11
|
from concurrent.futures import ThreadPoolExecutor
|
|
12
12
|
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
|
13
13
|
from contextlib import nullcontext
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
|
-
from typing import TYPE_CHECKING
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
16
|
|
|
17
17
|
from reflexio.defaults import resolve_agent_version
|
|
18
18
|
from reflexio.models.api_schema.service_schemas import (
|
|
@@ -22,6 +22,7 @@ from reflexio.models.api_schema.service_schemas import (
|
|
|
22
22
|
)
|
|
23
23
|
from reflexio.models.config_schema import Config
|
|
24
24
|
from reflexio.server.api_endpoints.request_context import RequestContext
|
|
25
|
+
from reflexio.server.env_utils import env_str, env_truthy
|
|
25
26
|
from reflexio.server.llm.litellm_client import LiteLLMClient
|
|
26
27
|
from reflexio.server.operation_limiter import operation_limit
|
|
27
28
|
from reflexio.server.services.agent_success_evaluation.runner import (
|
|
@@ -30,6 +31,7 @@ from reflexio.server.services.agent_success_evaluation.runner import (
|
|
|
30
31
|
from reflexio.server.services.agent_success_evaluation.scheduler import (
|
|
31
32
|
GroupEvaluationScheduler,
|
|
32
33
|
)
|
|
34
|
+
from reflexio.server.services.deferred_learning_plan import DeferredLearningPlan
|
|
33
35
|
from reflexio.server.services.operation_state_utils import OperationStateManager
|
|
34
36
|
from reflexio.server.services.playbook.playbook_service_utils import (
|
|
35
37
|
PlaybookGenerationRequest,
|
|
@@ -47,6 +49,10 @@ from reflexio.server.services.reflection.reflection_service_utils import (
|
|
|
47
49
|
ReflectionServiceRequest,
|
|
48
50
|
)
|
|
49
51
|
from reflexio.server.services.reflection.service import ReflectionService
|
|
52
|
+
from reflexio.server.services.shadow_comparison.worker import (
|
|
53
|
+
ShadowComparisonJob,
|
|
54
|
+
enqueue_shadow_comparison,
|
|
55
|
+
)
|
|
50
56
|
from reflexio.server.services.storage.retention import (
|
|
51
57
|
delete_count_for_retention,
|
|
52
58
|
get_row_retention_limits,
|
|
@@ -56,6 +62,7 @@ from reflexio.server.tracing import sentry_tags
|
|
|
56
62
|
from reflexio.server.usage_metrics import record_usage_event
|
|
57
63
|
|
|
58
64
|
if TYPE_CHECKING:
|
|
65
|
+
from reflexio.server.services.deferred_learning_plan import ReflectionWritePlan
|
|
59
66
|
from reflexio.server.services.unified_search_service import UnifiedSearchService
|
|
60
67
|
|
|
61
68
|
logger = logging.getLogger(__name__)
|
|
@@ -65,6 +72,24 @@ CLEANUP_STALE_LOCK_SECONDS = 600
|
|
|
65
72
|
GENERATION_SERVICE_TIMEOUT_SECONDS = 600
|
|
66
73
|
_STALL_WARNING_PREFIX = "Reflexio learning is paused"
|
|
67
74
|
|
|
75
|
+
# ── Durable-learning same-user guard (F4) ──
|
|
76
|
+
# Service prefix for the per-user in-progress lock that serializes concurrent
|
|
77
|
+
# durable-learning jobs for one user (compute → persist across the fence).
|
|
78
|
+
_DURABLE_LEARNING_LOCK_SERVICE = "durable_learning"
|
|
79
|
+
# Stale timeout for the per-user durable lock. Deliberately set ABOVE the 300s
|
|
80
|
+
# durable-job claim lease (V4): a job's claim can be re-leased after 300s, so the
|
|
81
|
+
# lock must outlive one claim window and NOT expire in lockstep with the lease
|
|
82
|
+
# under compute-time throttling — 900s = 3x the lease (>= the required 2x).
|
|
83
|
+
_DURABLE_LOCK_STALE_SECONDS = 900
|
|
84
|
+
# Operation-state payload written when releasing the per-user lock (mirrors the
|
|
85
|
+
# clean-slate state OperationStateManager.clear_lock_if_owner writes).
|
|
86
|
+
_DURABLE_LOCK_CLEARED_STATE = {
|
|
87
|
+
"in_progress": False,
|
|
88
|
+
"current_request_id": None,
|
|
89
|
+
"pending_request_id": None,
|
|
90
|
+
"pending_request_queue": [],
|
|
91
|
+
}
|
|
92
|
+
|
|
68
93
|
|
|
69
94
|
def _retention_cleanup_interval_seconds() -> float:
|
|
70
95
|
raw = os.getenv("REFLEXIO_RETENTION_CLEANUP_INTERVAL_SECONDS", "300") or "300"
|
|
@@ -92,6 +117,22 @@ def _stable_group_sampling_fraction(
|
|
|
92
117
|
return int.from_bytes(digest[:8], "big") / 2**64
|
|
93
118
|
|
|
94
119
|
|
|
120
|
+
def _org_in_durable_allowlist(org_id: str | None) -> bool:
|
|
121
|
+
"""Whether ``org_id`` may use the durable learning queue.
|
|
122
|
+
|
|
123
|
+
Reads ``REFLEXIO_DURABLE_LEARNING_QUEUE_ORG_ALLOWLIST`` (comma-separated org
|
|
124
|
+
IDs). An empty/whitespace-only value means the allowlist is unset — the
|
|
125
|
+
default global behavior, so every org is eligible. The allowlist can only
|
|
126
|
+
*narrow* the durable path; it never enables it on its own (the
|
|
127
|
+
``REFLEXIO_DURABLE_LEARNING_QUEUE`` flag still gates activation).
|
|
128
|
+
"""
|
|
129
|
+
raw = env_str("REFLEXIO_DURABLE_LEARNING_QUEUE_ORG_ALLOWLIST", "")
|
|
130
|
+
allowed = {s.strip() for s in raw.split(",") if s.strip()}
|
|
131
|
+
if not allowed:
|
|
132
|
+
return True
|
|
133
|
+
return str(org_id) in allowed
|
|
134
|
+
|
|
135
|
+
|
|
95
136
|
@dataclass
|
|
96
137
|
class GenerationServiceResult:
|
|
97
138
|
"""Result of a GenerationService.run call.
|
|
@@ -249,35 +290,51 @@ class GenerationService:
|
|
|
249
290
|
session_id=publish_user_interaction_request.session_id,
|
|
250
291
|
evaluation_only=publish_user_interaction_request.evaluation_only,
|
|
251
292
|
)
|
|
252
|
-
self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
|
|
253
293
|
|
|
254
|
-
#
|
|
255
|
-
|
|
256
|
-
|
|
294
|
+
# When the durable queue is enabled and this is a deferred publish, the
|
|
295
|
+
# request + interactions + job row are written together inside a single
|
|
296
|
+
# commit_scope (below). Every other path persists here unconditionally.
|
|
297
|
+
use_durable_queue = env_truthy(
|
|
298
|
+
env_str("REFLEXIO_DURABLE_LEARNING_QUEUE", "false")
|
|
299
|
+
)
|
|
300
|
+
_durable_defer = (
|
|
301
|
+
defer_learning
|
|
302
|
+
and use_durable_queue
|
|
303
|
+
and _org_in_durable_allowlist(self.org_id)
|
|
257
304
|
)
|
|
258
305
|
|
|
306
|
+
if not _durable_defer:
|
|
307
|
+
self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
|
|
308
|
+
# Add interactions to storage (bulk insert with batched embedding generation)
|
|
309
|
+
self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
|
|
310
|
+
user_id=user_id, interactions=new_interactions
|
|
311
|
+
)
|
|
312
|
+
|
|
259
313
|
# Extract source (empty string treated as None)
|
|
260
314
|
source = publish_user_interaction_request.source or None
|
|
261
315
|
|
|
262
316
|
if publish_user_interaction_request.evaluation_only:
|
|
263
|
-
|
|
317
|
+
if _durable_defer:
|
|
318
|
+
# evaluation_only returns early; ensure data lands even on durable path.
|
|
319
|
+
self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
|
|
320
|
+
self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
|
|
321
|
+
user_id=user_id, interactions=new_interactions
|
|
322
|
+
)
|
|
323
|
+
self._schedule_post_publish_evaluations(
|
|
264
324
|
new_request=new_request,
|
|
325
|
+
interactions=new_interactions,
|
|
265
326
|
user_id=user_id,
|
|
266
327
|
agent_version=agent_version,
|
|
267
328
|
source=source,
|
|
268
329
|
)
|
|
269
|
-
|
|
270
|
-
|
|
330
|
+
self._emit_publish_success_events(
|
|
331
|
+
interactions=new_interactions,
|
|
271
332
|
user_id=user_id,
|
|
272
333
|
request_id=request_id,
|
|
273
334
|
session_id=new_request.session_id,
|
|
274
335
|
source=source,
|
|
275
336
|
agent_version=agent_version,
|
|
276
337
|
backend="evaluation_only",
|
|
277
|
-
event_name="publish_request_succeeded",
|
|
278
|
-
event_category="publish",
|
|
279
|
-
outcome="success",
|
|
280
|
-
count_value=len(new_interactions),
|
|
281
338
|
duration_ms=int((time.perf_counter() - publish_start) * 1000),
|
|
282
339
|
metadata={
|
|
283
340
|
"evaluation_only": True,
|
|
@@ -290,32 +347,87 @@ class GenerationService:
|
|
|
290
347
|
not publish_user_interaction_request.override_learning_stall
|
|
291
348
|
and (stall_warning := self._active_learning_stall_warning()) is not None
|
|
292
349
|
):
|
|
350
|
+
if _durable_defer:
|
|
351
|
+
# Stall skips learning but data must still land.
|
|
352
|
+
self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
|
|
353
|
+
self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
|
|
354
|
+
user_id=user_id, interactions=new_interactions
|
|
355
|
+
)
|
|
293
356
|
result.warnings.append(stall_warning)
|
|
294
357
|
logger.warning("%s; skipping automatic extraction", stall_warning)
|
|
295
|
-
|
|
296
|
-
|
|
358
|
+
self._schedule_post_publish_evaluations(
|
|
359
|
+
new_request=new_request,
|
|
360
|
+
interactions=new_interactions,
|
|
361
|
+
user_id=user_id,
|
|
362
|
+
agent_version=agent_version,
|
|
363
|
+
source=source,
|
|
364
|
+
)
|
|
365
|
+
self._emit_publish_success_events(
|
|
366
|
+
interactions=new_interactions,
|
|
297
367
|
user_id=user_id,
|
|
298
368
|
request_id=request_id,
|
|
299
369
|
session_id=new_request.session_id,
|
|
300
370
|
source=source,
|
|
301
371
|
agent_version=agent_version,
|
|
302
|
-
event_name="publish_request_succeeded",
|
|
303
|
-
event_category="publish",
|
|
304
|
-
outcome="success",
|
|
305
|
-
count_value=len(new_interactions),
|
|
306
372
|
duration_ms=int((time.perf_counter() - publish_start) * 1000),
|
|
307
373
|
metadata={"warning_count": len(result.warnings)},
|
|
308
374
|
)
|
|
309
375
|
return result
|
|
310
376
|
|
|
311
377
|
if defer_learning:
|
|
378
|
+
if _durable_defer:
|
|
379
|
+
# Durable atomic path: pre-generate embeddings OUTSIDE the
|
|
380
|
+
# transaction (network call), then persist request + interactions
|
|
381
|
+
# + job in one commit_scope.
|
|
382
|
+
self.storage.prepare_interaction_embeddings(new_interactions) # type: ignore[reportOptionalMemberAccess]
|
|
383
|
+
covers_through = max(i.created_at for i in new_interactions)
|
|
384
|
+
with self.storage.commit_scope(): # type: ignore[reportOptionalMemberAccess]
|
|
385
|
+
self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
|
|
386
|
+
self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
|
|
387
|
+
user_id=user_id,
|
|
388
|
+
interactions=new_interactions,
|
|
389
|
+
embeddings_prepared=True,
|
|
390
|
+
)
|
|
391
|
+
self.storage.enqueue_learning_job( # type: ignore[reportOptionalMemberAccess]
|
|
392
|
+
org_id=self.org_id,
|
|
393
|
+
user_id=user_id,
|
|
394
|
+
request_id=request_id,
|
|
395
|
+
covers_through=covers_through,
|
|
396
|
+
force_extraction=publish_user_interaction_request.force_extraction,
|
|
397
|
+
skip_aggregation=publish_user_interaction_request.skip_aggregation,
|
|
398
|
+
)
|
|
399
|
+
self._schedule_post_publish_evaluations(
|
|
400
|
+
new_request=new_request,
|
|
401
|
+
interactions=new_interactions,
|
|
402
|
+
user_id=user_id,
|
|
403
|
+
agent_version=agent_version,
|
|
404
|
+
source=source,
|
|
405
|
+
)
|
|
406
|
+
self._emit_publish_success_events(
|
|
407
|
+
interactions=new_interactions,
|
|
408
|
+
user_id=user_id,
|
|
409
|
+
request_id=request_id,
|
|
410
|
+
session_id=new_request.session_id,
|
|
411
|
+
source=source,
|
|
412
|
+
agent_version=agent_version,
|
|
413
|
+
backend="durable",
|
|
414
|
+
duration_ms=int((time.perf_counter() - publish_start) * 1000),
|
|
415
|
+
metadata={
|
|
416
|
+
"defer_learning": True,
|
|
417
|
+
"durable_queue": True,
|
|
418
|
+
"warning_count": len(result.warnings),
|
|
419
|
+
},
|
|
420
|
+
)
|
|
421
|
+
return result
|
|
422
|
+
|
|
312
423
|
from reflexio.server.services.publish_learning_worker import (
|
|
313
424
|
PublishLearningJob,
|
|
314
425
|
enqueue_publish_learning,
|
|
315
426
|
)
|
|
316
427
|
|
|
317
|
-
self.
|
|
428
|
+
self._schedule_post_publish_evaluations(
|
|
318
429
|
new_request=new_request,
|
|
430
|
+
interactions=new_interactions,
|
|
319
431
|
user_id=user_id,
|
|
320
432
|
agent_version=agent_version,
|
|
321
433
|
source=source,
|
|
@@ -332,18 +444,14 @@ class GenerationService:
|
|
|
332
444
|
skip_aggregation=publish_user_interaction_request.skip_aggregation,
|
|
333
445
|
)
|
|
334
446
|
)
|
|
335
|
-
|
|
336
|
-
|
|
447
|
+
self._emit_publish_success_events(
|
|
448
|
+
interactions=new_interactions,
|
|
337
449
|
user_id=user_id,
|
|
338
450
|
request_id=request_id,
|
|
339
451
|
session_id=new_request.session_id,
|
|
340
452
|
source=source,
|
|
341
453
|
agent_version=agent_version,
|
|
342
454
|
backend="classic",
|
|
343
|
-
event_name="publish_request_succeeded",
|
|
344
|
-
event_category="publish",
|
|
345
|
-
outcome="success",
|
|
346
|
-
count_value=len(new_interactions),
|
|
347
455
|
duration_ms=int((time.perf_counter() - publish_start) * 1000),
|
|
348
456
|
metadata={
|
|
349
457
|
"defer_learning": True,
|
|
@@ -377,30 +485,26 @@ class GenerationService:
|
|
|
377
485
|
result=result,
|
|
378
486
|
)
|
|
379
487
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
488
|
+
self._schedule_post_publish_evaluations(
|
|
489
|
+
new_request=new_request,
|
|
490
|
+
interactions=new_interactions,
|
|
491
|
+
user_id=user_id,
|
|
492
|
+
agent_version=agent_version,
|
|
493
|
+
source=source,
|
|
494
|
+
)
|
|
387
495
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
duration_ms=int((time.perf_counter() - publish_start) * 1000),
|
|
401
|
-
metadata={"warning_count": len(result.warnings)},
|
|
402
|
-
)
|
|
403
|
-
return result
|
|
496
|
+
self._emit_publish_success_events(
|
|
497
|
+
interactions=new_interactions,
|
|
498
|
+
user_id=user_id,
|
|
499
|
+
request_id=request_id,
|
|
500
|
+
session_id=new_request.session_id,
|
|
501
|
+
source=source,
|
|
502
|
+
agent_version=agent_version,
|
|
503
|
+
backend="classic",
|
|
504
|
+
duration_ms=int((time.perf_counter() - publish_start) * 1000),
|
|
505
|
+
metadata={"warning_count": len(result.warnings)},
|
|
506
|
+
)
|
|
507
|
+
return result
|
|
404
508
|
|
|
405
509
|
except Exception as e:
|
|
406
510
|
record_usage_event(
|
|
@@ -430,6 +534,47 @@ class GenerationService:
|
|
|
430
534
|
)
|
|
431
535
|
raise
|
|
432
536
|
|
|
537
|
+
def _emit_publish_success_events(
|
|
538
|
+
self,
|
|
539
|
+
*,
|
|
540
|
+
interactions: list[Interaction],
|
|
541
|
+
user_id: str,
|
|
542
|
+
request_id: str,
|
|
543
|
+
session_id: str,
|
|
544
|
+
source: str | None,
|
|
545
|
+
agent_version: str,
|
|
546
|
+
duration_ms: int,
|
|
547
|
+
metadata: Mapping[str, Any],
|
|
548
|
+
backend: str | None = None,
|
|
549
|
+
) -> None:
|
|
550
|
+
"""Emit one ``publish_request_succeeded`` event per interaction.
|
|
551
|
+
|
|
552
|
+
Replaces the old single aggregate event (``count_value=len(interactions)``)
|
|
553
|
+
with one entity-backed event per interaction (``count_value=1``,
|
|
554
|
+
``event_key=f"pub:{interaction_id}"``, ``entity_id=interaction_id``) so
|
|
555
|
+
downstream dedup can key on the interaction id. The summed
|
|
556
|
+
``count_value`` across the emitted events equals the old aggregate
|
|
557
|
+
count, so totals are unchanged.
|
|
558
|
+
"""
|
|
559
|
+
for interaction in interactions:
|
|
560
|
+
record_usage_event(
|
|
561
|
+
org_id=self.org_id,
|
|
562
|
+
user_id=user_id,
|
|
563
|
+
request_id=request_id,
|
|
564
|
+
session_id=session_id,
|
|
565
|
+
source=source,
|
|
566
|
+
agent_version=agent_version,
|
|
567
|
+
backend=backend,
|
|
568
|
+
event_name="publish_request_succeeded",
|
|
569
|
+
event_category="publish",
|
|
570
|
+
outcome="success",
|
|
571
|
+
count_value=1,
|
|
572
|
+
event_key=f"pub:{interaction.interaction_id}",
|
|
573
|
+
entity_id=str(interaction.interaction_id),
|
|
574
|
+
duration_ms=duration_ms,
|
|
575
|
+
metadata=metadata,
|
|
576
|
+
)
|
|
577
|
+
|
|
433
578
|
# ===============================
|
|
434
579
|
# deferred learning
|
|
435
580
|
# ===============================
|
|
@@ -445,6 +590,13 @@ class GenerationService:
|
|
|
445
590
|
force_extraction: bool,
|
|
446
591
|
skip_aggregation: bool,
|
|
447
592
|
) -> GenerationServiceResult:
|
|
593
|
+
"""Run the learning steps for a deferred job (synchronous / non-durable
|
|
594
|
+
callers: ``PublishLearningWorker`` and manual reruns).
|
|
595
|
+
|
|
596
|
+
Runs compute + persist + side-effects together, with profile and playbook
|
|
597
|
+
generation in parallel — no external ``commit_scope`` is held on this
|
|
598
|
+
path (the durable worker uses the compute/persist/emit split instead).
|
|
599
|
+
"""
|
|
448
600
|
result = GenerationServiceResult(request_id=request_id)
|
|
449
601
|
self._run_learning_steps(
|
|
450
602
|
user_id=user_id,
|
|
@@ -454,49 +606,97 @@ class GenerationService:
|
|
|
454
606
|
skip_aggregation=skip_aggregation,
|
|
455
607
|
source=source,
|
|
456
608
|
result=result,
|
|
609
|
+
parallel=True,
|
|
457
610
|
)
|
|
458
611
|
return result
|
|
459
612
|
|
|
460
613
|
# ===============================
|
|
461
|
-
#
|
|
614
|
+
# deferred learning — compute / persist / emit split (gate b)
|
|
462
615
|
# ===============================
|
|
463
616
|
|
|
464
|
-
def
|
|
617
|
+
def compute_deferred_learning(
|
|
465
618
|
self,
|
|
466
619
|
*,
|
|
467
620
|
user_id: str,
|
|
468
621
|
request_id: str,
|
|
622
|
+
session_id: str | None, # noqa: ARG002 - kept for worker call symmetry
|
|
623
|
+
source: str | None,
|
|
469
624
|
agent_version: str,
|
|
470
625
|
force_extraction: bool,
|
|
471
626
|
skip_aggregation: bool,
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
627
|
+
) -> DeferredLearningPlan:
|
|
628
|
+
"""Compute half of one durable-learning job — NO ``commit_scope`` held.
|
|
629
|
+
|
|
630
|
+
Runs the same-user guard (F4) first, then reflection + profile + playbook
|
|
631
|
+
compute (LLM extraction, dedup, embeddings) entirely OUTSIDE any writer
|
|
632
|
+
transaction, and assembles a :class:`DeferredLearningPlan` of the held
|
|
633
|
+
``(service, plan)`` pairs. Issues NO learning DB write (F3): the only
|
|
634
|
+
writes are the extractor's own ``agent_run`` rows + the per-user
|
|
635
|
+
coordination lock (``try_acquire_in_progress_lock``, not a learning
|
|
636
|
+
terminal).
|
|
637
|
+
|
|
638
|
+
F4 GUARD FIRST: acquires a DB-backed per-user in-progress lock BEFORE any
|
|
639
|
+
LLM work. On contention returns ``DeferredLearningPlan(lock_acquired=
|
|
640
|
+
False, reflection/profile/playbook=None)`` immediately (no compute) — the
|
|
641
|
+
worker must then leave the job reclaimable (Task 9), NOT complete it.
|
|
642
|
+
|
|
643
|
+
Reflection runs first on the calling thread (it reads the pre-persist
|
|
644
|
+
snapshot); profile + playbook ``compute_generation`` then run in parallel
|
|
645
|
+
(no ``commit_scope`` is held, so the old ``sequential=True`` RLock
|
|
646
|
+
avoidance is unnecessary). Per-half failures are best-effort — captured
|
|
647
|
+
into ``warnings`` and the half dropped — mirroring
|
|
648
|
+
``_run_learning_steps``.
|
|
649
|
+
"""
|
|
650
|
+
warnings: list[str] = []
|
|
651
|
+
|
|
652
|
+
# F4 GUARD — before any LLM. On contention, no compute runs.
|
|
653
|
+
if not self._acquire_durable_learning_lock(
|
|
654
|
+
user_id=user_id, request_id=request_id
|
|
655
|
+
):
|
|
656
|
+
logger.info(
|
|
657
|
+
"durable-learning same-user lock held; leaving job reclaimable "
|
|
658
|
+
"(user_id=%s request_id=%s)",
|
|
659
|
+
user_id,
|
|
660
|
+
request_id,
|
|
661
|
+
)
|
|
662
|
+
return DeferredLearningPlan(
|
|
663
|
+
request_id=request_id,
|
|
664
|
+
user_id=user_id,
|
|
665
|
+
agent_version=agent_version,
|
|
666
|
+
lock_acquired=False,
|
|
667
|
+
reflection=None,
|
|
668
|
+
profile=None,
|
|
669
|
+
playbook=None,
|
|
670
|
+
warnings=warnings,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
# Reflection first (calling thread) — one pre-persist read snapshot.
|
|
674
|
+
reflection_pair = self._compute_reflection(
|
|
478
675
|
user_id=user_id,
|
|
479
676
|
request_id=request_id,
|
|
480
677
|
agent_version=agent_version,
|
|
481
678
|
source=source,
|
|
679
|
+
warnings=warnings,
|
|
482
680
|
)
|
|
483
681
|
|
|
484
|
-
|
|
682
|
+
# Profile + playbook compute in parallel — no scope held, so no RLock
|
|
683
|
+
# contention (the pre-split sequential mode only existed to avoid the
|
|
684
|
+
# SQLite commit_scope RLock, which compute never takes).
|
|
685
|
+
profile_service = ProfileGenerationService(
|
|
485
686
|
llm_client=self.client, request_context=self.request_context
|
|
486
687
|
)
|
|
487
|
-
|
|
688
|
+
profile_request = ProfileGenerationRequest(
|
|
488
689
|
user_id=user_id,
|
|
489
690
|
request_id=request_id,
|
|
490
691
|
source=source,
|
|
491
692
|
force_extraction=force_extraction,
|
|
492
693
|
)
|
|
493
|
-
|
|
494
|
-
playbook_generation_service = PlaybookGenerationService(
|
|
694
|
+
playbook_service = PlaybookGenerationService(
|
|
495
695
|
llm_client=self.client,
|
|
496
696
|
request_context=self.request_context,
|
|
497
697
|
skip_aggregation=skip_aggregation,
|
|
498
698
|
)
|
|
499
|
-
|
|
699
|
+
playbook_request = PlaybookGenerationRequest(
|
|
500
700
|
request_id=request_id,
|
|
501
701
|
agent_version=agent_version,
|
|
502
702
|
user_id=user_id,
|
|
@@ -504,25 +704,26 @@ class GenerationService:
|
|
|
504
704
|
force_extraction=force_extraction,
|
|
505
705
|
)
|
|
506
706
|
|
|
707
|
+
profile_pair: tuple = None # type: ignore[assignment]
|
|
708
|
+
playbook_pair: tuple = None # type: ignore[assignment]
|
|
507
709
|
executor = ThreadPoolExecutor(max_workers=2)
|
|
508
710
|
try:
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
for future, service_name in zip(futures, service_names, strict=True):
|
|
711
|
+
profile_future = executor.submit(
|
|
712
|
+
contextvars.copy_context().run,
|
|
713
|
+
profile_service.compute_generation,
|
|
714
|
+
profile_request,
|
|
715
|
+
)
|
|
716
|
+
playbook_future = executor.submit(
|
|
717
|
+
contextvars.copy_context().run,
|
|
718
|
+
playbook_service.compute_generation,
|
|
719
|
+
playbook_request,
|
|
720
|
+
)
|
|
721
|
+
for future, service_name, service in (
|
|
722
|
+
(profile_future, "profile_generation", profile_service),
|
|
723
|
+
(playbook_future, "playbook_generation", playbook_service),
|
|
724
|
+
):
|
|
524
725
|
try:
|
|
525
|
-
future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
|
|
726
|
+
plan = future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
|
|
526
727
|
except FuturesTimeoutError: # noqa: PERF203
|
|
527
728
|
msg = (
|
|
528
729
|
f"{service_name} timed out after "
|
|
@@ -535,7 +736,8 @@ class GenerationService:
|
|
|
535
736
|
error_type="timeout",
|
|
536
737
|
):
|
|
537
738
|
logger.error("%s for request %s", msg, request_id)
|
|
538
|
-
|
|
739
|
+
warnings.append(msg)
|
|
740
|
+
continue
|
|
539
741
|
except Exception as e:
|
|
540
742
|
msg = f"{service_name} failed: {e}"
|
|
541
743
|
with sentry_tags(
|
|
@@ -548,10 +750,334 @@ class GenerationService:
|
|
|
548
750
|
"Generation service failed for request %s",
|
|
549
751
|
request_id,
|
|
550
752
|
)
|
|
551
|
-
|
|
753
|
+
warnings.append(msg)
|
|
754
|
+
continue
|
|
755
|
+
if plan is None:
|
|
756
|
+
continue
|
|
757
|
+
if service_name == "profile_generation":
|
|
758
|
+
profile_pair = (service, plan)
|
|
759
|
+
else:
|
|
760
|
+
playbook_pair = (service, plan)
|
|
552
761
|
finally:
|
|
553
762
|
executor.shutdown(wait=False, cancel_futures=True)
|
|
554
763
|
|
|
764
|
+
return DeferredLearningPlan(
|
|
765
|
+
request_id=request_id,
|
|
766
|
+
user_id=user_id,
|
|
767
|
+
agent_version=agent_version,
|
|
768
|
+
lock_acquired=True,
|
|
769
|
+
reflection=reflection_pair,
|
|
770
|
+
profile=profile_pair,
|
|
771
|
+
playbook=playbook_pair,
|
|
772
|
+
warnings=warnings,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
def persist_deferred_learning(self, plan: DeferredLearningPlan) -> None:
|
|
776
|
+
"""Persist half — apply the fence-critical writes for a computed job.
|
|
777
|
+
|
|
778
|
+
This is the ONLY part the durable worker runs inside its fenced
|
|
779
|
+
``commit_scope``. For each present half it calls the held instance's
|
|
780
|
+
persist (reflection apply loop + bookmark; profile/playbook row writes +
|
|
781
|
+
extractor bookmark advance). Issues NO side-effects (telemetry, billing,
|
|
782
|
+
tagging, off-thread schedulers, lock release) — those are post-commit
|
|
783
|
+
(``emit_deferred_learning_side_effects``) so a fence-lost job never fires
|
|
784
|
+
them.
|
|
785
|
+
"""
|
|
786
|
+
if plan.reflection is not None:
|
|
787
|
+
reflection_service, reflection_plan = plan.reflection
|
|
788
|
+
reflection_service.persist(reflection_plan)
|
|
789
|
+
if plan.profile is not None:
|
|
790
|
+
profile_service, profile_plan = plan.profile
|
|
791
|
+
profile_service.persist_generation(profile_plan)
|
|
792
|
+
if plan.playbook is not None:
|
|
793
|
+
playbook_service, playbook_plan = plan.playbook
|
|
794
|
+
playbook_service.persist_generation(playbook_plan)
|
|
795
|
+
|
|
796
|
+
def emit_deferred_learning_side_effects(self, plan: DeferredLearningPlan) -> None:
|
|
797
|
+
"""Post-commit side-effects — telemetry, billing, tagging, lock release.
|
|
798
|
+
|
|
799
|
+
Runs only for a fence-winning job (the durable worker calls it after the
|
|
800
|
+
scope commits). Fires each held half's post-commit emit + schedules the
|
|
801
|
+
deferred tagging pass, then ALWAYS releases the per-user F4 lock (even if
|
|
802
|
+
an emit raised) so a completed job never strands the lock.
|
|
803
|
+
|
|
804
|
+
A ``lock_acquired=False`` plan never reaches here (the worker skips emit
|
|
805
|
+
on contention), so releasing here is safe — this instance owns the lock.
|
|
806
|
+
|
|
807
|
+
Each emit half is isolated in its own try/except (mirroring how
|
|
808
|
+
``schedule_tagging`` already self-guards): the halves are independent
|
|
809
|
+
post-commit side effects (billing / telemetry / off-thread schedulers),
|
|
810
|
+
so a failure in an early half must NOT starve the later halves. The
|
|
811
|
+
per-user lock release always runs in ``finally`` so a completed job never
|
|
812
|
+
strands the lock.
|
|
813
|
+
"""
|
|
814
|
+
try:
|
|
815
|
+
if plan.reflection is not None:
|
|
816
|
+
reflection_service, reflection_plan = plan.reflection
|
|
817
|
+
try:
|
|
818
|
+
reflection_service.emit_side_effects(reflection_plan)
|
|
819
|
+
except Exception:
|
|
820
|
+
logger.exception(
|
|
821
|
+
"Failed to emit reflection side effects for deferred "
|
|
822
|
+
"learning request %s",
|
|
823
|
+
plan.request_id,
|
|
824
|
+
)
|
|
825
|
+
if plan.profile is not None:
|
|
826
|
+
profile_service, profile_plan = plan.profile
|
|
827
|
+
try:
|
|
828
|
+
profile_service.emit_generation_side_effects(profile_plan)
|
|
829
|
+
except Exception:
|
|
830
|
+
logger.exception(
|
|
831
|
+
"Failed to emit profile side effects for deferred "
|
|
832
|
+
"learning request %s",
|
|
833
|
+
plan.request_id,
|
|
834
|
+
)
|
|
835
|
+
if plan.playbook is not None:
|
|
836
|
+
playbook_service, playbook_plan = plan.playbook
|
|
837
|
+
try:
|
|
838
|
+
playbook_service.emit_generation_side_effects(playbook_plan)
|
|
839
|
+
except Exception:
|
|
840
|
+
logger.exception(
|
|
841
|
+
"Failed to emit playbook side effects for deferred "
|
|
842
|
+
"learning request %s",
|
|
843
|
+
plan.request_id,
|
|
844
|
+
)
|
|
845
|
+
try:
|
|
846
|
+
schedule_tagging(
|
|
847
|
+
org_id=self.org_id,
|
|
848
|
+
user_id=plan.user_id,
|
|
849
|
+
agent_version=plan.agent_version,
|
|
850
|
+
request_context=self.request_context,
|
|
851
|
+
llm_client=self.client,
|
|
852
|
+
)
|
|
853
|
+
except Exception:
|
|
854
|
+
logger.exception(
|
|
855
|
+
"Failed to schedule tagging for deferred learning request %s",
|
|
856
|
+
plan.request_id,
|
|
857
|
+
)
|
|
858
|
+
finally:
|
|
859
|
+
self._release_durable_learning_lock(
|
|
860
|
+
user_id=plan.user_id, request_id=plan.request_id
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
# ===============================
|
|
864
|
+
# private methods
|
|
865
|
+
# ===============================
|
|
866
|
+
|
|
867
|
+
def _durable_learning_lock_key(self, user_id: str) -> str:
|
|
868
|
+
"""Per-user durable-learning in-progress lock key.
|
|
869
|
+
|
|
870
|
+
Mirrors ``OperationStateManager._lock_key`` shape
|
|
871
|
+
(``{service}::{org}::{scope}::lock``) but on a DEDICATED
|
|
872
|
+
``durable_learning`` service prefix, distinct from the
|
|
873
|
+
profile/playbook generation locks and from any extractor bookmark row.
|
|
874
|
+
"""
|
|
875
|
+
return f"{_DURABLE_LEARNING_LOCK_SERVICE}::{self.org_id}::{user_id}::lock"
|
|
876
|
+
|
|
877
|
+
def _acquire_durable_learning_lock(self, *, user_id: str, request_id: str) -> bool:
|
|
878
|
+
"""Try to acquire the per-user durable-learning lock (F4).
|
|
879
|
+
|
|
880
|
+
Uses the atomic DB-backed ``try_acquire_in_progress_lock`` (a distinct
|
|
881
|
+
storage method, NOT one of the learning-write terminals the compute
|
|
882
|
+
purity contract forbids), so the guard does not trip the compute
|
|
883
|
+
write-tripwire. Returns ``True`` when acquired (proceed with compute),
|
|
884
|
+
``False`` on contention (another same-user durable job holds it).
|
|
885
|
+
"""
|
|
886
|
+
if self.storage is None:
|
|
887
|
+
return True
|
|
888
|
+
result = self.storage.try_acquire_in_progress_lock(
|
|
889
|
+
self._durable_learning_lock_key(user_id),
|
|
890
|
+
request_id,
|
|
891
|
+
stale_lock_seconds=_DURABLE_LOCK_STALE_SECONDS,
|
|
892
|
+
)
|
|
893
|
+
return bool(result.get("acquired", False))
|
|
894
|
+
|
|
895
|
+
def _release_durable_learning_lock(self, *, user_id: str, request_id: str) -> None:
|
|
896
|
+
"""Release the per-user durable-learning lock iff we still own it.
|
|
897
|
+
|
|
898
|
+
Uses ``clear_in_progress_lock_if_owner`` (CAS on the holder) so a job
|
|
899
|
+
whose lock was already stolen after a stale-timeout does not clobber the
|
|
900
|
+
new holder's lock.
|
|
901
|
+
"""
|
|
902
|
+
if self.storage is None:
|
|
903
|
+
return
|
|
904
|
+
self.storage.clear_in_progress_lock_if_owner(
|
|
905
|
+
self._durable_learning_lock_key(user_id),
|
|
906
|
+
request_id,
|
|
907
|
+
dict(_DURABLE_LOCK_CLEARED_STATE),
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
def _compute_reflection(
|
|
911
|
+
self,
|
|
912
|
+
*,
|
|
913
|
+
user_id: str,
|
|
914
|
+
request_id: str,
|
|
915
|
+
agent_version: str,
|
|
916
|
+
source: str | None,
|
|
917
|
+
warnings: list[str],
|
|
918
|
+
) -> tuple[ReflectionService, ReflectionWritePlan] | None:
|
|
919
|
+
"""Run reflection ``compute`` best-effort; return its held pair or None.
|
|
920
|
+
|
|
921
|
+
Mirrors ``_maybe_run_reflection`` (a reflection failure must never break
|
|
922
|
+
the publish) but returns the ``(service, plan)`` pair so the caller can
|
|
923
|
+
defer ``persist`` / ``emit_side_effects`` across the worker fence.
|
|
924
|
+
Returns ``None`` when reflection has nothing to persist (gate closed /
|
|
925
|
+
disabled / storage None) or a compute error (logged + appended to
|
|
926
|
+
``warnings``).
|
|
927
|
+
"""
|
|
928
|
+
try:
|
|
929
|
+
service = ReflectionService(
|
|
930
|
+
request_context=self.request_context,
|
|
931
|
+
llm_client=self.client,
|
|
932
|
+
)
|
|
933
|
+
reflection_plan = service.compute(
|
|
934
|
+
ReflectionServiceRequest(
|
|
935
|
+
user_id=user_id,
|
|
936
|
+
request_id=request_id,
|
|
937
|
+
agent_version=agent_version,
|
|
938
|
+
source=source,
|
|
939
|
+
)
|
|
940
|
+
)
|
|
941
|
+
if reflection_plan is None:
|
|
942
|
+
return None
|
|
943
|
+
return (service, reflection_plan)
|
|
944
|
+
except Exception as exc: # noqa: BLE001 — reflection must not break publish
|
|
945
|
+
with sentry_tags(
|
|
946
|
+
subsystem="generation",
|
|
947
|
+
op="reflection",
|
|
948
|
+
org_id=self.org_id,
|
|
949
|
+
user_id=user_id,
|
|
950
|
+
error_type=type(exc).__name__,
|
|
951
|
+
):
|
|
952
|
+
logger.exception("reflection compute failed for user %s", user_id)
|
|
953
|
+
warnings.append(f"reflection failed: {exc}")
|
|
954
|
+
return None
|
|
955
|
+
|
|
956
|
+
def _run_learning_steps(
|
|
957
|
+
self,
|
|
958
|
+
*,
|
|
959
|
+
user_id: str,
|
|
960
|
+
request_id: str,
|
|
961
|
+
agent_version: str,
|
|
962
|
+
force_extraction: bool,
|
|
963
|
+
skip_aggregation: bool,
|
|
964
|
+
source: str | None,
|
|
965
|
+
result: GenerationServiceResult,
|
|
966
|
+
parallel: bool = True,
|
|
967
|
+
) -> None:
|
|
968
|
+
# Reflection runs as its own sliding-window step BEFORE the extractor
|
|
969
|
+
# pool spins up, so replacements are visible to extractors.
|
|
970
|
+
self._maybe_run_reflection(
|
|
971
|
+
user_id=user_id,
|
|
972
|
+
request_id=request_id,
|
|
973
|
+
agent_version=agent_version,
|
|
974
|
+
source=source,
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
profile_generation_service = ProfileGenerationService(
|
|
978
|
+
llm_client=self.client, request_context=self.request_context
|
|
979
|
+
)
|
|
980
|
+
source_request_id = request_id
|
|
981
|
+
profile_generation_request = ProfileGenerationRequest(
|
|
982
|
+
user_id=user_id,
|
|
983
|
+
request_id=source_request_id,
|
|
984
|
+
source=source,
|
|
985
|
+
force_extraction=force_extraction,
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
playbook_generation_service = PlaybookGenerationService(
|
|
989
|
+
llm_client=self.client,
|
|
990
|
+
request_context=self.request_context,
|
|
991
|
+
skip_aggregation=skip_aggregation,
|
|
992
|
+
)
|
|
993
|
+
playbook_generation_request = PlaybookGenerationRequest(
|
|
994
|
+
request_id=source_request_id,
|
|
995
|
+
agent_version=agent_version,
|
|
996
|
+
user_id=user_id,
|
|
997
|
+
source=source,
|
|
998
|
+
force_extraction=force_extraction,
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
if not parallel:
|
|
1002
|
+
# Sequential mode: run both services on the calling thread.
|
|
1003
|
+
# Required when the caller holds a commit_scope lock — spawning
|
|
1004
|
+
# child threads inside that scope would deadlock the SQLite RLock.
|
|
1005
|
+
for svc_name, _run in (
|
|
1006
|
+
(
|
|
1007
|
+
"profile_generation",
|
|
1008
|
+
lambda: profile_generation_service.run(profile_generation_request),
|
|
1009
|
+
),
|
|
1010
|
+
(
|
|
1011
|
+
"playbook_generation",
|
|
1012
|
+
lambda: playbook_generation_service.run(
|
|
1013
|
+
playbook_generation_request
|
|
1014
|
+
),
|
|
1015
|
+
),
|
|
1016
|
+
):
|
|
1017
|
+
try:
|
|
1018
|
+
_run()
|
|
1019
|
+
except Exception as e:
|
|
1020
|
+
msg = f"{svc_name} failed: {e}"
|
|
1021
|
+
with sentry_tags(
|
|
1022
|
+
subsystem="generation",
|
|
1023
|
+
service=svc_name,
|
|
1024
|
+
request_id=request_id,
|
|
1025
|
+
error_type=type(e).__name__,
|
|
1026
|
+
):
|
|
1027
|
+
logger.exception(
|
|
1028
|
+
"Generation service failed for request %s",
|
|
1029
|
+
request_id,
|
|
1030
|
+
)
|
|
1031
|
+
result.warnings.append(msg)
|
|
1032
|
+
else:
|
|
1033
|
+
executor = ThreadPoolExecutor(max_workers=2)
|
|
1034
|
+
try:
|
|
1035
|
+
futures = [
|
|
1036
|
+
executor.submit(
|
|
1037
|
+
contextvars.copy_context().run,
|
|
1038
|
+
profile_generation_service.run,
|
|
1039
|
+
profile_generation_request,
|
|
1040
|
+
),
|
|
1041
|
+
executor.submit(
|
|
1042
|
+
contextvars.copy_context().run,
|
|
1043
|
+
playbook_generation_service.run,
|
|
1044
|
+
playbook_generation_request,
|
|
1045
|
+
),
|
|
1046
|
+
]
|
|
1047
|
+
|
|
1048
|
+
service_names = ["profile_generation", "playbook_generation"]
|
|
1049
|
+
for future, service_name in zip(futures, service_names, strict=True):
|
|
1050
|
+
try:
|
|
1051
|
+
future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
|
|
1052
|
+
except FuturesTimeoutError: # noqa: PERF203
|
|
1053
|
+
msg = (
|
|
1054
|
+
f"{service_name} timed out after "
|
|
1055
|
+
f"{GENERATION_SERVICE_TIMEOUT_SECONDS}s"
|
|
1056
|
+
)
|
|
1057
|
+
with sentry_tags(
|
|
1058
|
+
subsystem="generation",
|
|
1059
|
+
service=service_name,
|
|
1060
|
+
request_id=request_id,
|
|
1061
|
+
error_type="timeout",
|
|
1062
|
+
):
|
|
1063
|
+
logger.error("%s for request %s", msg, request_id)
|
|
1064
|
+
result.warnings.append(msg)
|
|
1065
|
+
except Exception as e:
|
|
1066
|
+
msg = f"{service_name} failed: {e}"
|
|
1067
|
+
with sentry_tags(
|
|
1068
|
+
subsystem="generation",
|
|
1069
|
+
service=service_name,
|
|
1070
|
+
request_id=request_id,
|
|
1071
|
+
error_type=type(e).__name__,
|
|
1072
|
+
):
|
|
1073
|
+
logger.exception(
|
|
1074
|
+
"Generation service failed for request %s",
|
|
1075
|
+
request_id,
|
|
1076
|
+
)
|
|
1077
|
+
result.warnings.append(msg)
|
|
1078
|
+
finally:
|
|
1079
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
1080
|
+
|
|
555
1081
|
try:
|
|
556
1082
|
schedule_tagging(
|
|
557
1083
|
org_id=self.org_id,
|
|
@@ -566,6 +1092,39 @@ class GenerationService:
|
|
|
566
1092
|
request_id,
|
|
567
1093
|
)
|
|
568
1094
|
|
|
1095
|
+
def _schedule_post_publish_evaluations(
|
|
1096
|
+
self,
|
|
1097
|
+
*,
|
|
1098
|
+
new_request: Request,
|
|
1099
|
+
interactions: list[Interaction],
|
|
1100
|
+
user_id: str,
|
|
1101
|
+
agent_version: str,
|
|
1102
|
+
source: str | None,
|
|
1103
|
+
) -> None:
|
|
1104
|
+
"""Schedule all best-effort evaluation work after publish persistence."""
|
|
1105
|
+
if any(interaction.shadow_content for interaction in interactions):
|
|
1106
|
+
try:
|
|
1107
|
+
enqueue_shadow_comparison(
|
|
1108
|
+
ShadowComparisonJob(
|
|
1109
|
+
org_id=self.org_id,
|
|
1110
|
+
interactions=interactions,
|
|
1111
|
+
session_id=new_request.session_id,
|
|
1112
|
+
agent_version=agent_version,
|
|
1113
|
+
)
|
|
1114
|
+
)
|
|
1115
|
+
except Exception:
|
|
1116
|
+
logger.exception(
|
|
1117
|
+
"Failed to enqueue shadow comparison for session %s",
|
|
1118
|
+
new_request.session_id,
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
self._schedule_group_evaluation_if_needed(
|
|
1122
|
+
new_request=new_request,
|
|
1123
|
+
user_id=user_id,
|
|
1124
|
+
agent_version=agent_version,
|
|
1125
|
+
source=source,
|
|
1126
|
+
)
|
|
1127
|
+
|
|
569
1128
|
def _schedule_group_evaluation_if_needed(
|
|
570
1129
|
self,
|
|
571
1130
|
*,
|
|
@@ -845,6 +1404,7 @@ class GenerationService:
|
|
|
845
1404
|
expert_content=interaction_data.expert_content,
|
|
846
1405
|
tools_used=interaction_data.tools_used,
|
|
847
1406
|
citations=interaction_data.citations,
|
|
1407
|
+
retrieved_learnings=interaction_data.retrieved_learnings,
|
|
848
1408
|
)
|
|
849
1409
|
for interaction_data in interaction_data_list
|
|
850
1410
|
]
|