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
@@ -0,0 +1,379 @@
1
+ """SQLite implementation of the durable learning-job queue (Task 3)."""
2
+
3
+ import sqlite3
4
+ import time
5
+ import uuid
6
+ from typing import Any
7
+
8
+ from reflexio.server.services.storage.storage_base._learning_jobs import (
9
+ _ABSENCE_DONE_AFTER_SECONDS,
10
+ LearningJob,
11
+ LearningJobStoreABC,
12
+ LearningStatus,
13
+ )
14
+
15
+ from ._base import SQLiteStorageBase, _epoch_to_iso, _iso_to_epoch
16
+
17
+
18
+ def _row_to_learning_job(row: sqlite3.Row) -> LearningJob:
19
+ """Convert a sqlite3.Row from learning_jobs to a LearningJob dataclass."""
20
+ d = dict(row)
21
+ ct = d.get("covers_through")
22
+ return LearningJob(
23
+ job_id=d["job_id"],
24
+ org_id=d["org_id"],
25
+ user_id=d["user_id"],
26
+ job_type=d["job_type"],
27
+ latest_request_id=d.get("latest_request_id"),
28
+ status=d["status"],
29
+ attempts=d["attempts"],
30
+ claim_token=d.get("claim_token"),
31
+ covers_through=float(_iso_to_epoch(ct)) if ct else None,
32
+ force_extraction=bool(d.get("force_extraction", 0)),
33
+ skip_aggregation=bool(d.get("skip_aggregation", 0)),
34
+ max_attempts=int(d.get("max_attempts", 3)),
35
+ )
36
+
37
+
38
+ class SQLiteLearningJobStoreMixin(LearningJobStoreABC):
39
+ """SQLite implementation of the learning-job queue.
40
+
41
+ Relies on instance attributes provided by SQLiteStorageBase via MRO:
42
+ ``conn``, ``_lock``, ``_own_transaction``, ``org_id``.
43
+ """
44
+
45
+ # Type annotations for attributes/methods supplied by SQLiteStorageBase via MRO
46
+ _lock: Any
47
+ conn: sqlite3.Connection
48
+ org_id: str
49
+ _own_transaction: Any
50
+ _fetchall: Any
51
+
52
+ @SQLiteStorageBase.handle_exceptions
53
+ def enqueue_learning_job(
54
+ self,
55
+ *,
56
+ org_id: str,
57
+ user_id: str,
58
+ request_id: str,
59
+ covers_through: float,
60
+ job_type: str = "learning",
61
+ force_extraction: bool = False,
62
+ skip_aggregation: bool = False,
63
+ ) -> str:
64
+ """Coalescing upsert — safe to call inside a commit_scope."""
65
+ job_id = str(uuid.uuid4())
66
+ # int() truncates sub-second precision — intentional (second-precision epochs).
67
+ iso_covers = _epoch_to_iso(int(covers_through))
68
+ fe_int = int(force_extraction)
69
+ sa_int = int(skip_aggregation)
70
+ with self._lock:
71
+ own_txn = self._own_transaction()
72
+ try:
73
+ if own_txn:
74
+ self.conn.execute("BEGIN IMMEDIATE")
75
+ row = self.conn.execute(
76
+ """
77
+ INSERT INTO learning_jobs
78
+ (job_id, org_id, user_id, job_type, latest_request_id,
79
+ covers_through, status, force_extraction, skip_aggregation,
80
+ created_at, updated_at)
81
+ VALUES
82
+ (?, ?, ?, ?, ?,
83
+ ?, 'pending', ?, ?,
84
+ strftime('%Y-%m-%dT%H:%M:%fZ','now'),
85
+ strftime('%Y-%m-%dT%H:%M:%fZ','now'))
86
+ ON CONFLICT (org_id, user_id, job_type) WHERE status = 'pending'
87
+ DO UPDATE SET
88
+ latest_request_id = excluded.latest_request_id,
89
+ covers_through = CASE
90
+ WHEN learning_jobs.covers_through > excluded.covers_through
91
+ THEN learning_jobs.covers_through
92
+ ELSE excluded.covers_through
93
+ END,
94
+ force_extraction = excluded.force_extraction,
95
+ skip_aggregation = excluded.skip_aggregation,
96
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
97
+ RETURNING job_id
98
+ """,
99
+ (
100
+ job_id,
101
+ org_id,
102
+ user_id,
103
+ job_type,
104
+ request_id,
105
+ iso_covers,
106
+ fe_int,
107
+ sa_int,
108
+ ),
109
+ ).fetchone()
110
+ if own_txn:
111
+ self.conn.commit()
112
+ except Exception:
113
+ if own_txn:
114
+ self.conn.rollback()
115
+ raise
116
+ if row is None:
117
+ raise RuntimeError("enqueue_learning_job RETURNING job_id returned no row")
118
+ return str(row["job_id"])
119
+
120
+ @SQLiteStorageBase.handle_exceptions
121
+ def claim_learning_jobs(
122
+ self,
123
+ *,
124
+ claimed_by: str,
125
+ limit: int,
126
+ lease_seconds: int,
127
+ ) -> list[LearningJob]:
128
+ """BEGIN IMMEDIATE + SELECT + UPDATE to atomically claim jobs."""
129
+ with self._lock:
130
+ own_txn = self._own_transaction()
131
+ try:
132
+ if own_txn:
133
+ self.conn.execute("BEGIN IMMEDIATE")
134
+
135
+ # Find candidate job_ids using DB's now() to avoid clock skew.
136
+ # Include 'failed' so a failed-but-not-dead job is naturally
137
+ # reclaimable without a manual status reset.
138
+ candidate_rows = self.conn.execute(
139
+ """
140
+ SELECT job_id FROM learning_jobs
141
+ WHERE org_id = ?
142
+ AND (
143
+ status = 'pending'
144
+ OR status = 'failed'
145
+ OR (status = 'claimed'
146
+ AND claim_expires_at < strftime('%Y-%m-%dT%H:%M:%fZ','now'))
147
+ )
148
+ ORDER BY created_at
149
+ LIMIT ?
150
+ """,
151
+ (self.org_id, limit),
152
+ ).fetchall()
153
+
154
+ claimed: list[LearningJob] = []
155
+ for cand in candidate_rows:
156
+ job_id = cand["job_id"]
157
+ claim_token = str(uuid.uuid4())
158
+ updated = self.conn.execute(
159
+ """
160
+ UPDATE learning_jobs SET
161
+ status = 'claimed',
162
+ claimed_by = ?,
163
+ claim_token = ?,
164
+ claim_expires_at = strftime(
165
+ '%Y-%m-%dT%H:%M:%fZ', 'now',
166
+ ? || ' seconds'
167
+ ),
168
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
169
+ attempts = attempts + 1
170
+ WHERE job_id = ?
171
+ RETURNING *
172
+ """,
173
+ (claimed_by, claim_token, str(lease_seconds), job_id),
174
+ ).fetchone()
175
+ if updated is not None:
176
+ claimed.append(_row_to_learning_job(updated))
177
+
178
+ if own_txn:
179
+ self.conn.commit()
180
+ except Exception:
181
+ if own_txn:
182
+ self.conn.rollback()
183
+ raise
184
+
185
+ return claimed
186
+
187
+ @SQLiteStorageBase.handle_exceptions
188
+ def heartbeat_learning_job(
189
+ self,
190
+ *,
191
+ job_id: str,
192
+ claim_token: str,
193
+ lease_seconds: int,
194
+ ) -> bool:
195
+ """Extend the lease; return True if the token is still live."""
196
+ with self._lock:
197
+ own_txn = self._own_transaction()
198
+ try:
199
+ if own_txn:
200
+ self.conn.execute("BEGIN IMMEDIATE")
201
+ cur = self.conn.execute(
202
+ """
203
+ UPDATE learning_jobs SET
204
+ claim_expires_at = strftime(
205
+ '%Y-%m-%dT%H:%M:%fZ', 'now',
206
+ ? || ' seconds'
207
+ ),
208
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
209
+ WHERE job_id = ? AND claim_token = ? AND status = 'claimed'
210
+ """,
211
+ (str(lease_seconds), job_id, claim_token),
212
+ )
213
+ updated = cur.rowcount == 1
214
+ if own_txn:
215
+ self.conn.commit()
216
+ except Exception:
217
+ if own_txn:
218
+ self.conn.rollback()
219
+ raise
220
+
221
+ return updated
222
+
223
+ @SQLiteStorageBase.handle_exceptions
224
+ def complete_learning_job(
225
+ self,
226
+ *,
227
+ job_id: str,
228
+ claim_token: str,
229
+ ) -> int:
230
+ """Fenced completion — returns rowcount (0=superseded, 1=success).
231
+
232
+ Safe to call inside a commit_scope — no own BEGIN/COMMIT issued.
233
+ """
234
+ with self._lock:
235
+ own_txn = self._own_transaction()
236
+ try:
237
+ if own_txn:
238
+ self.conn.execute("BEGIN IMMEDIATE")
239
+ cur = self.conn.execute(
240
+ """
241
+ UPDATE learning_jobs SET
242
+ status = 'done',
243
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
244
+ WHERE job_id = ? AND claim_token = ? AND status = 'claimed'
245
+ """,
246
+ (job_id, claim_token),
247
+ )
248
+ rowcount = cur.rowcount
249
+ if own_txn:
250
+ self.conn.commit()
251
+ except Exception:
252
+ if own_txn:
253
+ self.conn.rollback()
254
+ raise
255
+
256
+ return rowcount
257
+
258
+ @SQLiteStorageBase.handle_exceptions
259
+ def fail_learning_job(
260
+ self,
261
+ *,
262
+ job_id: str,
263
+ claim_token: str,
264
+ dead: bool,
265
+ ) -> None:
266
+ """Fenced fail/dead transition — sets status, clears token for retry.
267
+
268
+ Does NOT increment attempts: claim_learning_jobs already incremented on
269
+ delivery. attempts tracks delivery count; fail only transitions status.
270
+ """
271
+ new_status = "dead" if dead else "failed"
272
+ # Clear claim_token and claim_expires_at only for 'failed' so it's reclaimable.
273
+ # For 'dead', we keep claim_token set for auditability (won't be reclaimed anyway).
274
+ with self._lock:
275
+ own_txn = self._own_transaction()
276
+ try:
277
+ if own_txn:
278
+ self.conn.execute("BEGIN IMMEDIATE")
279
+ self.conn.execute(
280
+ """
281
+ UPDATE learning_jobs SET
282
+ status = ?,
283
+ claim_token = CASE WHEN ? THEN claim_token ELSE NULL END,
284
+ claim_expires_at = CASE WHEN ? THEN claim_expires_at ELSE NULL END,
285
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
286
+ WHERE job_id = ? AND claim_token = ? AND status = 'claimed'
287
+ """,
288
+ (new_status, dead, dead, job_id, claim_token),
289
+ )
290
+ if own_txn:
291
+ self.conn.commit()
292
+ except Exception:
293
+ if own_txn:
294
+ self.conn.rollback()
295
+ raise
296
+
297
+ @SQLiteStorageBase.handle_exceptions
298
+ def list_org_ids_with_pending_learning_jobs(self) -> list[str]:
299
+ """Distinct org_ids with actionable jobs (cross-org, not org-scoped).
300
+
301
+ Uses the DB's now() for the expired-lease comparison to avoid clock skew,
302
+ mirroring ``claim_learning_jobs``. Index-aided by ``learning_jobs_poll``
303
+ (partial index narrows the scan to non-terminal rows; org_id still requires
304
+ a heap fetch).
305
+ """
306
+ rows = self._fetchall(
307
+ """
308
+ SELECT DISTINCT org_id FROM learning_jobs
309
+ WHERE status = 'pending'
310
+ OR status = 'failed'
311
+ OR (status = 'claimed'
312
+ AND claim_expires_at < strftime('%Y-%m-%dT%H:%M:%fZ','now'))
313
+ ORDER BY org_id ASC
314
+ """,
315
+ (),
316
+ )
317
+ return [str(row["org_id"]) for row in rows]
318
+
319
+ @SQLiteStorageBase.handle_exceptions
320
+ def get_learning_status_for_request(
321
+ self,
322
+ *,
323
+ user_id: str,
324
+ request_created_at: float,
325
+ ) -> LearningStatus:
326
+ """Coverage-based status lookup (§3.6 rule).
327
+
328
+ Converts request_created_at epoch to ISO for lexicographic comparison
329
+ with stored covers_through ISO strings (same format, both UTC).
330
+ """
331
+ req_iso = _epoch_to_iso(int(request_created_at))
332
+ rows = self._fetchall(
333
+ "SELECT status, covers_through FROM learning_jobs "
334
+ "WHERE org_id = ? AND user_id = ?",
335
+ (self.org_id, user_id),
336
+ )
337
+
338
+ has_pending = False
339
+ has_claimed_covering = False
340
+ has_dead_covering = False
341
+ has_failed = False
342
+
343
+ for row in rows:
344
+ status = row["status"]
345
+ ct: str | None = row["covers_through"]
346
+ covers = ct is not None and ct >= req_iso
347
+
348
+ if covers and status == "done":
349
+ return "done"
350
+ if covers and status == "claimed":
351
+ has_claimed_covering = True
352
+ if covers and status == "dead":
353
+ has_dead_covering = True
354
+ if status == "failed":
355
+ # 'failed' is reclaimable (attempts < max_attempts); treat as pending.
356
+ # Accumulate as a flag so a covering done row (encountered later in the
357
+ # iteration) is not shadowed by a failed row yielded earlier.
358
+ has_failed = True
359
+ if status == "pending":
360
+ has_pending = True
361
+ if status == "claimed":
362
+ # Deliberately treats any claimed job as covering, regardless of its
363
+ # covers_through value — it will extend the window once it completes.
364
+ has_claimed_covering = True
365
+
366
+ if has_claimed_covering:
367
+ return "processing"
368
+ if has_pending:
369
+ return "pending"
370
+ if has_failed:
371
+ return "pending"
372
+ if has_dead_covering:
373
+ return "failed"
374
+ # Absence semantics: terminal rows (done/dead) are GC'd after 24-72 h.
375
+ # Only treat absence as "done" once the request is old enough that a done
376
+ # row would have been reaped; a recent request with no rows is still pending.
377
+ if time.time() - request_created_at >= _ABSENCE_DONE_AFTER_SECONDS:
378
+ return "done"
379
+ return "pending"
@@ -17,7 +17,12 @@ EntityType = Literal["user_playbook", "agent_playbook", "profile"]
17
17
  # Also used as the merge guard: a source that already carries any of these
18
18
  # statuses is skipped (no re-tombstone, no clock reset).
19
19
  _GC_ELIGIBLE_STATUSES: frozenset[str] = frozenset(
20
- {Status.MERGED.value, Status.SUPERSEDED.value, Status.ARCHIVED.value}
20
+ {
21
+ Status.MERGED.value,
22
+ Status.SUPERSEDED.value,
23
+ Status.ARCHIVED.value,
24
+ Status.EXPIRED.value,
25
+ }
21
26
  )
22
27
 
23
28
  # Mapping from entity_type string to (table_name, primary_key_column).
@@ -122,6 +127,7 @@ class SQLiteLineageMixin:
122
127
  conn: sqlite3.Connection
123
128
  _lock: threading.RLock
124
129
  org_id: str
130
+ _own_transaction: Any
125
131
 
126
132
  def append_lineage_event(self, event: LineageEvent) -> int:
127
133
  """Append an event; idempotent on (org_id, entity_type, entity_id, op, request_id).
@@ -168,10 +174,12 @@ class SQLiteLineageMixin:
168
174
  ),
169
175
  ).fetchone()
170
176
  eid = row[0] if row else None
171
- self.conn.commit()
177
+ if self._own_transaction():
178
+ self.conn.commit()
172
179
  return int(eid) if eid is not None else 0
173
180
  last = cur.lastrowid
174
- self.conn.commit()
181
+ if self._own_transaction():
182
+ self.conn.commit()
175
183
  return int(last) if last is not None else 0
176
184
 
177
185
  def get_lineage_events(
@@ -296,7 +304,8 @@ class SQLiteLineageMixin:
296
304
  request_id=context.request_id,
297
305
  reason=context.reason,
298
306
  )
299
- self.conn.commit()
307
+ if self._own_transaction():
308
+ self.conn.commit()
300
309
 
301
310
  def supersede_record(
302
311
  self,
@@ -338,7 +347,8 @@ class SQLiteLineageMixin:
338
347
  (Status.SUPERSEDED.value, successor_id, _epoch_now(), incumbent_id),
339
348
  )
340
349
  if cur.rowcount == 0:
341
- self.conn.commit()
350
+ if self._own_transaction():
351
+ self.conn.commit()
342
352
  return False
343
353
  _append_event_stmt(
344
354
  self.conn,
@@ -352,7 +362,8 @@ class SQLiteLineageMixin:
352
362
  request_id=context.request_id,
353
363
  reason=context.reason,
354
364
  )
355
- self.conn.commit()
365
+ if self._own_transaction():
366
+ self.conn.commit()
356
367
  return True
357
368
 
358
369
  def purge_content(self, *, entity_type: EntityType, entity_id: str) -> bool: