claude-smart 0.2.48 → 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 (50) 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/opencode/dist/server.mjs +60 -2
  8. package/plugin/opencode/server.mts +64 -2
  9. package/plugin/pyproject.toml +2 -2
  10. package/plugin/scripts/_lib.sh +58 -6
  11. package/plugin/scripts/backend-service.sh +15 -10
  12. package/plugin/scripts/codex-hook.js +16 -4
  13. package/plugin/scripts/dashboard-service.sh +1 -1
  14. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  15. package/plugin/scripts/opencode-claude-compat.js +8 -1
  16. package/plugin/scripts/smart-install.sh +101 -20
  17. package/plugin/src/README.md +1 -1
  18. package/plugin/src/claude_smart/cli.py +79 -29
  19. package/plugin/src/claude_smart/env_config.py +53 -11
  20. package/plugin/uv.lock +5 -5
  21. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  22. package/plugin/vendor/reflexio/reflexio/client/client.py +40 -6
  23. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +18 -0
  24. package/plugin/vendor/reflexio/reflexio/server/api.py +15 -4
  25. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +1 -1
  26. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1 -0
  27. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +183 -1
  28. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +63 -2
  29. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  30. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  31. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +193 -0
  32. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +1 -3
  33. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +150 -37
  34. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +44 -22
  35. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +2 -0
  36. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +140 -2
  37. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +379 -0
  38. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -5
  39. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  40. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +86 -2
  41. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +16 -7
  42. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  43. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +12 -5
  44. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +52 -7
  45. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +28 -4
  46. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +17 -0
  47. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  48. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +219 -0
  49. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +23 -1
  50. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +22 -0
@@ -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
@@ -107,9 +107,7 @@ class ExtractionResumeScheduler(ThreadedScheduler):
107
107
  try:
108
108
  bootstrap_ctx = self.request_context_factory(self.bootstrap_org_id)
109
109
  config = bootstrap_ctx.configurator.get_config()
110
- poll_interval = (
111
- config.pending_tool_call_config.resume_poll_interval_seconds
112
- )
110
+ poll_interval = config.pending_tool_call_config.resume_poll_interval_seconds
113
111
  self._expire_pending_tool_calls(bootstrap_ctx)
114
112
  for org_id in self._discover_org_ids(bootstrap_ctx):
115
113
  if self._stop_event.is_set():
@@ -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 (
@@ -249,17 +250,32 @@ class GenerationService:
249
250
  session_id=publish_user_interaction_request.session_id,
250
251
  evaluation_only=publish_user_interaction_request.evaluation_only,
251
252
  )
252
- self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
253
253
 
254
- # Add interactions to storage (bulk insert with batched embedding generation)
255
- self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
256
- user_id=user_id, interactions=new_interactions
254
+ # When the durable queue is enabled and this is a deferred publish, the
255
+ # request + interactions + job row are written together inside a single
256
+ # commit_scope (below). Every other path persists here unconditionally.
257
+ use_durable_queue = env_truthy(
258
+ env_str("REFLEXIO_DURABLE_LEARNING_QUEUE", "false")
257
259
  )
260
+ _durable_defer = defer_learning and use_durable_queue
261
+
262
+ if not _durable_defer:
263
+ self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
264
+ # Add interactions to storage (bulk insert with batched embedding generation)
265
+ self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
266
+ user_id=user_id, interactions=new_interactions
267
+ )
258
268
 
259
269
  # Extract source (empty string treated as None)
260
270
  source = publish_user_interaction_request.source or None
261
271
 
262
272
  if publish_user_interaction_request.evaluation_only:
273
+ if _durable_defer:
274
+ # evaluation_only returns early; ensure data lands even on durable path.
275
+ self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
276
+ self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
277
+ user_id=user_id, interactions=new_interactions
278
+ )
263
279
  self._schedule_group_evaluation_if_needed(
264
280
  new_request=new_request,
265
281
  user_id=user_id,
@@ -290,6 +306,12 @@ class GenerationService:
290
306
  not publish_user_interaction_request.override_learning_stall
291
307
  and (stall_warning := self._active_learning_stall_warning()) is not None
292
308
  ):
309
+ if _durable_defer:
310
+ # Stall skips learning but data must still land.
311
+ self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
312
+ self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
313
+ user_id=user_id, interactions=new_interactions
314
+ )
293
315
  result.warnings.append(stall_warning)
294
316
  logger.warning("%s; skipping automatic extraction", stall_warning)
295
317
  record_usage_event(
@@ -309,6 +331,54 @@ class GenerationService:
309
331
  return result
310
332
 
311
333
  if defer_learning:
334
+ if _durable_defer:
335
+ # Durable atomic path: pre-generate embeddings OUTSIDE the
336
+ # transaction (network call), then persist request + interactions
337
+ # + job in one commit_scope.
338
+ self.storage.prepare_interaction_embeddings(new_interactions) # type: ignore[reportOptionalMemberAccess]
339
+ covers_through = max(i.created_at for i in new_interactions)
340
+ with self.storage.commit_scope(): # type: ignore[reportOptionalMemberAccess]
341
+ self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
342
+ self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
343
+ user_id=user_id,
344
+ interactions=new_interactions,
345
+ embeddings_prepared=True,
346
+ )
347
+ self.storage.enqueue_learning_job( # type: ignore[reportOptionalMemberAccess]
348
+ org_id=self.org_id,
349
+ user_id=user_id,
350
+ request_id=request_id,
351
+ covers_through=covers_through,
352
+ force_extraction=publish_user_interaction_request.force_extraction,
353
+ skip_aggregation=publish_user_interaction_request.skip_aggregation,
354
+ )
355
+ self._schedule_group_evaluation_if_needed(
356
+ new_request=new_request,
357
+ user_id=user_id,
358
+ agent_version=agent_version,
359
+ source=source,
360
+ )
361
+ record_usage_event(
362
+ org_id=self.org_id,
363
+ user_id=user_id,
364
+ request_id=request_id,
365
+ session_id=new_request.session_id,
366
+ source=source,
367
+ agent_version=agent_version,
368
+ backend="durable",
369
+ event_name="publish_request_succeeded",
370
+ event_category="publish",
371
+ outcome="success",
372
+ count_value=len(new_interactions),
373
+ duration_ms=int((time.perf_counter() - publish_start) * 1000),
374
+ metadata={
375
+ "defer_learning": True,
376
+ "durable_queue": True,
377
+ "warning_count": len(result.warnings),
378
+ },
379
+ )
380
+ return result
381
+
312
382
  from reflexio.server.services.publish_learning_worker import (
313
383
  PublishLearningJob,
314
384
  enqueue_publish_learning,
@@ -444,7 +514,16 @@ class GenerationService:
444
514
  agent_version: str,
445
515
  force_extraction: bool,
446
516
  skip_aggregation: bool,
517
+ sequential: bool = False,
447
518
  ) -> GenerationServiceResult:
519
+ """Run the learning steps for a deferred job.
520
+
521
+ Args:
522
+ sequential: When True, run profile and playbook generation on the
523
+ calling thread (no ThreadPoolExecutor). Required when the
524
+ caller already holds the storage commit_scope lock — spawning
525
+ worker threads inside that scope would deadlock on the RLock.
526
+ """
448
527
  result = GenerationServiceResult(request_id=request_id)
449
528
  self._run_learning_steps(
450
529
  user_id=user_id,
@@ -454,6 +533,7 @@ class GenerationService:
454
533
  skip_aggregation=skip_aggregation,
455
534
  source=source,
456
535
  result=result,
536
+ parallel=not sequential,
457
537
  )
458
538
  return result
459
539
 
@@ -471,6 +551,7 @@ class GenerationService:
471
551
  skip_aggregation: bool,
472
552
  source: str | None,
473
553
  result: GenerationServiceResult,
554
+ parallel: bool = True,
474
555
  ) -> None:
475
556
  # Reflection runs as its own sliding-window step BEFORE the extractor
476
557
  # pool spins up, so replacements are visible to extractors.
@@ -504,43 +585,29 @@ class GenerationService:
504
585
  force_extraction=force_extraction,
505
586
  )
506
587
 
507
- executor = ThreadPoolExecutor(max_workers=2)
508
- try:
509
- futures = [
510
- executor.submit(
511
- contextvars.copy_context().run,
512
- profile_generation_service.run,
513
- profile_generation_request,
588
+ if not parallel:
589
+ # Sequential mode: run both services on the calling thread.
590
+ # Required when the caller holds a commit_scope lock — spawning
591
+ # child threads inside that scope would deadlock the SQLite RLock.
592
+ for svc_name, _run in (
593
+ (
594
+ "profile_generation",
595
+ lambda: profile_generation_service.run(profile_generation_request),
514
596
  ),
515
- executor.submit(
516
- contextvars.copy_context().run,
517
- playbook_generation_service.run,
518
- playbook_generation_request,
597
+ (
598
+ "playbook_generation",
599
+ lambda: playbook_generation_service.run(
600
+ playbook_generation_request
601
+ ),
519
602
  ),
520
- ]
521
-
522
- service_names = ["profile_generation", "playbook_generation"]
523
- for future, service_name in zip(futures, service_names, strict=True):
603
+ ):
524
604
  try:
525
- future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
526
- except FuturesTimeoutError: # noqa: PERF203
527
- msg = (
528
- f"{service_name} timed out after "
529
- f"{GENERATION_SERVICE_TIMEOUT_SECONDS}s"
530
- )
531
- with sentry_tags(
532
- subsystem="generation",
533
- service=service_name,
534
- request_id=request_id,
535
- error_type="timeout",
536
- ):
537
- logger.error("%s for request %s", msg, request_id)
538
- result.warnings.append(msg)
605
+ _run()
539
606
  except Exception as e:
540
- msg = f"{service_name} failed: {e}"
607
+ msg = f"{svc_name} failed: {e}"
541
608
  with sentry_tags(
542
609
  subsystem="generation",
543
- service=service_name,
610
+ service=svc_name,
544
611
  request_id=request_id,
545
612
  error_type=type(e).__name__,
546
613
  ):
@@ -549,8 +616,54 @@ class GenerationService:
549
616
  request_id,
550
617
  )
551
618
  result.warnings.append(msg)
552
- finally:
553
- executor.shutdown(wait=False, cancel_futures=True)
619
+ else:
620
+ executor = ThreadPoolExecutor(max_workers=2)
621
+ try:
622
+ futures = [
623
+ executor.submit(
624
+ contextvars.copy_context().run,
625
+ profile_generation_service.run,
626
+ profile_generation_request,
627
+ ),
628
+ executor.submit(
629
+ contextvars.copy_context().run,
630
+ playbook_generation_service.run,
631
+ playbook_generation_request,
632
+ ),
633
+ ]
634
+
635
+ service_names = ["profile_generation", "playbook_generation"]
636
+ for future, service_name in zip(futures, service_names, strict=True):
637
+ try:
638
+ future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
639
+ except FuturesTimeoutError: # noqa: PERF203
640
+ msg = (
641
+ f"{service_name} timed out after "
642
+ f"{GENERATION_SERVICE_TIMEOUT_SECONDS}s"
643
+ )
644
+ with sentry_tags(
645
+ subsystem="generation",
646
+ service=service_name,
647
+ request_id=request_id,
648
+ error_type="timeout",
649
+ ):
650
+ logger.error("%s for request %s", msg, request_id)
651
+ result.warnings.append(msg)
652
+ except Exception as e:
653
+ msg = f"{service_name} failed: {e}"
654
+ with sentry_tags(
655
+ subsystem="generation",
656
+ service=service_name,
657
+ request_id=request_id,
658
+ error_type=type(e).__name__,
659
+ ):
660
+ logger.exception(
661
+ "Generation service failed for request %s",
662
+ request_id,
663
+ )
664
+ result.warnings.append(msg)
665
+ finally:
666
+ executor.shutdown(wait=False, cancel_futures=True)
554
667
 
555
668
  try:
556
669
  schedule_tagging(
@@ -477,29 +477,43 @@ class ProfileGenerationService(
477
477
 
478
478
  def _get_generated_count(
479
479
  self,
480
- request: RerunProfileGenerationRequest,
480
+ request: RerunProfileGenerationRequest | ManualProfileGenerationRequest,
481
481
  processed_user_ids: list[str] | None = None,
482
482
  ) -> int:
483
- """Get the count of profiles generated during rerun.
483
+ """Get the count of profiles generated during batch generation.
484
484
 
485
- Counts profiles with PENDING status for each processed user.
485
+ Counts PENDING profiles for rerun requests and CURRENT profiles for manual
486
+ regular requests, scoped to users the batch runner actually processed.
486
487
 
487
488
  Args:
488
- request: The rerun request object
489
+ request: The rerun or manual generation request object
489
490
  processed_user_ids: List of user IDs processed in the batch
490
491
 
491
492
  Returns:
492
493
  Number of profiles generated
493
494
  """
494
- user_ids = processed_user_ids or ([request.user_id] if request.user_id else [])
495
- total = 0
496
- for user_id in user_ids:
497
- profiles = self.storage.get_user_profile( # type: ignore[reportOptionalMemberAccess]
498
- user_id=user_id,
499
- status_filter=[Status.PENDING],
495
+ if isinstance(request, ManualProfileGenerationRequest):
496
+ return self._count_manual_generated(
497
+ request, processed_user_ids=processed_user_ids
500
498
  )
501
- total += len(profiles)
502
- return total
499
+
500
+ user_ids = self._count_user_ids(request.user_id, processed_user_ids)
501
+ if not user_ids:
502
+ return 0
503
+ return self.storage.count_user_profiles_by_status( # type: ignore[reportOptionalMemberAccess]
504
+ user_ids=user_ids,
505
+ status=Status.PENDING,
506
+ )
507
+
508
+ @staticmethod
509
+ def _count_user_ids(
510
+ request_user_id: str | None, processed_user_ids: list[str] | None
511
+ ) -> list[str]:
512
+ """Resolve users to count without treating an empty processed batch as missing."""
513
+
514
+ if processed_user_ids is not None:
515
+ return processed_user_ids
516
+ return [request_user_id] if request_user_id else []
503
517
 
504
518
  # ===============================
505
519
  # Upgrade/Downgrade hook implementations (override base class methods)
@@ -676,16 +690,15 @@ class ProfileGenerationService(
676
690
  "source": request.source,
677
691
  "mode": "manual_regular",
678
692
  }
679
- users_processed, _ = self._run_batch_with_progress(
693
+ # total_profiles is computed inside the batch runner via
694
+ # _get_generated_count(processed_user_ids=...).
695
+ users_processed, total_profiles = self._run_batch_with_progress(
680
696
  user_ids=user_ids,
681
697
  request=request, # type: ignore[reportArgumentType]
682
698
  request_params=request_params,
683
699
  state_manager=state_manager,
684
700
  )
685
701
 
686
- # 3. Count generated profiles (CURRENT status = None)
687
- total_profiles = self._count_manual_generated(request)
688
-
689
702
  return ManualProfileGenerationResponse(
690
703
  success=True,
691
704
  msg=f"Generated {total_profiles} profiles for {users_processed} user(s)",
@@ -700,20 +713,29 @@ class ProfileGenerationService(
700
713
  profiles_generated=0,
701
714
  )
702
715
 
703
- def _count_manual_generated(self, request: ManualProfileGenerationRequest) -> int:
716
+ def _count_manual_generated(
717
+ self,
718
+ request: ManualProfileGenerationRequest,
719
+ processed_user_ids: list[str] | None = None,
720
+ ) -> int:
704
721
  """
705
722
  Count profiles generated during manual regular generation.
706
723
 
707
- Counts profiles with CURRENT status (None), optionally filtered by user_id.
724
+ Counts profiles with CURRENT status (None) for each processed user.
708
725
 
709
726
  Args:
710
727
  request: The manual generation request object
728
+ processed_user_ids: User IDs processed by the manual batch. When
729
+ the request omitted user_id, this prevents passing None through
730
+ to storage methods that require a concrete user.
711
731
 
712
732
  Returns:
713
733
  Number of profiles with CURRENT status
714
734
  """
715
- profiles = self.storage.get_user_profile( # type: ignore[reportOptionalMemberAccess]
716
- user_id=request.user_id, # type: ignore[reportArgumentType]
717
- status_filter=[None], # CURRENT profiles
735
+ user_ids = self._count_user_ids(request.user_id, processed_user_ids)
736
+ if not user_ids:
737
+ return 0
738
+ return self.storage.count_user_profiles_by_status( # type: ignore[reportOptionalMemberAccess]
739
+ user_ids=user_ids,
740
+ status=None,
718
741
  )
719
- return len(profiles)
@@ -1,3 +1,4 @@
1
+ from ._learning_jobs import SQLiteLearningJobStoreMixin
1
2
  from ._base import (
2
3
  SQLiteStorageBase,
3
4
  _cosine_similarity,
@@ -48,6 +49,7 @@ from .profiles import InteractionStoreMixin, ProfileSearchMixin, ProfileStoreMix
48
49
 
49
50
 
50
51
  class SQLiteStorage(
52
+ SQLiteLearningJobStoreMixin,
51
53
  SQLiteAgentRunStoreMixin,
52
54
  SQLitePendingToolCallStoreMixin,
53
55
  SQLiteRunToolDependencyStoreMixin,