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.
Files changed (139) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +6 -2
  3. package/bin/claude-smart.js +289 -56
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  8. package/plugin/dashboard/package-lock.json +61 -390
  9. package/plugin/dashboard/package.json +2 -2
  10. package/plugin/opencode/dist/server.mjs +60 -2
  11. package/plugin/opencode/server.mts +64 -2
  12. package/plugin/pyproject.toml +2 -2
  13. package/plugin/scripts/_lib.sh +58 -6
  14. package/plugin/scripts/backend-service.sh +15 -10
  15. package/plugin/scripts/codex-hook.js +16 -4
  16. package/plugin/scripts/dashboard-service.sh +1 -1
  17. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  18. package/plugin/scripts/opencode-claude-compat.js +8 -1
  19. package/plugin/scripts/smart-install.sh +101 -20
  20. package/plugin/src/README.md +1 -1
  21. package/plugin/src/claude_smart/cli.py +79 -29
  22. package/plugin/src/claude_smart/env_config.py +53 -11
  23. package/plugin/uv.lock +5 -5
  24. package/plugin/vendor/reflexio/.env.example +7 -0
  25. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  26. package/plugin/vendor/reflexio/reflexio/README.md +8 -5
  27. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  28. package/plugin/vendor/reflexio/reflexio/client/client.py +40 -6
  29. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  30. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  31. package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
  32. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +18 -0
  33. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  34. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
  35. package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
  36. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  37. package/plugin/vendor/reflexio/reflexio/server/api.py +148 -3277
  38. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  39. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  40. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  41. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  42. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  43. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  44. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  45. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  46. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  47. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  48. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +72 -1817
  49. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
  50. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +183 -1
  51. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  52. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  53. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  54. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  55. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  56. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  57. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  58. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  59. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  60. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +320 -0
  61. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  62. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  63. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  64. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  65. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  66. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
  68. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  69. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  70. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  71. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  72. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  73. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  74. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  75. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  76. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  77. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  79. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +193 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +24 -41
  81. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +335 -113
  82. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
  83. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
  84. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  87. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  88. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +44 -22
  89. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  90. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +31 -4
  91. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  92. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +196 -353
  93. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
  94. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +379 -0
  95. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +17 -6
  96. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
  97. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  98. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  103. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  104. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +216 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +22 -10
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  114. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +25 -12
  115. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  116. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +308 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +920 -0
  118. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -8
  120. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  121. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  122. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +219 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  124. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  125. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  126. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  127. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  129. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  131. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  133. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  135. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +95 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +60 -80
  138. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  139. 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
- parts = item_id.rsplit("-", 1)
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 start(self) -> None:
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 stop(self, *, timeout_seconds: float = 5.0) -> None:
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 _run_loop(self) -> None:
120
- while not self._stop_event.is_set():
121
- poll_interval = _DEFAULT_POLL_INTERVAL_SECONDS
122
- try:
123
- bootstrap_ctx = self.request_context_factory(self.bootstrap_org_id)
124
- config = bootstrap_ctx.configurator.get_config()
125
- poll_interval = (
126
- config.pending_tool_call_config.resume_poll_interval_seconds
127
- )
128
- self._expire_pending_tool_calls(bootstrap_ctx)
129
- for org_id in self._discover_org_ids(bootstrap_ctx):
130
- if self._stop_event.is_set():
131
- break
132
- self._drain_org(org_id)
133
- except Exception as exc:
134
- with sentry_tags(
135
- subsystem="extraction",
136
- op="scheduler_tick",
137
- error_type=type(exc).__name__,
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(