apsimo 1.3.0__py3-none-any.whl

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 (614) hide show
  1. apsimo/__init__.py +38 -0
  2. apsimo/__main__.py +6 -0
  3. apsimo/agent/__init__.py +6 -0
  4. apsimo/agent/client.py +276 -0
  5. apsimo/agent/models.py +46 -0
  6. apsimo/agents/__init__.py +20 -0
  7. apsimo/agents/models.py +264 -0
  8. apsimo/agents/store.py +861 -0
  9. apsimo/agents/websocket.py +522 -0
  10. apsimo/api/__init__.py +1 -0
  11. apsimo/api/auth_telemetry.py +287 -0
  12. apsimo/api/authority.py +1203 -0
  13. apsimo/api/contact_grants.py +347 -0
  14. apsimo/api/middleware.py +483 -0
  15. apsimo/api/routers/__init__.py +1 -0
  16. apsimo/api/routers/commitment_work.py +265 -0
  17. apsimo/api/routers/context_gate.py +123 -0
  18. apsimo/api/routers/executions.py +140 -0
  19. apsimo/api/routers/followup_plans.py +147 -0
  20. apsimo/api/routers/governed_actions.py +162 -0
  21. apsimo/api/routers/host.py +14473 -0
  22. apsimo/api/routers/initiative_work.py +115 -0
  23. apsimo/api/routers/mining.py +104 -0
  24. apsimo/api/routers/observations.py +110 -0
  25. apsimo/api/routers/social_state.py +225 -0
  26. apsimo/api/routers/task_queue.py +2715 -0
  27. apsimo/api/routers/temporal_followups.py +251 -0
  28. apsimo/api/routers/transport.py +110 -0
  29. apsimo/api/routers/transport_ingress_api.py +240 -0
  30. apsimo/api/schemas/__init__.py +1 -0
  31. apsimo/api/schemas/host.py +1949 -0
  32. apsimo/autonomy/cli.py +110 -0
  33. apsimo/autonomy/condition_worker.py +437 -0
  34. apsimo/autonomy/config.py +424 -0
  35. apsimo/autonomy/loop.py +4316 -0
  36. apsimo/autonomy/registry.py +339 -0
  37. apsimo/autonomy/scheduler.py +1822 -0
  38. apsimo/autonomy/synthesis.py +449 -0
  39. apsimo/backup.py +962 -0
  40. apsimo/beliefs/__init__.py +23 -0
  41. apsimo/beliefs/contradictions.py +109 -0
  42. apsimo/beliefs/decay.py +61 -0
  43. apsimo/beliefs/engine.py +479 -0
  44. apsimo/beliefs/models.py +67 -0
  45. apsimo/beliefs/promotion.py +41 -0
  46. apsimo/beliefs/resolve.py +58 -0
  47. apsimo/beliefs/source_claims.py +690 -0
  48. apsimo/beliefs/source_projection.py +883 -0
  49. apsimo/beliefs/source_time.py +208 -0
  50. apsimo/beliefs/store.py +133 -0
  51. apsimo/briefings/aggregators.py +824 -0
  52. apsimo/briefings/composer.py +420 -0
  53. apsimo/briefings/config.py +55 -0
  54. apsimo/briefings/delivery.py +439 -0
  55. apsimo/briefings/engagement.py +97 -0
  56. apsimo/briefings/engine.py +274 -0
  57. apsimo/briefings/enhancer.py +99 -0
  58. apsimo/briefings/models.py +183 -0
  59. apsimo/briefings/scheduler.py +382 -0
  60. apsimo/briefings/store.py +435 -0
  61. apsimo/chain/__init__.py +48 -0
  62. apsimo/chain/block.py +100 -0
  63. apsimo/chain/cli.py +704 -0
  64. apsimo/chain/genesis.py +443 -0
  65. apsimo/chain/identity.py +416 -0
  66. apsimo/chain/keys.py +1025 -0
  67. apsimo/chain/local_keys.py +187 -0
  68. apsimo/chain/manager.py +290 -0
  69. apsimo/chain/node.py +163 -0
  70. apsimo/chain/plugin_transactions.py +371 -0
  71. apsimo/chain/protocol.py +220 -0
  72. apsimo/chain/state_machine.py +676 -0
  73. apsimo/chain/storage.py +503 -0
  74. apsimo/chain/transactions.py +250 -0
  75. apsimo/chain/validation.py +397 -0
  76. apsimo/channels/__init__.py +1 -0
  77. apsimo/channels/manifest.py +31 -0
  78. apsimo/channels/migrations/001_channels_schema.sql +12 -0
  79. apsimo/channels/phone_gateways.py +42 -0
  80. apsimo/channels/presence.py +188 -0
  81. apsimo/channels/router.py +235 -0
  82. apsimo/channels/store.py +231 -0
  83. apsimo/cli.py +2688 -0
  84. apsimo/cognition/__init__.py +11 -0
  85. apsimo/cognition/charter.py +398 -0
  86. apsimo/cognition/drive_governance.py +3530 -0
  87. apsimo/cognition/evidence_pipeline.py +1627 -0
  88. apsimo/cognition/external_events.py +932 -0
  89. apsimo/cognition/goal_spine.py +3488 -0
  90. apsimo/cognition/introspection.py +214 -0
  91. apsimo/cognition/prompt.py +150 -0
  92. apsimo/cognition/runtime.py +108 -0
  93. apsimo/cognition/trigger.py +154 -0
  94. apsimo/commitments/__init__.py +18 -0
  95. apsimo/commitments/local_work.py +355 -0
  96. apsimo/commitments/store.py +1052 -0
  97. apsimo/commitments/work.py +91 -0
  98. apsimo/compat.py +53 -0
  99. apsimo/compression/__init__.py +467 -0
  100. apsimo/connectors/__init__.py +21 -0
  101. apsimo/connectors/base.py +152 -0
  102. apsimo/connectors/caldav_calendar.py +125 -0
  103. apsimo/connectors/fs_documents.py +85 -0
  104. apsimo/connectors/imap_email.py +138 -0
  105. apsimo/connectors/manager.py +218 -0
  106. apsimo/connectors/webhook_pull.py +88 -0
  107. apsimo/contacts/__init__.py +33 -0
  108. apsimo/contacts/comms.py +357 -0
  109. apsimo/contacts/config.py +79 -0
  110. apsimo/contacts/exporters/__init__.py +1 -0
  111. apsimo/contacts/exporters/vcard.py +71 -0
  112. apsimo/contacts/identity_links.py +251 -0
  113. apsimo/contacts/importer.py +280 -0
  114. apsimo/contacts/importers/__init__.py +1 -0
  115. apsimo/contacts/importers/batch.py +43 -0
  116. apsimo/contacts/importers/macos_contacts.py +101 -0
  117. apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
  118. apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
  119. apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
  120. apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
  121. apsimo/contacts/migrations/005_identity_links.sql +27 -0
  122. apsimo/contacts/models.py +308 -0
  123. apsimo/contacts/scoring.py +16 -0
  124. apsimo/contacts/store.py +1623 -0
  125. apsimo/contacts/transport_ingress.py +252 -0
  126. apsimo/contacts/world_bridge.py +314 -0
  127. apsimo/contextgate/__init__.py +69 -0
  128. apsimo/contextgate/chunker.py +169 -0
  129. apsimo/contextgate/estimate.py +54 -0
  130. apsimo/contextgate/gate.py +313 -0
  131. apsimo/contextgate/retrieve.py +115 -0
  132. apsimo/delivery/__init__.py +16 -0
  133. apsimo/delivery/bridge.py +1260 -0
  134. apsimo/delivery/channels.py +526 -0
  135. apsimo/delivery/classification.py +50 -0
  136. apsimo/delivery/rate_limiter.py +268 -0
  137. apsimo/delivery/reachout_policy.py +206 -0
  138. apsimo/directed/__init__.py +22 -0
  139. apsimo/directed/audit.py +167 -0
  140. apsimo/directed/intake.py +95 -0
  141. apsimo/directed/models.py +191 -0
  142. apsimo/directed/service.py +509 -0
  143. apsimo/directives/__init__.py +25 -0
  144. apsimo/directives/evidence.py +87 -0
  145. apsimo/directives/extractor.py +188 -0
  146. apsimo/directives/guard.py +364 -0
  147. apsimo/directives/models.py +206 -0
  148. apsimo/directives/service.py +372 -0
  149. apsimo/directives/store.py +167 -0
  150. apsimo/doctor.py +2173 -0
  151. apsimo/environment.py +43 -0
  152. apsimo/events/__init__.py +33 -0
  153. apsimo/events/broadcaster.py +98 -0
  154. apsimo/events/bus.py +217 -0
  155. apsimo/events/journal.py +863 -0
  156. apsimo/events/stream.py +131 -0
  157. apsimo/events/types.py +150 -0
  158. apsimo/execution_results.py +357 -0
  159. apsimo/feedback/__init__.py +5 -0
  160. apsimo/feedback/store.py +76 -0
  161. apsimo/feeds/__init__.py +19 -0
  162. apsimo/feeds/cli.py +84 -0
  163. apsimo/feeds/engine.py +437 -0
  164. apsimo/feeds/example-feed.yaml +77 -0
  165. apsimo/feeds/hermes_cron.py +126 -0
  166. apsimo/feeds/manager.py +235 -0
  167. apsimo/feeds/spec.py +250 -0
  168. apsimo/feeds/template.py +202 -0
  169. apsimo/gate/__init__.py +18 -0
  170. apsimo/gate/audit.py +61 -0
  171. apsimo/gate/communication_policy.py +166 -0
  172. apsimo/gate/config.py +72 -0
  173. apsimo/gate/context_provenance.py +170 -0
  174. apsimo/gate/env_risk.py +226 -0
  175. apsimo/gate/guard_audit.py +353 -0
  176. apsimo/gate/layers/__init__.py +1 -0
  177. apsimo/gate/layers/base.py +15 -0
  178. apsimo/gate/layers/l1_recipient.py +66 -0
  179. apsimo/gate/layers/l2_pii.py +134 -0
  180. apsimo/gate/layers/l3_cross_context.py +50 -0
  181. apsimo/gate/layers/l4_trust_tier.py +78 -0
  182. apsimo/gate/layers/l5_injection.py +199 -0
  183. apsimo/gate/layers/l6_review.py +86 -0
  184. apsimo/gate/layers/l7_delay.py +100 -0
  185. apsimo/gate/layers/tom2_epistemic.py +185 -0
  186. apsimo/gate/models.py +64 -0
  187. apsimo/gate/pending_dispatch.py +5 -0
  188. apsimo/gate/pipeline.py +206 -0
  189. apsimo/gate/rejection.py +259 -0
  190. apsimo/gate/response_guard.py +700 -0
  191. apsimo/gate/rulesets/injection_v1.yaml +51 -0
  192. apsimo/gate/surface_policy.py +189 -0
  193. apsimo/gate/taint.py +226 -0
  194. apsimo/genesis.json +9 -0
  195. apsimo/goals/__init__.py +100 -0
  196. apsimo/goals/config.py +38 -0
  197. apsimo/goals/decomposer.py +421 -0
  198. apsimo/goals/engine.py +617 -0
  199. apsimo/goals/inference.py +354 -0
  200. apsimo/goals/models.py +302 -0
  201. apsimo/goals/priority.py +270 -0
  202. apsimo/goals/queue_bridge.py +149 -0
  203. apsimo/goals/replan.py +450 -0
  204. apsimo/goals/schema.sql +89 -0
  205. apsimo/goals/store.py +692 -0
  206. apsimo/governed_actions.py +1708 -0
  207. apsimo/harness_integration/__init__.py +45 -0
  208. apsimo/harness_integration/context.py +41 -0
  209. apsimo/harness_integration/skills.py +231 -0
  210. apsimo/identity/__init__.py +26 -0
  211. apsimo/identity/participants.py +181 -0
  212. apsimo/identity/resolver.py +329 -0
  213. apsimo/identity_bootstrap/__init__.py +5 -0
  214. apsimo/identity_bootstrap/builder.py +208 -0
  215. apsimo/identity_bootstrap/corpus.py +443 -0
  216. apsimo/identity_bootstrap/models.py +54 -0
  217. apsimo/identity_bootstrap/runner.py +353 -0
  218. apsimo/identity_bootstrap/seeders/__init__.py +25 -0
  219. apsimo/identity_bootstrap/seeders/briefings.py +109 -0
  220. apsimo/identity_bootstrap/seeders/chain.py +57 -0
  221. apsimo/identity_bootstrap/seeders/goals.py +128 -0
  222. apsimo/identity_bootstrap/seeders/memory.py +191 -0
  223. apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
  224. apsimo/identity_bootstrap/seeders/relationship.py +152 -0
  225. apsimo/identity_bootstrap/seeders/sessions.py +67 -0
  226. apsimo/identity_bootstrap/seeders/skills.py +92 -0
  227. apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
  228. apsimo/identity_bootstrap/seeders/world_model.py +143 -0
  229. apsimo/identity_bootstrap/self_query.py +92 -0
  230. apsimo/identity_bootstrap/self_reflection.py +155 -0
  231. apsimo/identity_bootstrap/skill.py +37 -0
  232. apsimo/identity_bootstrap/verifier.py +436 -0
  233. apsimo/initiatives/__init__.py +20 -0
  234. apsimo/initiatives/action_registry.py +454 -0
  235. apsimo/initiatives/approval_authority.py +2105 -0
  236. apsimo/initiatives/approval_policy.py +123 -0
  237. apsimo/initiatives/assignment.py +263 -0
  238. apsimo/initiatives/backup_evidence.py +100 -0
  239. apsimo/initiatives/context_freshness.py +103 -0
  240. apsimo/initiatives/models.py +318 -0
  241. apsimo/initiatives/native_work.py +270 -0
  242. apsimo/initiatives/standing_approvals.py +232 -0
  243. apsimo/initiatives/store.py +1081 -0
  244. apsimo/initiatives/temporal_followup.py +410 -0
  245. apsimo/intelligence/__init__.py +1 -0
  246. apsimo/intelligence/cognition/__init__.py +24 -0
  247. apsimo/intelligence/cognition/gap_detector.py +148 -0
  248. apsimo/intelligence/cognition/metalearner.py +547 -0
  249. apsimo/intelligence/cognition/metrics_collector.py +217 -0
  250. apsimo/intelligence/cognition/performance_index.py +299 -0
  251. apsimo/intelligence/cognition/registry.py +192 -0
  252. apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
  253. apsimo/intelligence/cognition/types.py +16 -0
  254. apsimo/intelligence/components/__init__.py +66 -0
  255. apsimo/intelligence/components/anomaly_detector.py +413 -0
  256. apsimo/intelligence/components/initiative_engine.py +2643 -0
  257. apsimo/intelligence/components/preference_learner.py +521 -0
  258. apsimo/intelligence/components/research_orchestrator.py +358 -0
  259. apsimo/intelligence/components/self_directed_thinker.py +221 -0
  260. apsimo/intelligence/components/self_reflector.py +252 -0
  261. apsimo/intelligence/components/session_continuity.py +154 -0
  262. apsimo/intelligence/components/task_planner.py +320 -0
  263. apsimo/intelligence/components/tool_learner.py +217 -0
  264. apsimo/intelligence/graph/__init__.py +79 -0
  265. apsimo/intelligence/graph/client.py +2483 -0
  266. apsimo/intelligence/graph/consolidator.py +405 -0
  267. apsimo/intelligence/graph/distiller.py +312 -0
  268. apsimo/intelligence/graph/migrations.py +129 -0
  269. apsimo/intelligence/graph/queries.py +248 -0
  270. apsimo/intelligence/graph/recall.py +281 -0
  271. apsimo/intelligence/graph/reconciler.py +144 -0
  272. apsimo/intelligence/graph/schema.py +337 -0
  273. apsimo/intelligence/graph/selection.py +252 -0
  274. apsimo/intelligence/learning/__init__.py +17 -0
  275. apsimo/intelligence/learning/continuous_learner.py +245 -0
  276. apsimo/intelligence/learning/feedback_store.py +321 -0
  277. apsimo/intelligence/mind_model/__init__.py +1 -0
  278. apsimo/intelligence/mind_model/graph_baseline.py +136 -0
  279. apsimo/intelligence/mind_model/signal_collector.py +361 -0
  280. apsimo/intelligence/relationships/__init__.py +11 -0
  281. apsimo/intelligence/relationships/profiler.py +389 -0
  282. apsimo/intelligence/relationships/scorer.py +560 -0
  283. apsimo/intelligence/relationships/signal_floor.py +66 -0
  284. apsimo/intelligence/relationships/trust_tiers.py +300 -0
  285. apsimo/intelligence/synthesis/__init__.py +40 -0
  286. apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
  287. apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
  288. apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
  289. apsimo/intelligence/synthesis/insight_store.py +79 -0
  290. apsimo/intelligence/synthesis/insight_validator.py +183 -0
  291. apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
  292. apsimo/intelligence/turn_middleware/__init__.py +15 -0
  293. apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
  294. apsimo/mcp/__init__.py +41 -0
  295. apsimo/mcp/__main__.py +6 -0
  296. apsimo/mcp/config.py +287 -0
  297. apsimo/mcp/server.py +501 -0
  298. apsimo/migrations.py +187 -0
  299. apsimo/mining/__init__.py +27 -0
  300. apsimo/mining/corpus.py +239 -0
  301. apsimo/mining/escalations.py +289 -0
  302. apsimo/mining/models.py +169 -0
  303. apsimo/mining/store.py +210 -0
  304. apsimo/models/__init__.py +30 -0
  305. apsimo/models/memory.py +80 -0
  306. apsimo/models/mesh.py +72 -0
  307. apsimo/models/person.py +104 -0
  308. apsimo/models/signal.py +108 -0
  309. apsimo/observations/__init__.py +15 -0
  310. apsimo/observations/store.py +277 -0
  311. apsimo/patterns/__init__.py +6 -0
  312. apsimo/patterns/extract.py +187 -0
  313. apsimo/patterns/store.py +227 -0
  314. apsimo/persona/__init__.py +1 -0
  315. apsimo/persona/engine.py +611 -0
  316. apsimo/persona/manifest.py +140 -0
  317. apsimo/projects/__init__.py +28 -0
  318. apsimo/projects/engine.py +1681 -0
  319. apsimo/projects/event_outbox.py +188 -0
  320. apsimo/projects/models.py +216 -0
  321. apsimo/projects/planner.py +181 -0
  322. apsimo/projects/store.py +1446 -0
  323. apsimo/proposals/__init__.py +12 -0
  324. apsimo/proposals/engine.py +114 -0
  325. apsimo/proposals/models.py +207 -0
  326. apsimo/qualification/__init__.py +1 -0
  327. apsimo/qualification/cases.py +75 -0
  328. apsimo/qualification/cli.py +51 -0
  329. apsimo/qualification/memory_cases.py +209 -0
  330. apsimo/qualification/records.py +92 -0
  331. apsimo/qualification/report.py +87 -0
  332. apsimo/qualification/runner.py +311 -0
  333. apsimo/qualification/structured_cases.py +131 -0
  334. apsimo/reasoning/__init__.py +13 -0
  335. apsimo/reasoning/executor.py +506 -0
  336. apsimo/reasoning/loop.py +373 -0
  337. apsimo/reasoning/native_tools/__init__.py +16 -0
  338. apsimo/reasoning/native_tools/calculate.py +141 -0
  339. apsimo/reasoning/native_tools/file_ops.py +150 -0
  340. apsimo/reasoning/native_tools/web_search.py +49 -0
  341. apsimo/reasoning/tool_policy.py +182 -0
  342. apsimo/redact/__init__.py +176 -0
  343. apsimo/repos/__init__.py +5 -0
  344. apsimo/repos/mirrors.py +204 -0
  345. apsimo/research/__init__.py +41 -0
  346. apsimo/research/artifact.py +482 -0
  347. apsimo/research/gatherer.py +387 -0
  348. apsimo/research/pipeline.py +513 -0
  349. apsimo/research/search/__init__.py +7 -0
  350. apsimo/research/search/base.py +41 -0
  351. apsimo/research/search/brave.py +59 -0
  352. apsimo/research/search/cache.py +51 -0
  353. apsimo/research/search/duckduckgo.py +103 -0
  354. apsimo/research/search/orchestrator.py +119 -0
  355. apsimo/research/search/serpapi.py +59 -0
  356. apsimo/research/search/tavily.py +59 -0
  357. apsimo/research/synthesizer.py +309 -0
  358. apsimo/router/__init__.py +30 -0
  359. apsimo/router/complexity_scorer.py +148 -0
  360. apsimo/router/endpoints.py +153 -0
  361. apsimo/router/fallback.py +58 -0
  362. apsimo/router/functions.py +243 -0
  363. apsimo/router/native_policy.py +52 -0
  364. apsimo/router/router.py +762 -0
  365. apsimo/router/self_learning.py +174 -0
  366. apsimo/router/tiers.py +677 -0
  367. apsimo/sandbox/__init__.py +21 -0
  368. apsimo/sandbox/backend.py +195 -0
  369. apsimo/sandbox/manager.py +173 -0
  370. apsimo/scope_bounds.py +7 -0
  371. apsimo/secrets/__init__.py +6 -0
  372. apsimo/secrets/backends/__init__.py +8 -0
  373. apsimo/secrets/backends/base.py +42 -0
  374. apsimo/secrets/backends/env.py +110 -0
  375. apsimo/secrets/backends/keyring.py +72 -0
  376. apsimo/secrets/backends/onepassword.py +232 -0
  377. apsimo/secrets/cli.py +191 -0
  378. apsimo/secrets/manager.py +160 -0
  379. apsimo/secrets/migration.py +101 -0
  380. apsimo/secrets/types.py +98 -0
  381. apsimo/seed.py +41 -0
  382. apsimo/self_model/__init__.py +37 -0
  383. apsimo/self_model/appraisals.py +673 -0
  384. apsimo/self_model/benchmark.py +1314 -0
  385. apsimo/self_model/brief.py +40 -0
  386. apsimo/self_model/event_concerns.py +1128 -0
  387. apsimo/self_model/execution_forecasts.py +353 -0
  388. apsimo/self_model/expectations.py +1595 -0
  389. apsimo/self_model/experiments.py +1150 -0
  390. apsimo/self_model/journal.py +148 -0
  391. apsimo/self_model/judgments.py +705 -0
  392. apsimo/self_model/native_outcomes.py +55 -0
  393. apsimo/self_model/params.py +220 -0
  394. apsimo/self_model/perspective.py +246 -0
  395. apsimo/self_model/reconcile.py +183 -0
  396. apsimo/self_model/reply_forecasts.py +381 -0
  397. apsimo/self_model/runtime_forecasts.py +296 -0
  398. apsimo/self_model/runtime_models.py +67 -0
  399. apsimo/self_model/settlement.py +207 -0
  400. apsimo/self_model/situation.py +1731 -0
  401. apsimo/self_model/store.py +883 -0
  402. apsimo/self_model/supervised.py +137 -0
  403. apsimo/self_model/thinker.py +99 -0
  404. apsimo/self_model/trust.py +388 -0
  405. apsimo/self_model/workspace.py +2388 -0
  406. apsimo/server.py +4197 -0
  407. apsimo/services/__init__.py +1 -0
  408. apsimo/services/agent_bridge.py +474 -0
  409. apsimo/services/initiative_executor.py +914 -0
  410. apsimo/services/instance.py +297 -0
  411. apsimo/sessions/__init__.py +22 -0
  412. apsimo/sessions/config.py +13 -0
  413. apsimo/sessions/context_loader.py +88 -0
  414. apsimo/sessions/federation_session.py +75 -0
  415. apsimo/sessions/isolated_session.py +98 -0
  416. apsimo/sessions/reports.py +84 -0
  417. apsimo/sessions/store.py +148 -0
  418. apsimo/setup.py +2818 -0
  419. apsimo/setup_hermes.py +879 -0
  420. apsimo/setup_local_work.py +218 -0
  421. apsimo/setup_native_goals.py +134 -0
  422. apsimo/setup_native_reviews.py +115 -0
  423. apsimo/skills/__init__.py +10 -0
  424. apsimo/skills/base.py +108 -0
  425. apsimo/skills/budget.py +28 -0
  426. apsimo/skills/executor.py +493 -0
  427. apsimo/skills/executors/__init__.py +1 -0
  428. apsimo/skills/executors/behavioral_correction.py +75 -0
  429. apsimo/skills/executors/capability_gap.py +38 -0
  430. apsimo/skills/executors/data_quality.py +163 -0
  431. apsimo/skills/executors/knowledge_acquisition.py +41 -0
  432. apsimo/skills/executors/operational_hygiene.py +185 -0
  433. apsimo/skills/executors/subsystem_health.py +169 -0
  434. apsimo/skills/hermes_export.py +431 -0
  435. apsimo/skills/index.py +123 -0
  436. apsimo/skills/learning/__init__.py +21 -0
  437. apsimo/skills/learning/novelty_detector.py +206 -0
  438. apsimo/skills/learning/pattern_extractor.py +199 -0
  439. apsimo/skills/learning/triggers.py +159 -0
  440. apsimo/skills/loader.py +246 -0
  441. apsimo/skills/migrations/002_progressive_loading.sql +6 -0
  442. apsimo/skills/migrations/backfill_triggers.py +20 -0
  443. apsimo/skills/models.py +202 -0
  444. apsimo/skills/packager.py +128 -0
  445. apsimo/skills/protocols.py +70 -0
  446. apsimo/skills/registry.py +191 -0
  447. apsimo/skills/runtime.py +58 -0
  448. apsimo/skills/sandbox_runner.py +229 -0
  449. apsimo/skills/scheduler.py +129 -0
  450. apsimo/skills/schema.py +79 -0
  451. apsimo/skills/security/__init__.py +12 -0
  452. apsimo/skills/security/guards.py +53 -0
  453. apsimo/skills/security/scanner.py +223 -0
  454. apsimo/skills_memory/__init__.py +26 -0
  455. apsimo/skills_memory/distill.py +159 -0
  456. apsimo/skills_memory/models.py +85 -0
  457. apsimo/skills_memory/retrieve.py +62 -0
  458. apsimo/skills_memory/store.py +172 -0
  459. apsimo/surprise/__init__.py +6 -0
  460. apsimo/surprise/accumulation.py +57 -0
  461. apsimo/surprise/scorer.py +102 -0
  462. apsimo/surprise/store.py +203 -0
  463. apsimo/task_queue/__init__.py +69 -0
  464. apsimo/task_queue/action_receipts.py +148 -0
  465. apsimo/task_queue/approval_relay_canary.py +108 -0
  466. apsimo/task_queue/config.py +85 -0
  467. apsimo/task_queue/contract.py +361 -0
  468. apsimo/task_queue/events.py +130 -0
  469. apsimo/task_queue/governor.py +1031 -0
  470. apsimo/task_queue/handlers/__init__.py +16 -0
  471. apsimo/task_queue/handlers/base.py +37 -0
  472. apsimo/task_queue/handlers/inference.py +640 -0
  473. apsimo/task_queue/handlers/monitoring.py +116 -0
  474. apsimo/task_queue/handlers/registry.py +75 -0
  475. apsimo/task_queue/handlers/subtask_handler.py +173 -0
  476. apsimo/task_queue/handlers/system_maintenance.py +147 -0
  477. apsimo/task_queue/mesh_integration.py +111 -0
  478. apsimo/task_queue/models.py +317 -0
  479. apsimo/task_queue/queue_manager.py +8286 -0
  480. apsimo/task_queue/routing.py +287 -0
  481. apsimo/task_queue/scheduler.py +252 -0
  482. apsimo/task_queue/schema.sql +197 -0
  483. apsimo/task_queue/work_control.py +342 -0
  484. apsimo/task_queue/worker.py +993 -0
  485. apsimo/telemetry.py +145 -0
  486. apsimo/tom/__init__.py +6 -0
  487. apsimo/tom/affect.py +387 -0
  488. apsimo/tom/approvals.py +171 -0
  489. apsimo/tom/arcs.py +896 -0
  490. apsimo/tom/asymmetry.py +131 -0
  491. apsimo/tom/eligibility.py +248 -0
  492. apsimo/tom/engagement.py +214 -0
  493. apsimo/tom/exposure.py +214 -0
  494. apsimo/tom/extractor.py +306 -0
  495. apsimo/tom/fact_adapters.py +144 -0
  496. apsimo/tom/facts.py +326 -0
  497. apsimo/tom/integration.py +592 -0
  498. apsimo/tom/leveled.py +118 -0
  499. apsimo/tom/levels.py +247 -0
  500. apsimo/tom/recipient_audit.py +995 -0
  501. apsimo/tom/recipient_simulator.py +593 -0
  502. apsimo/tom/source_lineage.py +93 -0
  503. apsimo/tom/tom2.py +277 -0
  504. apsimo/tom/visibility.py +559 -0
  505. apsimo/tom/visibility_store.py +414 -0
  506. apsimo/tools/__init__.py +0 -0
  507. apsimo/tools/definitions.py +740 -0
  508. apsimo/tools/handlers.py +943 -0
  509. apsimo/toolsmith/__init__.py +26 -0
  510. apsimo/toolsmith/authority.py +166 -0
  511. apsimo/toolsmith/engine.py +559 -0
  512. apsimo/toolsmith/integrity.py +100 -0
  513. apsimo/toolsmith/miner.py +145 -0
  514. apsimo/toolsmith/policy.py +110 -0
  515. apsimo/toolsmith/registry.py +635 -0
  516. apsimo/turns/__init__.py +17 -0
  517. apsimo/turns/audio.py +134 -0
  518. apsimo/turns/documents.py +235 -0
  519. apsimo/turns/executions.py +486 -0
  520. apsimo/turns/hermes_history.py +245 -0
  521. apsimo/turns/hermes_kanban.py +268 -0
  522. apsimo/turns/hermes_work.py +96 -0
  523. apsimo/turns/idempotency.py +752 -0
  524. apsimo/turns/local_work.py +115 -0
  525. apsimo/turns/media.py +581 -0
  526. apsimo/turns/reported_workers.py +196 -0
  527. apsimo/turns/source_annotations.py +283 -0
  528. apsimo/turns/source_attribution.py +154 -0
  529. apsimo/turns/source_read.py +351 -0
  530. apsimo/turns/source_vectors.py +263 -0
  531. apsimo/turns/video.py +210 -0
  532. apsimo/util/autonomy_preset.py +220 -0
  533. apsimo/util/instance.py +92 -0
  534. apsimo/util/model_output.py +25 -0
  535. apsimo/util/quiet_hours.py +27 -0
  536. apsimo/util/session_safety.py +37 -0
  537. apsimo/util/temporal.py +343 -0
  538. apsimo/vector/__init__.py +75 -0
  539. apsimo/vector/backfill.py +171 -0
  540. apsimo/vector/caption.py +114 -0
  541. apsimo/vector/collections.py +51 -0
  542. apsimo/vector/config.py +102 -0
  543. apsimo/vector/embedder.py +670 -0
  544. apsimo/vector/image_preprocess.py +406 -0
  545. apsimo/vector/image_store.py +296 -0
  546. apsimo/vector/indexes.py +162 -0
  547. apsimo/vector/migrate.py +334 -0
  548. apsimo/vector/multimodal_provider.py +417 -0
  549. apsimo/vector/multimodal_types.py +87 -0
  550. apsimo/vector/openai_provider.py +119 -0
  551. apsimo/vector/query.py +49 -0
  552. apsimo/vector/reranker.py +565 -0
  553. apsimo/vector/safety_image.py +159 -0
  554. apsimo/vector/scanner.py +197 -0
  555. apsimo/vector/setup.py +289 -0
  556. apsimo/vector/store.py +533 -0
  557. apsimo/vector/tiers.py +263 -0
  558. apsimo/work_orders.py +925 -0
  559. apsimo/workers/__init__.py +21 -0
  560. apsimo/workers/agent_bridge.py +640 -0
  561. apsimo/workers/colony_worker.py +382 -0
  562. apsimo/workers/queue_worker.py +441 -0
  563. apsimo/workers/skills_sync.py +152 -0
  564. apsimo/world_model/__init__.py +71 -0
  565. apsimo/world_model/causal_maintenance.py +131 -0
  566. apsimo/world_model/causal_policy.py +43 -0
  567. apsimo/world_model/causal_query.py +125 -0
  568. apsimo/world_model/confidence.py +54 -0
  569. apsimo/world_model/config.py +64 -0
  570. apsimo/world_model/constants.py +97 -0
  571. apsimo/world_model/entities.py +145 -0
  572. apsimo/world_model/expectation_resolvers.py +177 -0
  573. apsimo/world_model/extraction/__init__.py +7 -0
  574. apsimo/world_model/extraction/base.py +62 -0
  575. apsimo/world_model/extraction/conversation_extractor.py +262 -0
  576. apsimo/world_model/extraction/detector.py +74 -0
  577. apsimo/world_model/extraction/document_extractor.py +78 -0
  578. apsimo/world_model/extraction/formats/__init__.py +24 -0
  579. apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
  580. apsimo/world_model/extraction/formats/html_fmt.py +72 -0
  581. apsimo/world_model/extraction/formats/json_fmt.py +68 -0
  582. apsimo/world_model/extraction/formats/pdf.py +43 -0
  583. apsimo/world_model/extraction/formats/text.py +27 -0
  584. apsimo/world_model/extraction/llm_extractor.py +164 -0
  585. apsimo/world_model/extraction/pipeline.py +73 -0
  586. apsimo/world_model/integrations/__init__.py +5 -0
  587. apsimo/world_model/integrations/mind_model_bridge.py +115 -0
  588. apsimo/world_model/integrations/social_intel_bridge.py +120 -0
  589. apsimo/world_model/jobs/__init__.py +4 -0
  590. apsimo/world_model/jobs/extraction_job.py +168 -0
  591. apsimo/world_model/llm_extract.py +572 -0
  592. apsimo/world_model/neo4j/__init__.py +5 -0
  593. apsimo/world_model/neo4j/backend.py +654 -0
  594. apsimo/world_model/observations.py +155 -0
  595. apsimo/world_model/populator.py +307 -0
  596. apsimo/world_model/postgres/__init__.py +1 -0
  597. apsimo/world_model/postgres/backend.py +683 -0
  598. apsimo/world_model/relationships.py +25 -0
  599. apsimo/world_model/resolution/__init__.py +13 -0
  600. apsimo/world_model/resolution/entity_resolver.py +232 -0
  601. apsimo/world_model/resolution/merge_audit.py +16 -0
  602. apsimo/world_model/resolution/merge_workflow.py +117 -0
  603. apsimo/world_model/source_reports.py +121 -0
  604. apsimo/world_model/sqlite/__init__.py +4 -0
  605. apsimo/world_model/sqlite/backend.py +855 -0
  606. apsimo/world_model/sqlite/schema.sql +132 -0
  607. apsimo/world_model/store.py +545 -0
  608. apsimo-1.3.0.dist-info/METADATA +78 -0
  609. apsimo-1.3.0.dist-info/RECORD +614 -0
  610. apsimo-1.3.0.dist-info/WHEEL +5 -0
  611. apsimo-1.3.0.dist-info/entry_points.txt +11 -0
  612. apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
  613. apsimo-1.3.0.dist-info/top_level.txt +2 -0
  614. colony_sidecar/__init__.py +4 -0
@@ -0,0 +1,1822 @@
1
+ """Durable periodic scheduler for Colony autonomy subsystems.
2
+
3
+ The schedules table remains compatible with the original scheduler. Additive
4
+ lease, attempt, and receipt state makes one due claim atomic across processes
5
+ and makes every terminal transition auditable without treating a callback
6
+ failure as a successful run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from contextlib import contextmanager
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timedelta, timezone
15
+ import hashlib
16
+ import inspect
17
+ import json
18
+ import logging
19
+ import math
20
+ from pathlib import Path
21
+ import re
22
+ import sqlite3
23
+ import types
24
+ import uuid
25
+ from typing import Any, Callable, Coroutine, Dict, Iterator, List, Mapping, Optional, Union
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ #: Consecutive failures at which a schedule counts against the scheduler's
30
+ #: `healthy` verdict (failure_count resets to 0 on any real success).
31
+ SUSTAINED_FAILURE_THRESHOLD = 3
32
+
33
+ _TASK_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
34
+ _METADATA_KEY = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
35
+ _MAX_INTERVAL_SECONDS = 31_536_000
36
+ _MAX_METADATA_FIELDS = 32
37
+ _MAX_METADATA_BYTES = 8192
38
+ _MAX_RESULT_NODES = 256
39
+ _MAX_RESULT_ITEMS = 64
40
+ _MAX_RESULT_DEPTH = 8
41
+ _MAX_RESULT_TEXT_CHARS = 65_536
42
+ _MAX_RESULT_INTEGER_BITS = 4096
43
+ _MAX_RESULT_SUMMARY_CHARS = 2000
44
+ _MAX_RESULT_DESCRIPTOR_CHARS = 4096
45
+ _SCHEDULE_NAMESPACE = uuid.UUID("9fa9f892-fc4b-5ef4-aa25-b45d740d157c")
46
+ _TYPE_NAME_DESCRIPTOR = type.__dict__["__name__"]
47
+ _TYPE_MRO_DESCRIPTOR = type.__dict__["__mro__"]
48
+ _TYPE_DICT_DESCRIPTOR = type.__dict__["__dict__"]
49
+ _MAPPING_PROXY_TYPE = type(type.__dict__)
50
+ _MAPPING_PROXY_ITEMS = _MAPPING_PROXY_TYPE.__dict__["items"]
51
+ _GENERATOR_CODE_DESCRIPTOR = types.GeneratorType.__dict__["gi_code"]
52
+ _CODE_FLAGS_DESCRIPTOR = types.CodeType.__dict__["co_flags"]
53
+
54
+
55
+ def _utc(value: datetime) -> datetime:
56
+ if not isinstance(value, datetime) or value.tzinfo is None:
57
+ raise ValueError("scheduler clock must return a timezone-aware datetime")
58
+ return value.astimezone(timezone.utc)
59
+
60
+
61
+ def _iso(value: datetime) -> str:
62
+ return _utc(value).isoformat()
63
+
64
+
65
+ def _parse_time(value: Optional[str]) -> Optional[datetime]:
66
+ if not value:
67
+ return None
68
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
69
+ if parsed.tzinfo is None:
70
+ parsed = parsed.replace(tzinfo=timezone.utc)
71
+ return parsed.astimezone(timezone.utc)
72
+
73
+
74
+ def _validate_name(name: Any) -> str:
75
+ if not isinstance(name, str) or not _TASK_NAME.fullmatch(name):
76
+ raise ValueError("task name must be 1-128 safe characters")
77
+ return name
78
+
79
+
80
+ def _validate_interval(value: Any) -> int:
81
+ if (
82
+ isinstance(value, bool)
83
+ or not isinstance(value, int)
84
+ or not 1 <= value <= _MAX_INTERVAL_SECONDS
85
+ ):
86
+ raise ValueError(
87
+ f"interval_seconds must be an integer from 1-{_MAX_INTERVAL_SECONDS}"
88
+ )
89
+ return value
90
+
91
+
92
+ def _metadata_scalar(value: Any, *, field: str) -> Any:
93
+ if value is None or isinstance(value, bool):
94
+ return value
95
+ if isinstance(value, int):
96
+ if abs(value) > 9_007_199_254_740_991:
97
+ raise ValueError(f"metadata.{field} integer is outside bounds")
98
+ return value
99
+ if isinstance(value, float):
100
+ if not math.isfinite(value):
101
+ raise ValueError(f"metadata.{field} must be finite")
102
+ return value
103
+ if isinstance(value, str):
104
+ normalized = " ".join(value.split()).strip()
105
+ if len(normalized) > 500:
106
+ raise ValueError(f"metadata.{field} exceeds 500 characters")
107
+ return normalized
108
+ raise ValueError(f"metadata.{field} must be a scalar or scalar array")
109
+
110
+
111
+ def _canonical_metadata(value: Optional[Mapping[str, Any]]) -> tuple[Dict[str, Any], str]:
112
+ if value is None:
113
+ value = {}
114
+ if not isinstance(value, Mapping):
115
+ raise ValueError("metadata must be one flat object")
116
+ if len(value) > _MAX_METADATA_FIELDS:
117
+ raise ValueError(f"metadata exceeds {_MAX_METADATA_FIELDS} fields")
118
+ normalized: Dict[str, Any] = {}
119
+ for raw_key, raw_value in value.items():
120
+ if not isinstance(raw_key, str) or not _METADATA_KEY.fullmatch(raw_key):
121
+ raise ValueError("metadata contains an invalid key")
122
+ if isinstance(raw_value, list):
123
+ if len(raw_value) > 16:
124
+ raise ValueError(f"metadata.{raw_key} exceeds 16 list items")
125
+ normalized[raw_key] = [
126
+ _metadata_scalar(item, field=raw_key) for item in raw_value
127
+ ]
128
+ else:
129
+ normalized[raw_key] = _metadata_scalar(raw_value, field=raw_key)
130
+ encoded = json.dumps(
131
+ normalized,
132
+ ensure_ascii=False,
133
+ separators=(",", ":"),
134
+ sort_keys=True,
135
+ allow_nan=False,
136
+ )
137
+ if len(encoded.encode("utf-8")) > _MAX_METADATA_BYTES:
138
+ raise ValueError(f"metadata exceeds {_MAX_METADATA_BYTES} bytes")
139
+ return normalized, encoded
140
+
141
+
142
+ def _safe_text(value: Any, maximum: int) -> str:
143
+ """Bound exact primitive text without dispatching to user conversions."""
144
+ value_type = type(value)
145
+ if value_type is str:
146
+ text = value
147
+ elif value is None:
148
+ text = "null"
149
+ elif value_type is bool:
150
+ text = "true" if value else "false"
151
+ elif value_type is int and int.bit_length(value) <= _MAX_RESULT_INTEGER_BITS:
152
+ text = json.dumps(value)
153
+ elif value_type is float and math.isfinite(value):
154
+ text = json.dumps(value, allow_nan=False)
155
+ else:
156
+ text = f"<{_safe_type_name(value)}>"
157
+ bounded = text[:maximum]
158
+ return bounded.encode("utf-8", "replace").decode("utf-8")
159
+
160
+
161
+ def _safe_type_name(value: Any) -> str:
162
+ """Return a bounded type label without invoking a custom metaclass hook."""
163
+ try:
164
+ # Cache and call the built-in getset descriptor itself. Going through
165
+ # either ``type(value).__name__`` or ``type.__getattribute__`` still
166
+ # honors a hostile metaclass data descriptor named ``__name__``.
167
+ name = _TYPE_NAME_DESCRIPTOR.__get__(type(value), type)
168
+ except BaseException:
169
+ return "unknown"
170
+ if type(name) is not str:
171
+ return "unknown"
172
+ bounded = name[:128]
173
+ cleaned = "".join(
174
+ character
175
+ if character.isascii() and (character.isalnum() or character in "._-")
176
+ else "_"
177
+ for character in bounded
178
+ )
179
+ return cleaned or "unknown"
180
+
181
+
182
+ def _is_hook_free_awaitable(value: Any) -> bool:
183
+ """Classify real awaitables without generic ABC/metaclass dispatch."""
184
+ value_type = type(value)
185
+ try:
186
+ if value_type is types.GeneratorType:
187
+ code = _GENERATOR_CODE_DESCRIPTOR.__get__(
188
+ value, types.GeneratorType,
189
+ )
190
+ flags = _CODE_FLAGS_DESCRIPTOR.__get__(code, types.CodeType)
191
+ if (
192
+ type(flags) is int
193
+ and flags & inspect.CO_ITERABLE_COROUTINE
194
+ ):
195
+ return True
196
+
197
+ # Python's special-method lookup reads the actual MRO dictionaries;
198
+ # it does not consult metaclass attributes. Reproduce that lookup via
199
+ # cached built-in descriptors so hostile ``__mro__``/``__dict__``
200
+ # properties and ABC ``__subclasshook__`` code never execute.
201
+ hierarchy = _TYPE_MRO_DESCRIPTOR.__get__(value_type, type)
202
+ if type(hierarchy) is not tuple:
203
+ return False
204
+ for base_index in range(tuple.__len__(hierarchy)):
205
+ base = tuple.__getitem__(hierarchy, base_index)
206
+ namespace = _TYPE_DICT_DESCRIPTOR.__get__(base, type)
207
+ if type(namespace) is not _MAPPING_PROXY_TYPE:
208
+ return False
209
+ await_method = None
210
+ found_await_method = False
211
+ for key, method in _MAPPING_PROXY_ITEMS(namespace):
212
+ # A metaclass can inject a ``str`` subclass as a namespace
213
+ # key. It may compare equal to ``__await__`` regardless of its
214
+ # visible contents, and continuing into a base would let the
215
+ # eventual ``await`` execute that equality hook. Iterating the
216
+ # built-in mapping proxy exposes key/value pairs without any
217
+ # lookup or descriptor dispatch, so reject the whole MRO as
218
+ # soon as any non-exact namespace key is observed.
219
+ if type(key) is not str:
220
+ return False
221
+ if key == "__await__":
222
+ found_await_method = True
223
+ await_method = method
224
+ if found_await_method:
225
+ # Match ``inspect.isawaitable`` compatibility: an exact first
226
+ # MRO definition of ``None`` explicitly disables awaiting.
227
+ return await_method is not None
228
+ except BaseException:
229
+ return False
230
+ return False
231
+
232
+
233
+ class _BoundedText:
234
+ """A small append-only text sink that never exceeds its character budget."""
235
+
236
+ __slots__ = ("_maximum", "_parts", "_size", "truncated")
237
+
238
+ def __init__(self, maximum: int) -> None:
239
+ self._maximum = maximum
240
+ self._parts: List[str] = []
241
+ self._size = 0
242
+ self.truncated = False
243
+
244
+ def add(self, value: str) -> None:
245
+ remaining = self._maximum - self._size
246
+ if remaining <= 0:
247
+ self.truncated = True
248
+ return
249
+ if len(value) > remaining:
250
+ self._parts.append(value[:remaining])
251
+ self._size = self._maximum
252
+ self.truncated = True
253
+ return
254
+ self._parts.append(value)
255
+ self._size += len(value)
256
+
257
+ def render(self) -> str:
258
+ rendered = "".join(self._parts)
259
+ if not self.truncated:
260
+ return rendered
261
+ if not rendered:
262
+ return ""
263
+ return rendered[:-1] + "…"
264
+
265
+
266
+ def _safe_json_clone(
267
+ value: Any,
268
+ *,
269
+ depth: int,
270
+ budget: Dict[str, int],
271
+ active: set[int],
272
+ ) -> tuple[bool, Any, str]:
273
+ """Clone only exact built-in JSON values under fixed traversal budgets.
274
+
275
+ Exact-type checks are intentional: a list/dict/string subclass may replace
276
+ iteration, conversion, indexing, or length with arbitrary user code.
277
+ """
278
+ if budget["nodes"] <= 0 or depth > _MAX_RESULT_DEPTH:
279
+ return False, None, "oversized"
280
+ budget["nodes"] -= 1
281
+ value_type = type(value)
282
+
283
+ if value is None or value_type is bool:
284
+ return True, value, ""
285
+ if value_type is int:
286
+ if int.bit_length(value) > _MAX_RESULT_INTEGER_BITS:
287
+ return False, None, "oversized"
288
+ return True, value, ""
289
+ if value_type is float:
290
+ if not math.isfinite(value):
291
+ return False, None, "not_json"
292
+ return True, value, ""
293
+ if value_type is str:
294
+ length = str.__len__(value)
295
+ if length > budget["text"]:
296
+ return False, None, "oversized"
297
+ budget["text"] -= length
298
+ return True, value, ""
299
+
300
+ if value_type not in (dict, list, tuple):
301
+ return False, None, "not_json"
302
+ identity = id(value)
303
+ if identity in active:
304
+ return False, None, "not_json"
305
+ active.add(identity)
306
+ try:
307
+ if value_type is dict:
308
+ length = dict.__len__(value)
309
+ if length > _MAX_RESULT_ITEMS or length > budget["nodes"]:
310
+ return False, None, "oversized"
311
+ entries = list(dict.items(value))
312
+ if any(type(key) is not str for key, _item in entries):
313
+ return False, None, "not_json"
314
+ entries.sort(key=lambda entry: entry[0])
315
+ cloned: Dict[str, Any] = {}
316
+ for key, item in entries:
317
+ key_length = str.__len__(key)
318
+ if key_length > budget["text"]:
319
+ return False, None, "oversized"
320
+ budget["text"] -= key_length
321
+ ok, projected, reason = _safe_json_clone(
322
+ item, depth=depth + 1, budget=budget, active=active,
323
+ )
324
+ if not ok:
325
+ return False, None, reason
326
+ cloned[key] = projected
327
+ return True, cloned, ""
328
+
329
+ length = (
330
+ list.__len__(value) if value_type is list else tuple.__len__(value)
331
+ )
332
+ if length > _MAX_RESULT_ITEMS or length > budget["nodes"]:
333
+ return False, None, "oversized"
334
+ projected_items: List[Any] = []
335
+ getter = list.__getitem__ if value_type is list else tuple.__getitem__
336
+ for index in range(length):
337
+ ok, projected, reason = _safe_json_clone(
338
+ getter(value, index),
339
+ depth=depth + 1,
340
+ budget=budget,
341
+ active=active,
342
+ )
343
+ if not ok:
344
+ return False, None, reason
345
+ projected_items.append(projected)
346
+ return True, projected_items, ""
347
+ finally:
348
+ active.remove(identity)
349
+
350
+
351
+ def _summary_token(
352
+ value: Any,
353
+ *,
354
+ maximum: int,
355
+ depth: int = 0,
356
+ active: Optional[set[int]] = None,
357
+ ) -> str:
358
+ writer = _BoundedText(maximum)
359
+ _write_summary(value, writer=writer, depth=depth, active=active or set())
360
+ return writer.render()
361
+
362
+
363
+ _BASE_EXCEPTION_ARGS = BaseException.__dict__["args"]
364
+
365
+
366
+ def _safe_exception_projection(error: BaseException) -> tuple[str, str]:
367
+ """Project exception identity/args without calling exception user hooks."""
368
+ error_type = _safe_type_name(error)
369
+ try:
370
+ arguments = _BASE_EXCEPTION_ARGS.__get__(error, type(error))
371
+ except BaseException:
372
+ arguments = ()
373
+ if type(arguments) is not tuple:
374
+ return error_type, f"<{error_type} raised>"
375
+ if tuple.__len__(arguments) == 1:
376
+ first = tuple.__getitem__(arguments, 0)
377
+ if type(first) is str:
378
+ return error_type, _safe_text(first, 1000)
379
+ return error_type, _summary_token(first, maximum=1000)
380
+ if not arguments:
381
+ return error_type, f"<{error_type} raised>"
382
+ return error_type, _summary_token(arguments, maximum=1000)
383
+
384
+
385
+ def _shallow_summary_token(value: Any, *, maximum: int) -> str:
386
+ writer = _BoundedText(maximum)
387
+ value_type = type(value)
388
+ type_name = _safe_type_name(value)
389
+ if value is None:
390
+ writer.add("null")
391
+ elif value_type is bool:
392
+ writer.add("true" if value else "false")
393
+ elif value_type is int:
394
+ bits = int.bit_length(value)
395
+ if bits <= _MAX_RESULT_INTEGER_BITS:
396
+ writer.add(json.dumps(value, allow_nan=False))
397
+ else:
398
+ sign = "negative" if value < 0 else "positive"
399
+ writer.add(f"<int bits={bits} sign={sign}>")
400
+ elif value_type is float:
401
+ if math.isfinite(value):
402
+ writer.add(json.dumps(value, allow_nan=False))
403
+ elif math.isnan(value):
404
+ writer.add("<float nan>")
405
+ elif value < 0:
406
+ writer.add("<float -infinity>")
407
+ else:
408
+ writer.add("<float infinity>")
409
+ elif value_type is str:
410
+ length = str.__len__(value)
411
+ prefix = str.__getitem__(value, slice(0, 512))
412
+ writer.add(json.dumps(prefix, ensure_ascii=False))
413
+ if length > 512:
414
+ writer.add(f"<chars={length}>")
415
+ elif value_type is bytes:
416
+ length = bytes.__len__(value)
417
+ writer.add("bytes:")
418
+ writer.add(bytes.hex(bytes.__getitem__(value, slice(0, 128))))
419
+ if length > 128:
420
+ writer.add(f"<bytes={length}>")
421
+ elif value_type is bytearray:
422
+ length = bytearray.__len__(value)
423
+ prefix = bytearray.__getitem__(value, slice(0, 128))
424
+ writer.add("bytearray:")
425
+ writer.add(bytearray.hex(prefix))
426
+ if length > 128:
427
+ writer.add(f"<bytes={length}>")
428
+ elif value_type is dict:
429
+ writer.add(f"<dict items={dict.__len__(value)}>")
430
+ elif value_type is list:
431
+ writer.add(f"<list items={list.__len__(value)}>")
432
+ elif value_type is tuple:
433
+ writer.add(f"<tuple items={tuple.__len__(value)}>")
434
+ elif value_type is set:
435
+ writer.add(f"<set items={set.__len__(value)}>")
436
+ elif value_type is frozenset:
437
+ writer.add(f"<frozenset items={frozenset.__len__(value)}>")
438
+ else:
439
+ writer.add(f"<{type_name} opaque>")
440
+ return writer.render()
441
+
442
+
443
+ def _write_summary(
444
+ value: Any,
445
+ *,
446
+ writer: _BoundedText,
447
+ depth: int,
448
+ active: set[int],
449
+ ) -> None:
450
+ """Write a deterministic diagnostic shape without calling user methods."""
451
+ value_type = type(value)
452
+ type_name = _safe_type_name(value)
453
+ if depth > _MAX_RESULT_DEPTH:
454
+ writer.add(f"<max-depth:{type_name}>")
455
+ return
456
+ if value_type not in (dict, list, tuple, set, frozenset):
457
+ writer.add(_shallow_summary_token(value, maximum=2000))
458
+ return
459
+
460
+ identity = id(value)
461
+ if identity in active:
462
+ writer.add(f"<cycle:{type_name}>")
463
+ return
464
+ active.add(identity)
465
+ try:
466
+ if value_type is dict:
467
+ length = dict.__len__(value)
468
+ if length > _MAX_RESULT_ITEMS:
469
+ writer.add(f"<dict items={length}>")
470
+ return
471
+ pairs = []
472
+ for key, item in dict.items(value):
473
+ key_token = _shallow_summary_token(key, maximum=256)
474
+ item_token = _shallow_summary_token(item, maximum=512)
475
+ pairs.append((key_token, item_token))
476
+ pairs.sort()
477
+ writer.add("{")
478
+ for index, (key_token, item_token) in enumerate(pairs):
479
+ if index:
480
+ writer.add(",")
481
+ writer.add(key_token)
482
+ writer.add(":")
483
+ writer.add(item_token)
484
+ writer.add("}")
485
+ return
486
+
487
+ if value_type in (list, tuple):
488
+ length = (
489
+ list.__len__(value)
490
+ if value_type is list
491
+ else tuple.__len__(value)
492
+ )
493
+ getter = list.__getitem__ if value_type is list else tuple.__getitem__
494
+ visible = min(length, _MAX_RESULT_ITEMS)
495
+ writer.add("[" if value_type is list else "tuple(")
496
+ for index in range(visible):
497
+ if index:
498
+ writer.add(",")
499
+ writer.add(_shallow_summary_token(
500
+ getter(value, index), maximum=512,
501
+ ))
502
+ if visible < length:
503
+ if visible:
504
+ writer.add(",")
505
+ writer.add(f"…<items={length}>")
506
+ writer.add("]" if value_type is list else ")")
507
+ return
508
+
509
+ length = (
510
+ set.__len__(value)
511
+ if value_type is set
512
+ else frozenset.__len__(value)
513
+ )
514
+ if length > _MAX_RESULT_ITEMS:
515
+ writer.add(f"<{type_name} items={length}>")
516
+ return
517
+ iterator = (
518
+ set.__iter__(value)
519
+ if value_type is set
520
+ else frozenset.__iter__(value)
521
+ )
522
+ tokens = sorted(
523
+ _shallow_summary_token(item, maximum=512)
524
+ for item in iterator
525
+ )
526
+ writer.add("set{" if value_type is set else "frozenset{")
527
+ for index, token in enumerate(tokens):
528
+ if index:
529
+ writer.add(",")
530
+ writer.add(token)
531
+ writer.add("}")
532
+ finally:
533
+ active.remove(identity)
534
+
535
+
536
+ def _render_receipt_projection(
537
+ *,
538
+ value_type: str,
539
+ reason: str,
540
+ summary: str,
541
+ descriptor: str,
542
+ ) -> str:
543
+ digest_payload = json.dumps(
544
+ [value_type, reason, descriptor],
545
+ ensure_ascii=True,
546
+ separators=(",", ":"),
547
+ )
548
+ envelope = {
549
+ "digest": hashlib.sha256(digest_payload.encode("ascii")).hexdigest(),
550
+ "digest_scope": "bounded-shape-v1",
551
+ "projection": "bounded",
552
+ "reason": reason,
553
+ "summary": "",
554
+ "type": value_type,
555
+ }
556
+
557
+ def render(summary_prefix: str) -> str:
558
+ envelope["summary"] = summary_prefix
559
+ return json.dumps(
560
+ envelope,
561
+ ensure_ascii=True,
562
+ separators=(",", ":"),
563
+ sort_keys=True,
564
+ )
565
+
566
+ encoded = render(summary)
567
+ if len(encoded) <= _MAX_METADATA_BYTES:
568
+ return encoded
569
+
570
+ # ASCII JSON makes character length the exact persisted byte length.
571
+ # Binary search retains the largest exact prefix; whitespace is never
572
+ # normalized or collapsed.
573
+ low, high = 0, len(summary)
574
+ bounded = render("")
575
+ while low <= high:
576
+ middle = (low + high) // 2
577
+ candidate = summary[:middle]
578
+ if middle < len(summary):
579
+ candidate += "…"
580
+ projected = render(candidate)
581
+ if len(projected) <= _MAX_METADATA_BYTES:
582
+ bounded = projected
583
+ low = middle + 1
584
+ else:
585
+ high = middle - 1
586
+ return bounded
587
+
588
+
589
+ def _bounded_receipt_json(value: Any, *, value_type: str) -> str:
590
+ budget = {
591
+ "nodes": _MAX_RESULT_NODES,
592
+ "text": _MAX_RESULT_TEXT_CHARS,
593
+ }
594
+ ok, safe_value, reason = _safe_json_clone(
595
+ value, depth=0, budget=budget, active=set(),
596
+ )
597
+ canonical: Optional[str] = None
598
+ if ok:
599
+ canonical = json.dumps(
600
+ safe_value,
601
+ ensure_ascii=True,
602
+ separators=(",", ":"),
603
+ sort_keys=True,
604
+ allow_nan=False,
605
+ )
606
+ if len(canonical) <= _MAX_METADATA_BYTES:
607
+ return canonical
608
+ reason = "oversized"
609
+
610
+ if canonical is not None:
611
+ descriptor = canonical
612
+ summary = canonical[:_MAX_RESULT_SUMMARY_CHARS]
613
+ else:
614
+ descriptor = _summary_token(
615
+ value, maximum=_MAX_RESULT_DESCRIPTOR_CHARS,
616
+ )
617
+ summary = descriptor[:_MAX_RESULT_SUMMARY_CHARS]
618
+ return _render_receipt_projection(
619
+ value_type=value_type,
620
+ reason=reason or "not_json",
621
+ summary=summary,
622
+ descriptor=descriptor,
623
+ )
624
+
625
+
626
+ def _receipt_json(value: Any) -> str:
627
+ """Project an arbitrary callback result into deterministic bounded JSON.
628
+
629
+ No operation in this function dispatches through a user-defined iterator,
630
+ string/repr conversion, container override, or metaclass attribute hook.
631
+ Concurrent mutation of an exact built-in container also degrades to an
632
+ opaque receipt instead of escaping and stranding the claimed attempt.
633
+ """
634
+ value_type = _safe_type_name(value)
635
+ try:
636
+ return _bounded_receipt_json(value, value_type=value_type)
637
+ except Exception:
638
+ descriptor = f"<{value_type} projection-unavailable>"
639
+ return _render_receipt_projection(
640
+ value_type=value_type,
641
+ reason="not_json",
642
+ summary=descriptor,
643
+ descriptor=descriptor,
644
+ )
645
+
646
+
647
+ class TaskSchedule:
648
+ """A persisted periodic task and its current scheduling state."""
649
+
650
+ def __init__(
651
+ self,
652
+ id: str,
653
+ name: str,
654
+ interval_seconds: int,
655
+ callback_name: str,
656
+ last_run: Optional[datetime] = None,
657
+ next_run: Optional[datetime] = None,
658
+ enabled: bool = True,
659
+ metadata: Optional[Dict[str, Any]] = None,
660
+ failure_count: int = 0,
661
+ lease_token: Optional[str] = None,
662
+ lease_expires_at: Optional[datetime] = None,
663
+ degraded_reason: Optional[str] = None,
664
+ created_at: Optional[datetime] = None,
665
+ updated_at: Optional[datetime] = None,
666
+ ) -> None:
667
+ self.id = id
668
+ self.name = name
669
+ self.interval_seconds = interval_seconds
670
+ self.callback_name = callback_name
671
+ self.last_run = last_run
672
+ self.next_run = next_run or datetime.now(timezone.utc)
673
+ self.enabled = enabled
674
+ self.metadata = metadata or {}
675
+ self.failure_count = max(0, int(failure_count))
676
+ self.lease_token = lease_token
677
+ self.lease_expires_at = lease_expires_at
678
+ self.degraded_reason = degraded_reason
679
+ self.created_at = created_at
680
+ self.updated_at = updated_at
681
+
682
+ def to_dict(self) -> dict:
683
+ return {
684
+ "id": self.id,
685
+ "name": self.name,
686
+ "interval_seconds": self.interval_seconds,
687
+ "callback_name": self.callback_name,
688
+ "last_run": self.last_run.isoformat() if self.last_run else None,
689
+ "next_run": self.next_run.isoformat() if self.next_run else None,
690
+ "enabled": self.enabled,
691
+ "metadata": self.metadata,
692
+ "failure_count": self.failure_count,
693
+ "leased": bool(self.lease_token),
694
+ "lease_expires_at": (
695
+ self.lease_expires_at.isoformat()
696
+ if self.lease_expires_at else None
697
+ ),
698
+ "degraded_reason": self.degraded_reason,
699
+ }
700
+
701
+
702
+ @dataclass(frozen=True)
703
+ class RunClaim:
704
+ schedule: TaskSchedule
705
+ attempt_id: str
706
+ lease_token: str
707
+ claimed_at: datetime
708
+ lease_expires_at: datetime
709
+
710
+
711
+ class ScheduleStore:
712
+ """SQLite schedule store with atomic cross-process due claims."""
713
+
714
+ def __init__(self, db_path: str, *, clock: Optional[Callable[[], datetime]] = None):
715
+ self._db_path = str(db_path)
716
+ self._clock = clock or (lambda: datetime.now(timezone.utc))
717
+ self._init_db()
718
+
719
+ def _now(self) -> datetime:
720
+ return _utc(self._clock())
721
+
722
+ @contextmanager
723
+ def _connect(self) -> Iterator[sqlite3.Connection]:
724
+ conn = sqlite3.connect(
725
+ self._db_path,
726
+ timeout=30.0,
727
+ isolation_level=None,
728
+ )
729
+ try:
730
+ conn.row_factory = sqlite3.Row
731
+ conn.execute("PRAGMA busy_timeout=30000")
732
+ conn.execute("PRAGMA foreign_keys=ON")
733
+ with conn:
734
+ yield conn
735
+ finally:
736
+ conn.close()
737
+
738
+ def _init_db(self) -> None:
739
+ Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
740
+ now = _iso(self._now())
741
+ with self._connect() as conn:
742
+ conn.execute("PRAGMA journal_mode=WAL")
743
+ conn.execute("BEGIN IMMEDIATE")
744
+ try:
745
+ conn.execute("""
746
+ CREATE TABLE IF NOT EXISTS schedules (
747
+ id TEXT PRIMARY KEY,
748
+ name TEXT NOT NULL,
749
+ interval_seconds INTEGER NOT NULL,
750
+ callback_name TEXT NOT NULL,
751
+ last_run TEXT,
752
+ next_run TEXT,
753
+ enabled INTEGER DEFAULT 1,
754
+ metadata TEXT DEFAULT '{}'
755
+ )
756
+ """)
757
+ columns = {
758
+ row[1] for row in conn.execute("PRAGMA table_info(schedules)")
759
+ }
760
+ additions = {
761
+ "failure_count": "INTEGER NOT NULL DEFAULT 0",
762
+ "lease_token": "TEXT",
763
+ "lease_expires_at": "TEXT",
764
+ "degraded_reason": "TEXT",
765
+ "created_at": "TEXT",
766
+ "updated_at": "TEXT",
767
+ }
768
+ for column, declaration in additions.items():
769
+ if column not in columns:
770
+ conn.execute(
771
+ f"ALTER TABLE schedules ADD COLUMN {column} {declaration}"
772
+ )
773
+ conn.execute(
774
+ "UPDATE schedules SET created_at=COALESCE(created_at, ?), "
775
+ "updated_at=COALESCE(updated_at, ?), failure_count="
776
+ "COALESCE(failure_count, 0), next_run=COALESCE(next_run, ?)",
777
+ (now, now, now),
778
+ )
779
+ conn.execute(
780
+ "UPDATE schedules SET lease_token=NULL, lease_expires_at=NULL "
781
+ "WHERE (lease_token IS NULL) != (lease_expires_at IS NULL)"
782
+ )
783
+ self._migrate_duplicate_names(conn)
784
+ self._migrate_legacy_rows(conn)
785
+ conn.execute(
786
+ "CREATE UNIQUE INDEX IF NOT EXISTS schedules_name_unique "
787
+ "ON schedules(name)"
788
+ )
789
+ conn.execute(
790
+ "CREATE INDEX IF NOT EXISTS schedules_due_idx ON schedules "
791
+ "(enabled, next_run, lease_expires_at)"
792
+ )
793
+ conn.execute("""
794
+ CREATE TABLE IF NOT EXISTS schedule_run_attempts (
795
+ attempt_id TEXT PRIMARY KEY,
796
+ schedule_id TEXT NOT NULL,
797
+ task_name TEXT NOT NULL,
798
+ callback_name TEXT NOT NULL,
799
+ lease_token TEXT NOT NULL UNIQUE,
800
+ claimed_at TEXT NOT NULL,
801
+ lease_expires_at TEXT NOT NULL,
802
+ scheduled_for TEXT NOT NULL,
803
+ failure_count_before INTEGER NOT NULL,
804
+ metadata_digest TEXT NOT NULL
805
+ )
806
+ """)
807
+ conn.execute("""
808
+ CREATE TABLE IF NOT EXISTS schedule_run_receipts (
809
+ receipt_id TEXT PRIMARY KEY,
810
+ attempt_id TEXT NOT NULL UNIQUE,
811
+ schedule_id TEXT NOT NULL,
812
+ task_name TEXT NOT NULL,
813
+ lease_token TEXT NOT NULL,
814
+ status TEXT NOT NULL,
815
+ finished_at TEXT NOT NULL,
816
+ result_json TEXT,
817
+ error_type TEXT,
818
+ error_message TEXT,
819
+ failure_count_after INTEGER NOT NULL,
820
+ next_run TEXT
821
+ )
822
+ """)
823
+ conn.execute(
824
+ "CREATE INDEX IF NOT EXISTS schedule_attempt_schedule_idx "
825
+ "ON schedule_run_attempts(schedule_id, claimed_at)"
826
+ )
827
+ conn.execute(
828
+ "CREATE INDEX IF NOT EXISTS schedule_receipt_schedule_idx "
829
+ "ON schedule_run_receipts(schedule_id, finished_at)"
830
+ )
831
+ for table in ("schedule_run_attempts", "schedule_run_receipts"):
832
+ conn.execute(f"""
833
+ CREATE TRIGGER IF NOT EXISTS {table}_no_update
834
+ BEFORE UPDATE ON {table}
835
+ BEGIN
836
+ SELECT RAISE(ABORT, '{table} append-only');
837
+ END
838
+ """)
839
+ conn.execute(f"""
840
+ CREATE TRIGGER IF NOT EXISTS {table}_no_delete
841
+ BEFORE DELETE ON {table}
842
+ BEGIN
843
+ SELECT RAISE(ABORT, '{table} append-only');
844
+ END
845
+ """)
846
+ conn.commit()
847
+ except Exception:
848
+ conn.rollback()
849
+ raise
850
+
851
+ @staticmethod
852
+ def _migrate_duplicate_names(conn: sqlite3.Connection) -> None:
853
+ duplicates = conn.execute(
854
+ "SELECT name FROM schedules GROUP BY name HAVING COUNT(*) > 1"
855
+ ).fetchall()
856
+ for duplicate in duplicates:
857
+ name = str(duplicate["name"])
858
+ rows = conn.execute(
859
+ "SELECT rowid,id FROM schedules WHERE name=? ORDER BY rowid,id",
860
+ (name,),
861
+ ).fetchall()
862
+ canonical_id = str(rows[0]["id"])
863
+ for index, row in enumerate(rows[1:], start=1):
864
+ digest = hashlib.sha256(str(row["id"]).encode()).hexdigest()[:12]
865
+ renamed = f"{name[:96]}#legacy-duplicate-{digest}-{index}"
866
+ conn.execute(
867
+ "UPDATE schedules SET name=?,enabled=0,degraded_reason=? "
868
+ "WHERE rowid=?",
869
+ (
870
+ renamed,
871
+ f"migration_duplicate_name:{canonical_id}"[:500],
872
+ row["rowid"],
873
+ ),
874
+ )
875
+
876
+ @staticmethod
877
+ def _migrate_legacy_rows(conn: sqlite3.Connection) -> None:
878
+ for row in conn.execute(
879
+ "SELECT id,name,interval_seconds,callback_name,metadata "
880
+ "FROM schedules"
881
+ ).fetchall():
882
+ reason: Optional[str] = None
883
+ if not _TASK_NAME.fullmatch(str(row["name"] or "")):
884
+ reason = "migration_invalid_task_name"
885
+ try:
886
+ _validate_interval(row["interval_seconds"])
887
+ except ValueError:
888
+ reason = reason or "migration_invalid_interval"
889
+ if not str(row["callback_name"] or "").strip():
890
+ reason = reason or "migration_invalid_callback_name"
891
+ try:
892
+ decoded = json.loads(str(row["metadata"] or "{}"))
893
+ _, encoded = _canonical_metadata(decoded)
894
+ except (TypeError, ValueError, json.JSONDecodeError):
895
+ reason = reason or "migration_invalid_metadata"
896
+ encoded = None
897
+ if encoded is not None:
898
+ conn.execute(
899
+ "UPDATE schedules SET metadata=? WHERE id=?",
900
+ (encoded, row["id"]),
901
+ )
902
+ if reason:
903
+ conn.execute("""
904
+ UPDATE schedules SET enabled=0,
905
+ degraded_reason=COALESCE(degraded_reason, ?)
906
+ WHERE id=?
907
+ """, (reason, row["id"]))
908
+
909
+ def register_schedule(
910
+ self,
911
+ *,
912
+ name: str,
913
+ interval_seconds: int,
914
+ callback_name: str,
915
+ metadata_json: str,
916
+ now: datetime,
917
+ ) -> TaskSchedule:
918
+ schedule_id = str(uuid.uuid5(
919
+ _SCHEDULE_NAMESPACE, f"colony-autonomy-schedule:{name}",
920
+ ))
921
+ stamp = _iso(now)
922
+ with self._connect() as conn:
923
+ conn.execute("BEGIN IMMEDIATE")
924
+ try:
925
+ existing = conn.execute(
926
+ "SELECT * FROM schedules WHERE name=?", (name,),
927
+ ).fetchone()
928
+ if existing is None:
929
+ conn.execute("""
930
+ INSERT INTO schedules
931
+ (id,name,interval_seconds,callback_name,last_run,next_run,
932
+ enabled,metadata,failure_count,lease_token,
933
+ lease_expires_at,degraded_reason,created_at,updated_at)
934
+ VALUES (?,?,?,?,NULL,?,1,?,0,NULL,NULL,NULL,?,?)
935
+ """, (
936
+ schedule_id, name, interval_seconds, callback_name,
937
+ stamp, metadata_json, stamp, stamp,
938
+ ))
939
+ else:
940
+ degraded = str(existing["degraded_reason"] or "")
941
+ recover_callback = (
942
+ degraded.startswith("callback_unregistered:")
943
+ or degraded in {
944
+ "migration_invalid_metadata",
945
+ "migration_invalid_interval",
946
+ "migration_invalid_callback_name",
947
+ }
948
+ )
949
+ conn.execute("""
950
+ UPDATE schedules SET interval_seconds=?,callback_name=?,
951
+ metadata=?,enabled=CASE WHEN ? THEN 1 ELSE enabled END,
952
+ degraded_reason=CASE WHEN ? THEN NULL
953
+ ELSE degraded_reason END,
954
+ updated_at=?
955
+ WHERE name=?
956
+ """, (
957
+ interval_seconds, callback_name, metadata_json,
958
+ 1 if recover_callback else 0,
959
+ 1 if recover_callback else 0,
960
+ stamp, name,
961
+ ))
962
+ row = conn.execute(
963
+ "SELECT * FROM schedules WHERE name=?", (name,),
964
+ ).fetchone()
965
+ conn.commit()
966
+ except Exception:
967
+ conn.rollback()
968
+ raise
969
+ return self._row_to_schedule(row)
970
+
971
+ def upsert(self, schedule: TaskSchedule) -> None:
972
+ """Persist compatibility configuration without rewriting run state.
973
+
974
+ Existing embedded callers may retain a ``TaskSchedule`` object while a
975
+ different scheduler process claims or completes it. On conflict only
976
+ caller-owned definition/configuration fields are updated. The store is
977
+ the sole owner of cadence, terminal state, failures, degradation, and
978
+ exact-token leases after initial insertion.
979
+ """
980
+ _, metadata_json = _canonical_metadata(schedule.metadata)
981
+ stamp = _iso(self._now())
982
+ with self._connect() as conn:
983
+ conn.execute("BEGIN IMMEDIATE")
984
+ try:
985
+ conn.execute("""
986
+ INSERT INTO schedules
987
+ (id,name,interval_seconds,callback_name,last_run,next_run,
988
+ enabled,metadata,failure_count,lease_token,lease_expires_at,
989
+ degraded_reason,created_at,updated_at)
990
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
991
+ ON CONFLICT(id) DO UPDATE SET
992
+ name=excluded.name,
993
+ interval_seconds=excluded.interval_seconds,
994
+ callback_name=excluded.callback_name,
995
+ enabled=excluded.enabled,
996
+ metadata=excluded.metadata,
997
+ updated_at=excluded.updated_at
998
+ """, (
999
+ schedule.id, schedule.name, schedule.interval_seconds,
1000
+ schedule.callback_name,
1001
+ _iso(schedule.last_run) if schedule.last_run else None,
1002
+ _iso(schedule.next_run), 1 if schedule.enabled else 0,
1003
+ metadata_json, schedule.failure_count, schedule.lease_token,
1004
+ _iso(schedule.lease_expires_at)
1005
+ if schedule.lease_expires_at else None,
1006
+ schedule.degraded_reason,
1007
+ _iso(schedule.created_at) if schedule.created_at else stamp,
1008
+ stamp,
1009
+ ))
1010
+ conn.commit()
1011
+ except Exception:
1012
+ conn.rollback()
1013
+ raise
1014
+
1015
+ def get_due(self, now: Optional[datetime] = None) -> List[TaskSchedule]:
1016
+ stamp = _iso(now or self._now())
1017
+ with self._connect() as conn:
1018
+ rows = conn.execute("""
1019
+ SELECT * FROM schedules
1020
+ WHERE enabled=1 AND next_run <= ?
1021
+ AND (lease_token IS NULL OR lease_expires_at <= ?)
1022
+ ORDER BY next_run,name
1023
+ """, (stamp, stamp)).fetchall()
1024
+ return [self._row_to_schedule(row) for row in rows]
1025
+
1026
+ def claim_one_due(
1027
+ self,
1028
+ now: datetime,
1029
+ *,
1030
+ lease_seconds: int,
1031
+ exclude_schedule_ids: Optional[set[str]] = None,
1032
+ ) -> Optional[RunClaim]:
1033
+ observed = _utc(now)
1034
+ stamp = _iso(observed)
1035
+ excluded = sorted(exclude_schedule_ids or set())
1036
+ exclusion_sql = ""
1037
+ parameters: List[Any] = [stamp, stamp]
1038
+ if excluded:
1039
+ exclusion_sql = " AND id NOT IN (" + ",".join("?" for _ in excluded) + ")"
1040
+ parameters.extend(excluded)
1041
+ with self._connect() as conn:
1042
+ conn.execute("BEGIN IMMEDIATE")
1043
+ try:
1044
+ row = conn.execute(
1045
+ "SELECT * FROM schedules WHERE enabled=1 "
1046
+ "AND next_run <= ? AND (lease_token IS NULL OR "
1047
+ "lease_expires_at <= ?)" + exclusion_sql
1048
+ + " ORDER BY next_run,name LIMIT 1",
1049
+ parameters,
1050
+ ).fetchone()
1051
+ if row is None:
1052
+ conn.commit()
1053
+ return None
1054
+
1055
+ failure_count = int(row["failure_count"] or 0)
1056
+ old_token = str(row["lease_token"] or "")
1057
+ if old_token:
1058
+ expired_attempt = conn.execute(
1059
+ "SELECT * FROM schedule_run_attempts WHERE lease_token=?",
1060
+ (old_token,),
1061
+ ).fetchone()
1062
+ if expired_attempt is not None:
1063
+ inserted = self._insert_receipt(
1064
+ conn,
1065
+ attempt=expired_attempt,
1066
+ status="lease_expired",
1067
+ now=observed,
1068
+ result_json=None,
1069
+ error_type="LeaseExpired",
1070
+ error_message="lease expired before terminal receipt",
1071
+ failure_count_after=failure_count + 1,
1072
+ next_run=_parse_time(row["next_run"]),
1073
+ )
1074
+ if inserted:
1075
+ failure_count += 1
1076
+
1077
+ lease_token = uuid.uuid4().hex
1078
+ attempt_id = str(uuid.uuid4())
1079
+ expires = observed + timedelta(seconds=lease_seconds)
1080
+ updated = conn.execute("""
1081
+ UPDATE schedules
1082
+ SET lease_token=?,lease_expires_at=?,failure_count=?,updated_at=?
1083
+ WHERE id=? AND enabled=1 AND next_run <= ?
1084
+ AND (lease_token IS NULL OR lease_expires_at <= ?)
1085
+ """, (
1086
+ lease_token, _iso(expires), failure_count, stamp,
1087
+ row["id"], stamp, stamp,
1088
+ ))
1089
+ if updated.rowcount != 1:
1090
+ conn.rollback()
1091
+ return None
1092
+ metadata_text = str(row["metadata"] or "{}")
1093
+ conn.execute("""
1094
+ INSERT INTO schedule_run_attempts
1095
+ (attempt_id,schedule_id,task_name,callback_name,lease_token,
1096
+ claimed_at,lease_expires_at,scheduled_for,
1097
+ failure_count_before,metadata_digest)
1098
+ VALUES (?,?,?,?,?,?,?,?,?,?)
1099
+ """, (
1100
+ attempt_id, row["id"], row["name"], row["callback_name"],
1101
+ lease_token, stamp, _iso(expires), row["next_run"],
1102
+ failure_count,
1103
+ hashlib.sha256(metadata_text.encode("utf-8")).hexdigest(),
1104
+ ))
1105
+ claimed_row = conn.execute(
1106
+ "SELECT * FROM schedules WHERE id=?", (row["id"],),
1107
+ ).fetchone()
1108
+ conn.commit()
1109
+ except Exception:
1110
+ conn.rollback()
1111
+ raise
1112
+ return RunClaim(
1113
+ schedule=self._row_to_schedule(claimed_row),
1114
+ attempt_id=attempt_id,
1115
+ lease_token=lease_token,
1116
+ claimed_at=observed,
1117
+ lease_expires_at=expires,
1118
+ )
1119
+
1120
+ @staticmethod
1121
+ def _insert_receipt(
1122
+ conn: sqlite3.Connection,
1123
+ *,
1124
+ attempt: sqlite3.Row,
1125
+ status: str,
1126
+ now: datetime,
1127
+ result_json: Optional[str],
1128
+ error_type: Optional[str],
1129
+ error_message: Optional[str],
1130
+ failure_count_after: int,
1131
+ next_run: Optional[datetime],
1132
+ ) -> bool:
1133
+ cursor = conn.execute("""
1134
+ INSERT OR IGNORE INTO schedule_run_receipts
1135
+ (receipt_id,attempt_id,schedule_id,task_name,lease_token,status,
1136
+ finished_at,result_json,error_type,error_message,
1137
+ failure_count_after,next_run)
1138
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
1139
+ """, (
1140
+ str(uuid.uuid4()), attempt["attempt_id"], attempt["schedule_id"],
1141
+ attempt["task_name"], attempt["lease_token"], status[:64],
1142
+ _iso(now), result_json,
1143
+ _safe_text(error_type, 128) if error_type else None,
1144
+ _safe_text(error_message, 1000) if error_message else None,
1145
+ max(0, int(failure_count_after)),
1146
+ _iso(next_run) if next_run else None,
1147
+ ))
1148
+ return cursor.rowcount == 1
1149
+
1150
+ def _completion_rows(
1151
+ self,
1152
+ conn: sqlite3.Connection,
1153
+ claim: RunClaim,
1154
+ now: datetime,
1155
+ ) -> tuple[Optional[sqlite3.Row], Optional[sqlite3.Row], bool]:
1156
+ schedule = conn.execute(
1157
+ "SELECT * FROM schedules WHERE id=?", (claim.schedule.id,),
1158
+ ).fetchone()
1159
+ attempt = conn.execute(
1160
+ "SELECT * FROM schedule_run_attempts WHERE attempt_id=?",
1161
+ (claim.attempt_id,),
1162
+ ).fetchone()
1163
+ owns = bool(
1164
+ schedule is not None
1165
+ and attempt is not None
1166
+ and schedule["lease_token"] == claim.lease_token
1167
+ and attempt["schedule_id"] == claim.schedule.id
1168
+ and attempt["lease_token"] == claim.lease_token
1169
+ and str(schedule["lease_expires_at"] or "") > _iso(now)
1170
+ )
1171
+ return schedule, attempt, owns
1172
+
1173
+ def _record_lost_or_expired(
1174
+ self,
1175
+ conn: sqlite3.Connection,
1176
+ claim: RunClaim,
1177
+ schedule: Optional[sqlite3.Row],
1178
+ attempt: Optional[sqlite3.Row],
1179
+ now: datetime,
1180
+ ) -> None:
1181
+ if (
1182
+ attempt is None
1183
+ or attempt["schedule_id"] != claim.schedule.id
1184
+ or attempt["lease_token"] != claim.lease_token
1185
+ ):
1186
+ return
1187
+ expired_while_owned = bool(
1188
+ schedule is not None
1189
+ and schedule["lease_token"] == claim.lease_token
1190
+ )
1191
+ failure_count = int(schedule["failure_count"] or 0) if schedule else 0
1192
+ status = "lease_expired" if expired_while_owned else "lease_lost"
1193
+ inserted = self._insert_receipt(
1194
+ conn,
1195
+ attempt=attempt,
1196
+ status=status,
1197
+ now=now,
1198
+ result_json=None,
1199
+ error_type="LeaseExpired" if expired_while_owned else "LeaseLost",
1200
+ error_message=(
1201
+ "lease expired before terminal receipt"
1202
+ if expired_while_owned
1203
+ else "exact lease token no longer owns schedule"
1204
+ ),
1205
+ failure_count_after=failure_count + (1 if expired_while_owned else 0),
1206
+ next_run=_parse_time(schedule["next_run"]) if schedule else None,
1207
+ )
1208
+ if expired_while_owned and inserted:
1209
+ conn.execute("""
1210
+ UPDATE schedules SET lease_token=NULL,lease_expires_at=NULL,
1211
+ failure_count=?,updated_at=?
1212
+ WHERE id=? AND lease_token=?
1213
+ """, (
1214
+ failure_count + 1, _iso(now), claim.schedule.id,
1215
+ claim.lease_token,
1216
+ ))
1217
+
1218
+ def _complete_success_with_projection(
1219
+ self,
1220
+ claim: RunClaim,
1221
+ result: Any,
1222
+ *,
1223
+ now: datetime,
1224
+ outcome: str = "success",
1225
+ ) -> tuple[bool, Any]:
1226
+ """Complete a claim whose callback returned normally.
1227
+
1228
+ ``outcome="skipped"`` records that the callback declined to do work:
1229
+ the schedule still advances and the lease is released, but the run is
1230
+ receipted as ``skipped`` and neither resets ``failure_count`` nor
1231
+ clears ``degraded_reason`` — a skip is not a success.
1232
+ """
1233
+ if outcome not in ("success", "skipped"):
1234
+ raise ValueError("outcome must be 'success' or 'skipped'")
1235
+ observed = _utc(now)
1236
+ # Projection is deliberately outside the SQLite writer transaction.
1237
+ # Even an unexpectedly expensive diagnostic value cannot hold the
1238
+ # scheduler's global write lock while it is rendered.
1239
+ result_json = _receipt_json(result) if result is not None else None
1240
+ projected_result = (
1241
+ json.loads(result_json) if result_json is not None else None
1242
+ )
1243
+ with self._connect() as conn:
1244
+ conn.execute("BEGIN IMMEDIATE")
1245
+ try:
1246
+ schedule, attempt, owns = self._completion_rows(
1247
+ conn, claim, observed,
1248
+ )
1249
+ if not owns:
1250
+ self._record_lost_or_expired(
1251
+ conn, claim, schedule, attempt, observed,
1252
+ )
1253
+ conn.commit()
1254
+ return False, projected_result
1255
+ interval = int(schedule["interval_seconds"])
1256
+ next_run = observed + timedelta(seconds=interval)
1257
+ failure_count = int(schedule["failure_count"] or 0)
1258
+ failure_count_after = failure_count if outcome == "skipped" else 0
1259
+ self._insert_receipt(
1260
+ conn,
1261
+ attempt=attempt,
1262
+ status=outcome,
1263
+ now=observed,
1264
+ result_json=result_json,
1265
+ error_type=None,
1266
+ error_message=None,
1267
+ failure_count_after=failure_count_after,
1268
+ next_run=next_run,
1269
+ )
1270
+ if outcome == "skipped":
1271
+ updated = conn.execute("""
1272
+ UPDATE schedules SET last_run=?,next_run=?,
1273
+ lease_token=NULL,lease_expires_at=NULL,updated_at=?
1274
+ WHERE id=? AND lease_token=? AND lease_expires_at > ?
1275
+ """, (
1276
+ _iso(observed), _iso(next_run), _iso(observed),
1277
+ claim.schedule.id, claim.lease_token, _iso(observed),
1278
+ ))
1279
+ else:
1280
+ updated = conn.execute("""
1281
+ UPDATE schedules SET last_run=?,next_run=?,failure_count=0,
1282
+ lease_token=NULL,lease_expires_at=NULL,
1283
+ degraded_reason=NULL,updated_at=?
1284
+ WHERE id=? AND lease_token=? AND lease_expires_at > ?
1285
+ """, (
1286
+ _iso(observed), _iso(next_run), _iso(observed),
1287
+ claim.schedule.id, claim.lease_token, _iso(observed),
1288
+ ))
1289
+ if updated.rowcount != 1:
1290
+ raise RuntimeError("lease ownership changed during success commit")
1291
+ conn.commit()
1292
+ return True, projected_result
1293
+ except Exception:
1294
+ conn.rollback()
1295
+ raise
1296
+
1297
+ def complete_success(self, claim: RunClaim, result: Any, *, now: datetime) -> bool:
1298
+ accepted, _projection = self._complete_success_with_projection(
1299
+ claim, result, now=now,
1300
+ )
1301
+ return accepted
1302
+
1303
+ def complete_failure(
1304
+ self,
1305
+ claim: RunClaim,
1306
+ error: BaseException,
1307
+ *,
1308
+ now: datetime,
1309
+ base_backoff_seconds: int,
1310
+ max_backoff_seconds: int,
1311
+ ) -> bool:
1312
+ observed = _utc(now)
1313
+ error_type, error_message = _safe_exception_projection(error)
1314
+ with self._connect() as conn:
1315
+ conn.execute("BEGIN IMMEDIATE")
1316
+ try:
1317
+ schedule, attempt, owns = self._completion_rows(
1318
+ conn, claim, observed,
1319
+ )
1320
+ if not owns:
1321
+ self._record_lost_or_expired(
1322
+ conn, claim, schedule, attempt, observed,
1323
+ )
1324
+ conn.commit()
1325
+ return False
1326
+ failures = int(schedule["failure_count"] or 0) + 1
1327
+ exponent = min(30, failures - 1)
1328
+ backoff = min(
1329
+ max_backoff_seconds,
1330
+ base_backoff_seconds * (2 ** exponent),
1331
+ )
1332
+ next_run = observed + timedelta(seconds=backoff)
1333
+ self._insert_receipt(
1334
+ conn,
1335
+ attempt=attempt,
1336
+ status="error",
1337
+ now=observed,
1338
+ result_json=None,
1339
+ error_type=error_type,
1340
+ error_message=error_message,
1341
+ failure_count_after=failures,
1342
+ next_run=next_run,
1343
+ )
1344
+ updated = conn.execute("""
1345
+ UPDATE schedules SET next_run=?,failure_count=?,
1346
+ lease_token=NULL,lease_expires_at=NULL,updated_at=?
1347
+ WHERE id=? AND lease_token=? AND lease_expires_at > ?
1348
+ """, (
1349
+ _iso(next_run), failures, _iso(observed),
1350
+ claim.schedule.id, claim.lease_token, _iso(observed),
1351
+ ))
1352
+ if updated.rowcount != 1:
1353
+ raise RuntimeError("lease ownership changed during failure commit")
1354
+ conn.commit()
1355
+ return True
1356
+ except Exception:
1357
+ conn.rollback()
1358
+ raise
1359
+
1360
+ def degrade_unknown_callback(
1361
+ self,
1362
+ claim: RunClaim,
1363
+ *,
1364
+ now: datetime,
1365
+ ) -> bool:
1366
+ observed = _utc(now)
1367
+ reason = f"callback_unregistered:{claim.schedule.callback_name}"[:500]
1368
+ with self._connect() as conn:
1369
+ conn.execute("BEGIN IMMEDIATE")
1370
+ try:
1371
+ schedule, attempt, owns = self._completion_rows(
1372
+ conn, claim, observed,
1373
+ )
1374
+ if not owns:
1375
+ self._record_lost_or_expired(
1376
+ conn, claim, schedule, attempt, observed,
1377
+ )
1378
+ conn.commit()
1379
+ return False
1380
+ failures = int(schedule["failure_count"] or 0) + 1
1381
+ self._insert_receipt(
1382
+ conn,
1383
+ attempt=attempt,
1384
+ status="degraded",
1385
+ now=observed,
1386
+ result_json=None,
1387
+ error_type="CallbackUnregistered",
1388
+ error_message=reason,
1389
+ failure_count_after=failures,
1390
+ next_run=_parse_time(schedule["next_run"]),
1391
+ )
1392
+ updated = conn.execute("""
1393
+ UPDATE schedules SET enabled=0,degraded_reason=?,
1394
+ failure_count=?,lease_token=NULL,lease_expires_at=NULL,
1395
+ updated_at=?
1396
+ WHERE id=? AND lease_token=? AND lease_expires_at > ?
1397
+ """, (
1398
+ reason, failures, _iso(observed), claim.schedule.id,
1399
+ claim.lease_token, _iso(observed),
1400
+ ))
1401
+ if updated.rowcount != 1:
1402
+ raise RuntimeError("lease ownership changed during degradation")
1403
+ conn.commit()
1404
+ return True
1405
+ except Exception:
1406
+ conn.rollback()
1407
+ raise
1408
+
1409
+ def list_all(self) -> List[TaskSchedule]:
1410
+ with self._connect() as conn:
1411
+ rows = conn.execute(
1412
+ "SELECT * FROM schedules ORDER BY next_run,name"
1413
+ ).fetchall()
1414
+ return [self._row_to_schedule(row) for row in rows]
1415
+
1416
+ def set_enabled(self, schedule_id: str, enabled: bool) -> bool:
1417
+ stamp = _iso(self._now())
1418
+ with self._connect() as conn:
1419
+ conn.execute("BEGIN IMMEDIATE")
1420
+ try:
1421
+ row = conn.execute(
1422
+ "SELECT * FROM schedules WHERE id=?", (schedule_id,),
1423
+ ).fetchone()
1424
+ if row is None:
1425
+ conn.commit()
1426
+ return False
1427
+ # Enabling is an idempotent configuration operation regardless
1428
+ # of the row's current enabled flag. A compatibility writer may
1429
+ # have disabled a row after another process claimed it; the
1430
+ # exact-token lease still belongs to that original attempt and
1431
+ # must survive the disabled-to-enabled transition.
1432
+ if enabled:
1433
+ conn.execute("""
1434
+ UPDATE schedules SET enabled=1,degraded_reason=NULL,
1435
+ updated_at=? WHERE id=?
1436
+ """, (stamp, schedule_id))
1437
+ conn.commit()
1438
+ return True
1439
+ if not enabled and row["lease_token"]:
1440
+ attempt = conn.execute(
1441
+ "SELECT * FROM schedule_run_attempts WHERE lease_token=?",
1442
+ (row["lease_token"],),
1443
+ ).fetchone()
1444
+ if attempt is not None:
1445
+ self._insert_receipt(
1446
+ conn,
1447
+ attempt=attempt,
1448
+ status="disabled",
1449
+ now=self._now(),
1450
+ result_json=None,
1451
+ error_type="ScheduleDisabled",
1452
+ error_message="schedule disabled while leased",
1453
+ failure_count_after=int(row["failure_count"] or 0),
1454
+ next_run=_parse_time(row["next_run"]),
1455
+ )
1456
+ conn.execute("""
1457
+ UPDATE schedules SET enabled=0,lease_token=NULL,
1458
+ lease_expires_at=NULL,updated_at=? WHERE id=?
1459
+ """, (stamp, schedule_id))
1460
+ conn.commit()
1461
+ return True
1462
+ except Exception:
1463
+ conn.rollback()
1464
+ raise
1465
+
1466
+ def delete(self, schedule_id: str) -> bool:
1467
+ """Delete an idle schedule, refusing to orphan an open run attempt.
1468
+
1469
+ A live (or otherwise unreceipted) claim must first reach a terminal
1470
+ receipt. ``set_enabled(..., False)`` can explicitly cancel a leased
1471
+ schedule with a ``disabled`` receipt before a subsequent delete.
1472
+ """
1473
+ with self._connect() as conn:
1474
+ conn.execute("BEGIN IMMEDIATE")
1475
+ try:
1476
+ row = conn.execute(
1477
+ "SELECT lease_token FROM schedules WHERE id=?",
1478
+ (schedule_id,),
1479
+ ).fetchone()
1480
+ if row is None:
1481
+ conn.commit()
1482
+ return False
1483
+ open_attempt = conn.execute("""
1484
+ SELECT 1
1485
+ FROM schedule_run_attempts AS attempt
1486
+ LEFT JOIN schedule_run_receipts AS receipt
1487
+ ON receipt.attempt_id=attempt.attempt_id
1488
+ WHERE attempt.schedule_id=? AND receipt.attempt_id IS NULL
1489
+ LIMIT 1
1490
+ """, (schedule_id,)).fetchone()
1491
+ if row["lease_token"] is not None or open_attempt is not None:
1492
+ conn.commit()
1493
+ return False
1494
+ cursor = conn.execute(
1495
+ "DELETE FROM schedules WHERE id=?", (schedule_id,),
1496
+ )
1497
+ conn.commit()
1498
+ return cursor.rowcount > 0
1499
+ except Exception:
1500
+ conn.rollback()
1501
+ raise
1502
+
1503
+ def list_attempts(
1504
+ self, schedule_id: Optional[str] = None, *, limit: int = 100,
1505
+ ) -> List[dict]:
1506
+ bounded = max(1, min(1000, int(limit)))
1507
+ query = "SELECT * FROM schedule_run_attempts"
1508
+ params: List[Any] = []
1509
+ if schedule_id:
1510
+ query += " WHERE schedule_id=?"
1511
+ params.append(schedule_id)
1512
+ query += " ORDER BY claimed_at,rowid LIMIT ?"
1513
+ params.append(bounded)
1514
+ with self._connect() as conn:
1515
+ return [dict(row) for row in conn.execute(query, params).fetchall()]
1516
+
1517
+ def list_receipts(
1518
+ self, schedule_id: Optional[str] = None, *, limit: int = 100,
1519
+ ) -> List[dict]:
1520
+ bounded = max(1, min(1000, int(limit)))
1521
+ query = "SELECT * FROM schedule_run_receipts"
1522
+ params: List[Any] = []
1523
+ if schedule_id:
1524
+ query += " WHERE schedule_id=?"
1525
+ params.append(schedule_id)
1526
+ query += " ORDER BY finished_at,rowid LIMIT ?"
1527
+ params.append(bounded)
1528
+ with self._connect() as conn:
1529
+ rows = conn.execute(query, params).fetchall()
1530
+ result = []
1531
+ for row in rows:
1532
+ item = dict(row)
1533
+ if item.get("result_json") is not None:
1534
+ item["result"] = json.loads(item["result_json"])
1535
+ result.append(item)
1536
+ return result
1537
+
1538
+ def health_snapshot(self, now: datetime) -> dict:
1539
+ stamp = _iso(now)
1540
+ with self._connect() as conn:
1541
+ counts = conn.execute("""
1542
+ SELECT COUNT(*) AS total,
1543
+ SUM(CASE WHEN enabled=1 THEN 1 ELSE 0 END) AS enabled,
1544
+ SUM(CASE WHEN degraded_reason IS NOT NULL THEN 1 ELSE 0 END)
1545
+ AS degraded,
1546
+ SUM(CASE WHEN enabled=1 AND failure_count >= ? THEN 1
1547
+ ELSE 0 END)
1548
+ AS failing,
1549
+ SUM(CASE WHEN lease_token IS NOT NULL
1550
+ AND lease_expires_at > ? THEN 1 ELSE 0 END)
1551
+ AS leased,
1552
+ SUM(CASE WHEN lease_token IS NOT NULL
1553
+ AND lease_expires_at <= ? THEN 1 ELSE 0 END)
1554
+ AS expired,
1555
+ SUM(CASE WHEN enabled=1 AND next_run <= ?
1556
+ AND (lease_token IS NULL
1557
+ OR lease_expires_at <= ?)
1558
+ THEN 1 ELSE 0 END)
1559
+ AS due
1560
+ FROM schedules
1561
+ """, (SUSTAINED_FAILURE_THRESHOLD, stamp, stamp, stamp, stamp)).fetchone()
1562
+ attempts = conn.execute(
1563
+ "SELECT COUNT(*) FROM schedule_run_attempts"
1564
+ ).fetchone()[0]
1565
+ receipts = conn.execute(
1566
+ "SELECT COUNT(*) FROM schedule_run_receipts"
1567
+ ).fetchone()[0]
1568
+ degraded_rows = conn.execute("""
1569
+ SELECT name,degraded_reason FROM schedules
1570
+ WHERE degraded_reason IS NOT NULL ORDER BY name LIMIT 20
1571
+ """).fetchall()
1572
+ return {
1573
+ "total_schedules": int(counts["total"] or 0),
1574
+ "enabled_schedules": int(counts["enabled"] or 0),
1575
+ "degraded_schedules": int(counts["degraded"] or 0),
1576
+ "failing_schedules": int(counts["failing"] or 0),
1577
+ "active_leases": int(counts["leased"] or 0),
1578
+ "expired_leases": int(counts["expired"] or 0),
1579
+ "due_schedules": int(counts["due"] or 0),
1580
+ "run_attempts": int(attempts),
1581
+ "terminal_receipts": int(receipts),
1582
+ "open_attempts": max(0, int(attempts) - int(receipts)),
1583
+ "degraded": [dict(row) for row in degraded_rows],
1584
+ }
1585
+
1586
+ @staticmethod
1587
+ def _row_to_schedule(row: sqlite3.Row) -> TaskSchedule:
1588
+ try:
1589
+ metadata = json.loads(row["metadata"] or "{}")
1590
+ except (TypeError, ValueError, json.JSONDecodeError):
1591
+ metadata = {"legacy_metadata_invalid": True}
1592
+ if not isinstance(metadata, dict):
1593
+ metadata = {"legacy_metadata_invalid": True}
1594
+ return TaskSchedule(
1595
+ id=str(row["id"]),
1596
+ name=str(row["name"]),
1597
+ interval_seconds=int(row["interval_seconds"]),
1598
+ callback_name=str(row["callback_name"]),
1599
+ last_run=_parse_time(row["last_run"]),
1600
+ next_run=_parse_time(row["next_run"]),
1601
+ enabled=bool(row["enabled"]),
1602
+ metadata=metadata,
1603
+ failure_count=int(row["failure_count"] or 0),
1604
+ lease_token=str(row["lease_token"]) if row["lease_token"] else None,
1605
+ lease_expires_at=_parse_time(row["lease_expires_at"]),
1606
+ degraded_reason=(
1607
+ str(row["degraded_reason"])
1608
+ if row["degraded_reason"] else None
1609
+ ),
1610
+ created_at=_parse_time(row["created_at"]),
1611
+ updated_at=_parse_time(row["updated_at"]),
1612
+ )
1613
+
1614
+
1615
+ class AutonomyScheduler:
1616
+ """Periodic scheduler with durable exact-token leases and receipts."""
1617
+
1618
+ def __init__(
1619
+ self,
1620
+ db_path: str,
1621
+ *,
1622
+ clock: Optional[Callable[[], datetime]] = None,
1623
+ lease_seconds: int = 300,
1624
+ base_backoff_seconds: int = 5,
1625
+ max_backoff_seconds: int = 3600,
1626
+ ) -> None:
1627
+ self._clock = clock or (lambda: datetime.now(timezone.utc))
1628
+ self._lease_seconds = self._bounded_positive(
1629
+ lease_seconds, "lease_seconds", maximum=86_400,
1630
+ )
1631
+ self._base_backoff_seconds = self._bounded_positive(
1632
+ base_backoff_seconds, "base_backoff_seconds", maximum=86_400,
1633
+ )
1634
+ self._max_backoff_seconds = self._bounded_positive(
1635
+ max_backoff_seconds, "max_backoff_seconds", maximum=604_800,
1636
+ )
1637
+ if self._max_backoff_seconds < self._base_backoff_seconds:
1638
+ raise ValueError(
1639
+ "max_backoff_seconds must be >= base_backoff_seconds"
1640
+ )
1641
+ self._store = ScheduleStore(db_path, clock=self._clock)
1642
+ self._callbacks: Dict[str, Callable] = {}
1643
+ self._last_tick_at: Optional[str] = None
1644
+ self._last_error: Optional[str] = None
1645
+ self._tick_count = 0
1646
+
1647
+ @staticmethod
1648
+ def _bounded_positive(value: Any, field: str, *, maximum: int) -> int:
1649
+ if (
1650
+ isinstance(value, bool)
1651
+ or not isinstance(value, int)
1652
+ or not 1 <= value <= maximum
1653
+ ):
1654
+ raise ValueError(f"{field} must be an integer from 1-{maximum}")
1655
+ return value
1656
+
1657
+ def _now(self) -> datetime:
1658
+ return _utc(self._clock())
1659
+
1660
+ def register(
1661
+ self,
1662
+ name: str,
1663
+ callback: Union[Callable, Callable[..., Coroutine]],
1664
+ interval_seconds: int,
1665
+ metadata: Optional[Dict[str, Any]] = None,
1666
+ ) -> str:
1667
+ """Register or update one stable-name periodic task.
1668
+
1669
+ The public call signature is unchanged. Existing last/next run state
1670
+ and operator-disabled state survive re-registration. A schedule that
1671
+ was degraded only because its callback was absent is recovered.
1672
+ """
1673
+ task_name = _validate_name(name)
1674
+ interval = _validate_interval(interval_seconds)
1675
+ if not callable(callback):
1676
+ raise ValueError("callback must be callable")
1677
+ _, metadata_json = _canonical_metadata(metadata)
1678
+ schedule = self._store.register_schedule(
1679
+ name=task_name,
1680
+ interval_seconds=interval,
1681
+ callback_name=task_name,
1682
+ metadata_json=metadata_json,
1683
+ now=self._now(),
1684
+ )
1685
+ self._callbacks[task_name] = callback
1686
+ logger.debug("Registered schedule: %s (every %ds)", task_name, interval)
1687
+ return schedule.id
1688
+
1689
+ async def tick(self) -> List[dict]:
1690
+ """Atomically claim and execute due tasks once for this tick."""
1691
+ results: List[dict] = []
1692
+ claimed_ids: set[str] = set()
1693
+ try:
1694
+ for _ in range(100):
1695
+ claim = self._store.claim_one_due(
1696
+ self._now(),
1697
+ lease_seconds=self._lease_seconds,
1698
+ exclude_schedule_ids=claimed_ids,
1699
+ )
1700
+ if claim is None:
1701
+ break
1702
+ task = claim.schedule
1703
+ claimed_ids.add(task.id)
1704
+ callback = self._callbacks.get(task.callback_name)
1705
+ if callback is None:
1706
+ reason = f"callback_unregistered:{task.callback_name}"
1707
+ accepted = self._store.degrade_unknown_callback(
1708
+ claim, now=self._now(),
1709
+ )
1710
+ results.append({
1711
+ "task": task.name,
1712
+ "status": "degraded" if accepted else "ambiguous",
1713
+ "error": reason if accepted else "lease_lost",
1714
+ })
1715
+ logger.warning(
1716
+ "Disabled degraded schedule '%s': %s", task.name, reason,
1717
+ )
1718
+ continue
1719
+ try:
1720
+ result = callback()
1721
+ if _is_hook_free_awaitable(result):
1722
+ result = await result
1723
+ except Exception as exc:
1724
+ error_type, error_message = _safe_exception_projection(exc)
1725
+ accepted = self._store.complete_failure(
1726
+ claim,
1727
+ exc,
1728
+ now=self._now(),
1729
+ base_backoff_seconds=self._base_backoff_seconds,
1730
+ max_backoff_seconds=self._max_backoff_seconds,
1731
+ )
1732
+ results.append({
1733
+ "task": task.name,
1734
+ "status": "error" if accepted else "ambiguous",
1735
+ "error": error_message if accepted else "lease_lost",
1736
+ })
1737
+ logger.warning(
1738
+ "Scheduled task failed: %s — %s: %s",
1739
+ task.name, error_type, error_message,
1740
+ )
1741
+ continue
1742
+ # A callback that declined to run ({"status": "skipped"}) is
1743
+ # receipted as a skip, never counted as a success.
1744
+ skipped = (
1745
+ isinstance(result, dict)
1746
+ and result.get("status") == "skipped"
1747
+ )
1748
+ accepted, projected_result = (
1749
+ self._store._complete_success_with_projection(
1750
+ claim, result, now=self._now(),
1751
+ outcome="skipped" if skipped else "success",
1752
+ )
1753
+ )
1754
+ if not accepted:
1755
+ status = "ambiguous"
1756
+ else:
1757
+ status = "skipped" if skipped else "ok"
1758
+ results.append({
1759
+ "task": task.name,
1760
+ "status": status,
1761
+ **(
1762
+ {"result": projected_result}
1763
+ if accepted
1764
+ else {"error": "lease_lost"}
1765
+ ),
1766
+ })
1767
+ if accepted:
1768
+ logger.debug(
1769
+ "Scheduled task %s: %s",
1770
+ "skipped" if skipped else "completed", task.name,
1771
+ )
1772
+ self._last_tick_at = _iso(self._now())
1773
+ self._last_error = None
1774
+ self._tick_count += 1
1775
+ return results
1776
+ except Exception as exc:
1777
+ self._last_tick_at = _iso(self._now())
1778
+ error_type, error_message = _safe_exception_projection(exc)
1779
+ self._last_error = f"{error_type}:{error_message}"[:1000]
1780
+ self._tick_count += 1
1781
+ raise
1782
+
1783
+ def list_schedules(self) -> List[TaskSchedule]:
1784
+ return self._store.list_all()
1785
+
1786
+ def list_run_attempts(
1787
+ self, schedule_id: Optional[str] = None, *, limit: int = 100,
1788
+ ) -> List[dict]:
1789
+ return self._store.list_attempts(schedule_id, limit=limit)
1790
+
1791
+ def list_run_receipts(
1792
+ self, schedule_id: Optional[str] = None, *, limit: int = 100,
1793
+ ) -> List[dict]:
1794
+ return self._store.list_receipts(schedule_id, limit=limit)
1795
+
1796
+ @property
1797
+ def health(self) -> dict:
1798
+ snapshot = self._store.health_snapshot(self._now())
1799
+ snapshot.update({
1800
+ "healthy": (
1801
+ self._last_error is None
1802
+ and snapshot["degraded_schedules"] == 0
1803
+ and snapshot["expired_leases"] == 0
1804
+ # A schedule failing every run forever must not read healthy.
1805
+ and snapshot["failing_schedules"] == 0
1806
+ ),
1807
+ "last_tick_at": self._last_tick_at,
1808
+ "last_error": self._last_error,
1809
+ "tick_count": self._tick_count,
1810
+ })
1811
+ return snapshot
1812
+
1813
+ def enable(self, schedule_id: str) -> bool:
1814
+ return self._store.set_enabled(schedule_id, True)
1815
+
1816
+ def disable(self, schedule_id: str) -> bool:
1817
+ return self._store.set_enabled(schedule_id, False)
1818
+
1819
+
1820
+ __all__ = [
1821
+ "AutonomyScheduler", "RunClaim", "ScheduleStore", "TaskSchedule",
1822
+ ]