claude-smart 0.2.47 → 0.2.49
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 +1 -1
- package/README.md +6 -2
- package/bin/claude-smart.js +289 -56
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
- package/plugin/dashboard/package-lock.json +61 -390
- package/plugin/dashboard/package.json +2 -2
- 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 +58 -6
- package/plugin/scripts/backend-service.sh +15 -10
- package/plugin/scripts/codex-hook.js +16 -4
- package/plugin/scripts/dashboard-service.sh +1 -1
- package/plugin/scripts/ensure-plugin-root.sh +106 -8
- package/plugin/scripts/opencode-claude-compat.js +8 -1
- package/plugin/scripts/smart-install.sh +101 -20
- package/plugin/src/README.md +1 -1
- package/plugin/src/claude_smart/cli.py +79 -29
- package/plugin/src/claude_smart/env_config.py +53 -11
- package/plugin/uv.lock +5 -5
- package/plugin/vendor/reflexio/.env.example +7 -0
- package/plugin/vendor/reflexio/pyproject.toml +2 -1
- package/plugin/vendor/reflexio/reflexio/README.md +8 -5
- package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
- package/plugin/vendor/reflexio/reflexio/client/client.py +40 -6
- package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
- package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
- package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +18 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
- package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
- package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
- package/plugin/vendor/reflexio/reflexio/server/api.py +148 -3277
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
- package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
- package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +72 -1817
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +183 -1
- package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
- package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
- package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +320 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
- package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
- package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
- 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 +193 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +24 -41
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +335 -113
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
- package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +44 -22
- package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +31 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +196 -353
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +379 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +17 -6
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +216 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +22 -10
- 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 +25 -12
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +308 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +920 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -8
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
- 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/_learning_jobs.py +219 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +95 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +60 -80
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
|
@@ -48,6 +48,10 @@ def parse_item_id(item_id: str) -> tuple[str, int] | None:
|
|
|
48
48
|
"""
|
|
49
49
|
Parse a prompt-format item ID like 'NEW-0' or 'EXISTING-1' into its prefix and index.
|
|
50
50
|
|
|
51
|
+
Weak models sometimes echo the rendered display label with its brackets
|
|
52
|
+
(``[NEW-0]``); strip a single surrounding pair so those outputs parse
|
|
53
|
+
instead of being silently dropped.
|
|
54
|
+
|
|
51
55
|
Args:
|
|
52
56
|
item_id (str): Item ID string in the format 'PREFIX-N' (e.g., 'NEW-0', 'EXISTING-1')
|
|
53
57
|
|
|
@@ -55,7 +59,10 @@ def parse_item_id(item_id: str) -> tuple[str, int] | None:
|
|
|
55
59
|
Optional[tuple[str, int]]: A tuple of (prefix, index) where prefix is 'NEW' or 'EXISTING',
|
|
56
60
|
or None if the item ID is invalid
|
|
57
61
|
"""
|
|
58
|
-
|
|
62
|
+
stripped = item_id.strip()
|
|
63
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
64
|
+
stripped = stripped[1:-1].strip()
|
|
65
|
+
parts = stripped.rsplit("-", 1)
|
|
59
66
|
if len(parts) != 2:
|
|
60
67
|
logger.warning("Invalid item ID format: %s", item_id)
|
|
61
68
|
return None
|
|
@@ -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,193 @@
|
|
|
1
|
+
"""DurableLearningWorker: claim jobs from the durable queue, process in a fenced
|
|
2
|
+
commit_scope so exactly one of N racing workers commits its outputs.
|
|
3
|
+
|
|
4
|
+
Design constraints (v1 — do not remove):
|
|
5
|
+
- LLM calls are inside the commit_scope (compute/persist separation is deferred).
|
|
6
|
+
- No heartbeat thread (deferred; would deadlock a SQLite scope anyway).
|
|
7
|
+
- Exactly-once is guaranteed by the fenced complete_learning_job:
|
|
8
|
+
rowcount == 0 -> lease was stolen -> _SupersededError -> rollback.
|
|
9
|
+
rowcount == 1 -> we own the lease -> commit succeeds.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import uuid
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
from reflexio.server.api_endpoints.request_context import RequestContext
|
|
19
|
+
from reflexio.server.cache.reflexio_cache import get_reflexio
|
|
20
|
+
from reflexio.server.services.generation_service import GenerationService
|
|
21
|
+
from reflexio.server.services.storage.storage_base._learning_jobs import LearningJob
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _SupersededError(Exception):
|
|
27
|
+
"""Raised inside commit_scope when complete_learning_job returns 0.
|
|
28
|
+
|
|
29
|
+
Propagating this exception causes commit_scope to roll back all writes
|
|
30
|
+
(profiles, playbooks) made by run_deferred_learning for the superseded
|
|
31
|
+
worker — guaranteeing exactly-once side effects.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, job_id: str) -> None:
|
|
35
|
+
super().__init__(f"learning job {job_id} superseded (lease stolen)")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DurableLearningWorker:
|
|
39
|
+
"""Claim durable learning jobs and process each in a fenced commit_scope.
|
|
40
|
+
|
|
41
|
+
Two instances racing the same job produce profile/playbook side effects
|
|
42
|
+
identical to a single run: the loser's complete_learning_job returns 0,
|
|
43
|
+
which raises _SupersededError inside the scope, rolling back its writes.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
request_context_factory: Callable that returns a RequestContext for a
|
|
47
|
+
given org_id. Called once per drain_org invocation.
|
|
48
|
+
instance_id: Stable label written to claimed_by on each claim.
|
|
49
|
+
Defaults to a random short UUID suffix.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
request_context_factory: Callable[[str], RequestContext],
|
|
55
|
+
*,
|
|
56
|
+
instance_id: str | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
self._factory = request_context_factory
|
|
59
|
+
self._instance_id = instance_id or str(uuid.uuid4())[:8]
|
|
60
|
+
|
|
61
|
+
def drain_org(self, org_id: str, batch_size: int, lease_seconds: int) -> int:
|
|
62
|
+
"""Claim up to batch_size of this org's jobs and process each.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
org_id: Organisation to drain.
|
|
66
|
+
batch_size: Maximum number of jobs to claim in one call.
|
|
67
|
+
lease_seconds: Lease duration passed to claim_learning_jobs.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Number of jobs successfully completed (not counting superseded or failed).
|
|
71
|
+
"""
|
|
72
|
+
ctx = self._factory(org_id)
|
|
73
|
+
storage = ctx.storage
|
|
74
|
+
if storage is None:
|
|
75
|
+
logger.error("event=learning_worker_no_storage org_id=%s", org_id)
|
|
76
|
+
return 0
|
|
77
|
+
jobs = storage.claim_learning_jobs(
|
|
78
|
+
claimed_by=self._instance_id,
|
|
79
|
+
limit=batch_size,
|
|
80
|
+
lease_seconds=lease_seconds,
|
|
81
|
+
)
|
|
82
|
+
processed = 0
|
|
83
|
+
for job in jobs:
|
|
84
|
+
if self._process_job(ctx, job):
|
|
85
|
+
processed += 1
|
|
86
|
+
return processed
|
|
87
|
+
|
|
88
|
+
def _process_job(self, ctx: RequestContext, job: LearningJob) -> bool:
|
|
89
|
+
"""Process a single claimed job.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
ctx: RequestContext for the job's org.
|
|
93
|
+
job: The claimed LearningJob (claim_token set).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
True if the job was successfully completed; False if superseded or failed.
|
|
97
|
+
"""
|
|
98
|
+
storage = ctx.storage
|
|
99
|
+
if storage is None:
|
|
100
|
+
logger.error(
|
|
101
|
+
"event=learning_job_no_storage job_id=%s org_id=%s",
|
|
102
|
+
job.job_id,
|
|
103
|
+
job.org_id,
|
|
104
|
+
)
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# claim_learning_jobs always sets claim_token on a returned job.
|
|
108
|
+
claim_token = job.claim_token
|
|
109
|
+
if claim_token is None:
|
|
110
|
+
logger.error(
|
|
111
|
+
"event=learning_job_no_claim_token job_id=%s org_id=%s",
|
|
112
|
+
job.job_id,
|
|
113
|
+
job.org_id,
|
|
114
|
+
)
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
dead = job.attempts >= job.max_attempts
|
|
118
|
+
|
|
119
|
+
# The job does not carry agent_version / session_id / source.
|
|
120
|
+
# These must be read from the durably-persisted Request.
|
|
121
|
+
if job.latest_request_id is None:
|
|
122
|
+
logger.error(
|
|
123
|
+
"event=learning_job_no_request_id job_id=%s org_id=%s user_id=%s",
|
|
124
|
+
job.job_id,
|
|
125
|
+
job.org_id,
|
|
126
|
+
job.user_id,
|
|
127
|
+
)
|
|
128
|
+
storage.fail_learning_job(
|
|
129
|
+
job_id=job.job_id, claim_token=claim_token, dead=dead
|
|
130
|
+
)
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
request = storage.get_request(job.latest_request_id)
|
|
134
|
+
if request is None:
|
|
135
|
+
logger.error(
|
|
136
|
+
"event=learning_job_missing_request job_id=%s request_id=%s",
|
|
137
|
+
job.job_id,
|
|
138
|
+
job.latest_request_id,
|
|
139
|
+
)
|
|
140
|
+
storage.fail_learning_job(
|
|
141
|
+
job_id=job.job_id, claim_token=claim_token, dead=dead
|
|
142
|
+
)
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
reflexio = get_reflexio(
|
|
147
|
+
org_id=ctx.org_id, storage_base_dir=ctx.storage_base_dir
|
|
148
|
+
)
|
|
149
|
+
gen = GenerationService(llm_client=reflexio.llm_client, request_context=ctx)
|
|
150
|
+
|
|
151
|
+
with storage.commit_scope():
|
|
152
|
+
gen.run_deferred_learning(
|
|
153
|
+
user_id=job.user_id,
|
|
154
|
+
request_id=job.latest_request_id,
|
|
155
|
+
session_id=request.session_id,
|
|
156
|
+
source=request.source,
|
|
157
|
+
agent_version=request.agent_version,
|
|
158
|
+
force_extraction=job.force_extraction,
|
|
159
|
+
skip_aggregation=job.skip_aggregation,
|
|
160
|
+
sequential=True, # prevent ThreadPoolExecutor deadlock on commit_scope RLock
|
|
161
|
+
)
|
|
162
|
+
rows = storage.complete_learning_job(
|
|
163
|
+
job_id=job.job_id, claim_token=claim_token
|
|
164
|
+
)
|
|
165
|
+
if rows == 0:
|
|
166
|
+
raise _SupersededError(job.job_id)
|
|
167
|
+
logger.info(
|
|
168
|
+
"event=learning_job_done job_id=%s org_id=%s user_id=%s",
|
|
169
|
+
job.job_id,
|
|
170
|
+
job.org_id,
|
|
171
|
+
job.user_id,
|
|
172
|
+
)
|
|
173
|
+
return True
|
|
174
|
+
except _SupersededError:
|
|
175
|
+
logger.info(
|
|
176
|
+
"event=learning_job_superseded job_id=%s org_id=%s",
|
|
177
|
+
job.job_id,
|
|
178
|
+
job.org_id,
|
|
179
|
+
)
|
|
180
|
+
return False
|
|
181
|
+
except Exception:
|
|
182
|
+
logger.exception(
|
|
183
|
+
"event=learning_job_failed job_id=%s org_id=%s user_id=%s",
|
|
184
|
+
job.job_id,
|
|
185
|
+
job.org_id,
|
|
186
|
+
job.user_id,
|
|
187
|
+
)
|
|
188
|
+
# attempts was incremented by claim_learning_jobs; go dead when we've
|
|
189
|
+
# exhausted max_attempts total claim attempts.
|
|
190
|
+
storage.fail_learning_job(
|
|
191
|
+
job_id=job.job_id, claim_token=claim_token, dead=dead
|
|
192
|
+
)
|
|
193
|
+
return False
|
|
@@ -11,11 +11,11 @@ with — never another tenant's runs.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import logging
|
|
14
|
-
import threading
|
|
15
14
|
from collections.abc import Callable
|
|
16
15
|
from datetime import UTC, datetime
|
|
17
16
|
|
|
18
17
|
from reflexio.server.api_endpoints.request_context import RequestContext
|
|
18
|
+
from reflexio.server.scheduling import ThreadedScheduler
|
|
19
19
|
from reflexio.server.services.extraction.resumable_agent import (
|
|
20
20
|
pending_tool_calls_enabled,
|
|
21
21
|
)
|
|
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|
|
27
27
|
_DEFAULT_POLL_INTERVAL_SECONDS = 5.0
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
class ExtractionResumeScheduler:
|
|
30
|
+
class ExtractionResumeScheduler(ThreadedScheduler):
|
|
31
31
|
"""Small polling wrapper that drives :class:`ExtractionResumeWorker` per org."""
|
|
32
32
|
|
|
33
33
|
def __init__(
|
|
@@ -37,29 +37,15 @@ class ExtractionResumeScheduler:
|
|
|
37
37
|
bootstrap_org_id: str,
|
|
38
38
|
max_runs_per_tick: int = 10,
|
|
39
39
|
) -> None:
|
|
40
|
+
super().__init__(thread_name="reflexio-extraction-resume-scheduler")
|
|
40
41
|
self.request_context_factory = request_context_factory
|
|
41
42
|
self.bootstrap_org_id = bootstrap_org_id
|
|
42
43
|
self.max_runs_per_tick = max_runs_per_tick
|
|
43
|
-
self._stop_event = threading.Event()
|
|
44
|
-
self._thread: threading.Thread | None = None
|
|
45
44
|
|
|
46
|
-
def
|
|
47
|
-
if self._thread is not None and self._thread.is_alive():
|
|
48
|
-
return
|
|
49
|
-
self._stop_event.clear()
|
|
50
|
-
self._thread = threading.Thread(
|
|
51
|
-
target=self._run_loop,
|
|
52
|
-
name="reflexio-extraction-resume-scheduler",
|
|
53
|
-
daemon=True,
|
|
54
|
-
)
|
|
55
|
-
self._thread.start()
|
|
45
|
+
def _on_started(self) -> None:
|
|
56
46
|
logger.info("event=extraction_resume_scheduler_started")
|
|
57
47
|
|
|
58
|
-
def
|
|
59
|
-
self._stop_event.set()
|
|
60
|
-
if self._thread is not None:
|
|
61
|
-
self._thread.join(timeout=timeout_seconds)
|
|
62
|
-
self._thread = None
|
|
48
|
+
def _on_stopped(self) -> None:
|
|
63
49
|
logger.info("event=extraction_resume_scheduler_stopped")
|
|
64
50
|
|
|
65
51
|
def _discover_org_ids(self, bootstrap_ctx: RequestContext) -> list[str]:
|
|
@@ -116,28 +102,25 @@ class ExtractionResumeScheduler:
|
|
|
116
102
|
org_id,
|
|
117
103
|
)
|
|
118
104
|
|
|
119
|
-
def
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
):
|
|
139
|
-
logger.exception("event=extraction_resume_scheduler_tick_failed")
|
|
140
|
-
self._stop_event.wait(poll_interval)
|
|
105
|
+
def _run_once(self) -> float:
|
|
106
|
+
poll_interval = _DEFAULT_POLL_INTERVAL_SECONDS
|
|
107
|
+
try:
|
|
108
|
+
bootstrap_ctx = self.request_context_factory(self.bootstrap_org_id)
|
|
109
|
+
config = bootstrap_ctx.configurator.get_config()
|
|
110
|
+
poll_interval = config.pending_tool_call_config.resume_poll_interval_seconds
|
|
111
|
+
self._expire_pending_tool_calls(bootstrap_ctx)
|
|
112
|
+
for org_id in self._discover_org_ids(bootstrap_ctx):
|
|
113
|
+
if self._stop_event.is_set():
|
|
114
|
+
break
|
|
115
|
+
self._drain_org(org_id)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
with sentry_tags(
|
|
118
|
+
subsystem="extraction",
|
|
119
|
+
op="scheduler_tick",
|
|
120
|
+
error_type=type(exc).__name__,
|
|
121
|
+
):
|
|
122
|
+
logger.exception("event=extraction_resume_scheduler_tick_failed")
|
|
123
|
+
return poll_interval
|
|
141
124
|
|
|
142
125
|
|
|
143
126
|
def maybe_start_resume_scheduler(
|