shellbrain 0.1.3__tar.gz → 0.1.4__tar.gz

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 (183) hide show
  1. {shellbrain-0.1.3 → shellbrain-0.1.4}/PKG-INFO +1 -1
  2. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/cli/main.py +7 -2
  3. shellbrain-0.1.4/app/periphery/episodes/launcher.py +36 -0
  4. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/poller.py +123 -107
  5. shellbrain-0.1.4/app/periphery/episodes/poller_lock.py +225 -0
  6. {shellbrain-0.1.3 → shellbrain-0.1.4}/pyproject.toml +1 -1
  7. {shellbrain-0.1.3 → shellbrain-0.1.4}/shellbrain.egg-info/PKG-INFO +1 -1
  8. {shellbrain-0.1.3 → shellbrain-0.1.4}/shellbrain.egg-info/SOURCES.txt +1 -0
  9. shellbrain-0.1.3/app/periphery/episodes/launcher.py +0 -66
  10. {shellbrain-0.1.3 → shellbrain-0.1.4}/README.md +0 -0
  11. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/__init__.py +0 -0
  12. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/__main__.py +0 -0
  13. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/__init__.py +0 -0
  14. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/admin_db.py +0 -0
  15. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/config.py +0 -0
  16. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/create_policy.py +0 -0
  17. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/db.py +0 -0
  18. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/embeddings.py +0 -0
  19. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/home.py +0 -0
  20. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/migrations.py +0 -0
  21. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/read_policy.py +0 -0
  22. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/repos.py +0 -0
  23. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/retrieval.py +0 -0
  24. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/thresholds.py +0 -0
  25. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/update_policy.py +0 -0
  26. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/use_cases.py +0 -0
  27. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/__init__.py +0 -0
  28. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/defaults/create_policy.yaml +0 -0
  29. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/defaults/read_policy.yaml +0 -0
  30. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/defaults/runtime.yaml +0 -0
  31. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/defaults/thresholds.yaml +0 -0
  32. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/defaults/update_policy.yaml +0 -0
  33. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/loader.py +0 -0
  34. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/__init__.py +0 -0
  35. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/contracts/__init__.py +0 -0
  36. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/contracts/errors.py +0 -0
  37. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/contracts/requests.py +0 -0
  38. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/contracts/responses.py +0 -0
  39. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/__init__.py +0 -0
  40. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/associations.py +0 -0
  41. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/episodes.py +0 -0
  42. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/evidence.py +0 -0
  43. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/facts.py +0 -0
  44. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/guidance.py +0 -0
  45. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/identity.py +0 -0
  46. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/memory.py +0 -0
  47. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/runtime_context.py +0 -0
  48. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/session_state.py +0 -0
  49. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/telemetry.py +0 -0
  50. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/utility.py +0 -0
  51. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/__init__.py +0 -0
  52. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/clock.py +0 -0
  53. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/config.py +0 -0
  54. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/embeddings.py +0 -0
  55. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/idgen.py +0 -0
  56. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/repos.py +0 -0
  57. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/retrieval.py +0 -0
  58. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/session_state_store.py +0 -0
  59. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/unit_of_work.py +0 -0
  60. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/__init__.py +0 -0
  61. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/_shared/__init__.py +0 -0
  62. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/_shared/executor.py +0 -0
  63. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/_shared/side_effects.py +0 -0
  64. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/create_policy/__init__.py +0 -0
  65. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/create_policy/pipeline.py +0 -0
  66. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/__init__.py +0 -0
  67. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/bm25.py +0 -0
  68. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/context_pack_builder.py +0 -0
  69. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/expansion.py +0 -0
  70. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/fusion_rrf.py +0 -0
  71. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/lexical_query.py +0 -0
  72. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/pipeline.py +0 -0
  73. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/scenario_lift.py +0 -0
  74. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/scoring.py +0 -0
  75. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/seed_retrieval.py +0 -0
  76. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/utility_prior.py +0 -0
  77. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/update_policy/__init__.py +0 -0
  78. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/update_policy/pipeline.py +0 -0
  79. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/__init__.py +0 -0
  80. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/build_guidance.py +0 -0
  81. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/create_memory.py +0 -0
  82. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/manage_session_state.py +0 -0
  83. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/read_memory.py +0 -0
  84. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/record_episode_sync_telemetry.py +0 -0
  85. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/record_operation_telemetry.py +0 -0
  86. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/sync_episode.py +0 -0
  87. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/update_memory.py +0 -0
  88. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/__init__.py +0 -0
  89. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/env.py +0 -0
  90. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260226_0001_initial_schema.py +0 -0
  91. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260312_0002_add_hard_invariants.py +0 -0
  92. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260312_0003_drop_create_confidence.py +0 -0
  93. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260313_0004_episode_sync_hardening.py +0 -0
  94. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260313_0005_evidence_episode_event_refs.py +0 -0
  95. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260318_0006_usage_telemetry_schema.py +0 -0
  96. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260319_0007_identity_session_guidance.py +0 -0
  97. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260320_0008_instance_metadata_and_backup_safety.py +0 -0
  98. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/__init__.py +0 -0
  99. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/__init__.py +0 -0
  100. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/claude/skills/shellbrain-session-start/SKILL.md +0 -0
  101. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/codex/shellbrain-session-start/SKILL.md +0 -0
  102. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/codex/shellbrain-session-start/agents/openai.yaml +0 -0
  103. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/codex/shellbrain-session-start/assets/shellbrain-large.svg +0 -0
  104. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/codex/shellbrain-session-start/assets/shellbrain-small.svg +0 -0
  105. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/codex/shellbrain-session-start/references/request-shapes.md +0 -0
  106. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/codex/shellbrain-session-start/references/session-workflow.md +0 -0
  107. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/__init__.py +0 -0
  108. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/__init__.py +0 -0
  109. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/backup.py +0 -0
  110. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/destructive_guard.py +0 -0
  111. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/doctor.py +0 -0
  112. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/init.py +0 -0
  113. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/instance_guard.py +0 -0
  114. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/machine_state.py +0 -0
  115. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/privileges.py +0 -0
  116. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/repo_state.py +0 -0
  117. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/restore.py +0 -0
  118. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/upgrade.py +0 -0
  119. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/cli/__init__.py +0 -0
  120. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/cli/handlers.py +0 -0
  121. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/cli/hydration.py +0 -0
  122. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/cli/presenter_json.py +0 -0
  123. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/cli/schema_validation.py +0 -0
  124. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/__init__.py +0 -0
  125. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/engine.py +0 -0
  126. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/__init__.py +0 -0
  127. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/associations.py +0 -0
  128. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/episodes.py +0 -0
  129. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/evidence.py +0 -0
  130. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/experiences.py +0 -0
  131. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/instance_metadata.py +0 -0
  132. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/memories.py +0 -0
  133. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/metadata.py +0 -0
  134. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/registry.py +0 -0
  135. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/telemetry.py +0 -0
  136. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/utility.py +0 -0
  137. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/views.py +0 -0
  138. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/__init__.py +0 -0
  139. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/__init__.py +0 -0
  140. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/associations_repo.py +0 -0
  141. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/episodes_repo.py +0 -0
  142. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/evidence_repo.py +0 -0
  143. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/experiences_repo.py +0 -0
  144. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/memories_repo.py +0 -0
  145. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/read_policy_repo.py +0 -0
  146. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/telemetry_repo.py +0 -0
  147. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/utility_repo.py +0 -0
  148. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/semantic/__init__.py +0 -0
  149. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/semantic/keyword_retrieval_repo.py +0 -0
  150. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/semantic/semantic_retrieval_repo.py +0 -0
  151. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/session.py +0 -0
  152. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/uow.py +0 -0
  153. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/embeddings/__init__.py +0 -0
  154. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/embeddings/local_provider.py +0 -0
  155. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/embeddings/query_vector_search.py +0 -0
  156. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/__init__.py +0 -0
  157. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/claude_code.py +0 -0
  158. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/codex.py +0 -0
  159. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/normalization.py +0 -0
  160. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/source_discovery.py +0 -0
  161. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/tool_filter.py +0 -0
  162. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/identity/__init__.py +0 -0
  163. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/identity/claude_hook_install.py +0 -0
  164. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/identity/claude_runtime.py +0 -0
  165. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/identity/codex_runtime.py +0 -0
  166. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/identity/compatibility.py +0 -0
  167. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/identity/resolver.py +0 -0
  168. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/onboarding/__init__.py +0 -0
  169. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/onboarding/host_assets.py +0 -0
  170. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/session_state/__init__.py +0 -0
  171. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/session_state/file_store.py +0 -0
  172. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/telemetry/__init__.py +0 -0
  173. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/telemetry/operation_summary.py +0 -0
  174. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/telemetry/session_selection.py +0 -0
  175. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/telemetry/sync_summary.py +0 -0
  176. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/validation/__init__.py +0 -0
  177. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/validation/integrity_validation.py +0 -0
  178. {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/validation/semantic_validation.py +0 -0
  179. {shellbrain-0.1.3 → shellbrain-0.1.4}/setup.cfg +0 -0
  180. {shellbrain-0.1.3 → shellbrain-0.1.4}/shellbrain.egg-info/dependency_links.txt +0 -0
  181. {shellbrain-0.1.3 → shellbrain-0.1.4}/shellbrain.egg-info/entry_points.txt +0 -0
  182. {shellbrain-0.1.3 → shellbrain-0.1.4}/shellbrain.egg-info/requires.txt +0 -0
  183. {shellbrain-0.1.3 → shellbrain-0.1.4}/shellbrain.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shellbrain
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Repo-scoped Shellbrain CLI with explicit evidence-backed writes.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -807,12 +807,17 @@ def _warn_or_fail_on_unsafe_app_role() -> None:
807
807
 
808
808
  from app.boot.admin_db import should_fail_on_unsafe_app_role
809
809
  from app.boot.db import get_db_dsn
810
- from app.periphery.admin.instance_guard import inspect_role_safety
810
+ from app.periphery.admin.instance_guard import SCRATCH, TEST, fetch_instance_metadata, inspect_role_safety
811
811
 
812
- warnings = inspect_role_safety(get_db_dsn())
812
+ dsn = get_db_dsn()
813
+ warnings = inspect_role_safety(dsn)
813
814
  if not warnings:
814
815
  return
815
816
  message = "Unsafe Shellbrain app-role configuration:\n- " + "\n- ".join(warnings)
817
+ metadata = fetch_instance_metadata(dsn)
818
+ if metadata is not None and metadata.instance_mode in {TEST, SCRATCH}:
819
+ print(message, file=sys.stderr)
820
+ return
816
821
  if should_fail_on_unsafe_app_role():
817
822
  raise ValueError(message)
818
823
  print(message, file=sys.stderr)
@@ -0,0 +1,36 @@
1
+ """Best-effort startup for the repo-local episodic sync poller."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import subprocess
7
+ import sys
8
+
9
+ from app.periphery.episodes.poller_lock import inspect_poller_lock
10
+
11
+
12
+ def ensure_episode_sync_started(*, repo_id: str, repo_root: Path) -> bool:
13
+ """Start one detached poller process for the repo when needed."""
14
+
15
+ resolved_repo_root = repo_root.resolve()
16
+ inspection = inspect_poller_lock(repo_root=resolved_repo_root)
17
+ if inspection.active:
18
+ return False
19
+
20
+ command = [
21
+ sys.executable,
22
+ "-m",
23
+ "app.periphery.episodes.poller",
24
+ "--repo-id",
25
+ repo_id,
26
+ "--repo-root",
27
+ str(resolved_repo_root),
28
+ ]
29
+ subprocess.Popen(
30
+ command,
31
+ cwd=resolved_repo_root,
32
+ stdout=subprocess.DEVNULL,
33
+ stderr=subprocess.DEVNULL,
34
+ start_new_session=True,
35
+ )
36
+ return True
@@ -14,6 +14,7 @@ from uuid import uuid4
14
14
  from app.boot.use_cases import get_uow_factory
15
15
  from app.core.use_cases.record_episode_sync_telemetry import record_episode_sync_telemetry
16
16
  from app.core.use_cases.sync_episode import sync_episode_from_host
17
+ from app.periphery.episodes.poller_lock import acquire_poller_lock, write_poller_pid_artifact
17
18
  from app.periphery.episodes.source_discovery import (
18
19
  SUPPORTED_HOSTS,
19
20
  default_search_roots,
@@ -52,126 +53,135 @@ def run_episode_poller(*, repo_id: str, repo_root: Path) -> None:
52
53
  """Run until the repo appears idle for long enough."""
53
54
 
54
55
  repo_root = repo_root.resolve()
56
+ lock_handle = acquire_poller_lock(repo_id=repo_id, repo_root=repo_root)
57
+ if lock_handle is None:
58
+ return
59
+
55
60
  known_state: dict[str, _HostState] = {}
56
61
  last_change_at = time.monotonic()
57
62
  uow_factory = get_uow_factory()
63
+ try:
64
+ _write_pid_artifact(repo_root=repo_root)
65
+ while True:
66
+ saw_change = False
67
+ for host_app in SUPPORTED_HOSTS:
68
+ search_roots = default_search_roots(repo_root=repo_root, host_app=host_app)
69
+ candidate = discover_active_host_session(
70
+ host_app=host_app,
71
+ repo_root=repo_root,
72
+ search_roots=search_roots,
73
+ )
58
74
 
59
- while True:
60
- saw_change = False
61
- for host_app in SUPPORTED_HOSTS:
62
- search_roots = default_search_roots(repo_root=repo_root, host_app=host_app)
63
- candidate = discover_active_host_session(
64
- host_app=host_app,
65
- repo_root=repo_root,
66
- search_roots=search_roots,
67
- )
68
-
69
- if candidate is None:
70
- if host_app in known_state:
71
- _record_missing_source(
72
- repo_root=repo_root,
75
+ if candidate is None:
76
+ if host_app in known_state:
77
+ _record_missing_source(
78
+ repo_root=repo_root,
79
+ host_app=host_app,
80
+ host_session_key=known_state[host_app].session_key,
81
+ search_roots=search_roots,
82
+ last_known_path=known_state[host_app].transcript_path,
83
+ )
84
+ continue
85
+
86
+ transcript_path = Path(candidate["transcript_path"])
87
+ state = known_state.get(host_app)
88
+ session_changed = state is not None and state.session_key != candidate["host_session_key"]
89
+ if session_changed:
90
+ _close_episode(
91
+ repo_id=repo_id,
73
92
  host_app=host_app,
74
- host_session_key=known_state[host_app].session_key,
75
- search_roots=search_roots,
76
- last_known_path=known_state[host_app].transcript_path,
93
+ host_session_key=state.session_key,
94
+ uow_factory=uow_factory,
77
95
  )
78
- continue
79
-
80
- transcript_path = Path(candidate["transcript_path"])
81
- state = known_state.get(host_app)
82
- session_changed = state is not None and state.session_key != candidate["host_session_key"]
83
- if session_changed:
84
- _close_episode(
85
- repo_id=repo_id,
86
- host_app=host_app,
87
- host_session_key=state.session_key,
88
- uow_factory=uow_factory,
89
- )
90
96
 
91
- mtime = transcript_path.stat().st_mtime if transcript_path.exists() else 0.0
92
- should_sync = state is None or session_changed or state.last_mtime != mtime
93
- known_state[host_app] = _HostState(
94
- session_key=str(candidate["host_session_key"]),
95
- transcript_path=transcript_path,
96
- last_mtime=mtime,
97
- )
98
- if not should_sync:
99
- continue
100
-
101
- sync_started_at = perf_counter()
102
- try:
103
- with uow_factory() as uow:
104
- sync_result = sync_episode_from_host(
97
+ mtime = transcript_path.stat().st_mtime if transcript_path.exists() else 0.0
98
+ should_sync = state is None or session_changed or state.last_mtime != mtime
99
+ known_state[host_app] = _HostState(
100
+ session_key=str(candidate["host_session_key"]),
101
+ transcript_path=transcript_path,
102
+ last_mtime=mtime,
103
+ )
104
+ if not should_sync:
105
+ continue
106
+
107
+ sync_started_at = perf_counter()
108
+ try:
109
+ with uow_factory() as uow:
110
+ sync_result = sync_episode_from_host(
111
+ repo_id=repo_id,
112
+ host_app=host_app,
113
+ host_session_key=str(candidate["host_session_key"]),
114
+ uow=uow,
115
+ search_roots=search_roots,
116
+ last_known_path=transcript_path,
117
+ )
118
+ _record_status(
119
+ repo_root=repo_root,
120
+ host_app=host_app,
121
+ host_session_key=str(candidate["host_session_key"]),
122
+ last_successful_sync_at=_utc_now().isoformat(),
123
+ last_error=None,
124
+ )
125
+ _record_sync_telemetry_best_effort(
126
+ uow_factory=uow_factory,
105
127
  repo_id=repo_id,
106
128
  host_app=host_app,
107
129
  host_session_key=str(candidate["host_session_key"]),
108
- uow=uow,
109
- search_roots=search_roots,
110
- last_known_path=transcript_path,
130
+ thread_id=str(sync_result["thread_id"]),
131
+ episode_id=str(sync_result["episode_id"]),
132
+ transcript_path=str(sync_result["transcript_path"]),
133
+ outcome="ok",
134
+ error_stage=None,
135
+ error_message=None,
136
+ duration_ms=int((perf_counter() - sync_started_at) * 1000),
137
+ imported_event_count=int(sync_result["imported_event_count"]),
138
+ total_event_count=int(sync_result["total_event_count"]),
139
+ user_event_count=int(sync_result["user_event_count"]),
140
+ assistant_event_count=int(sync_result["assistant_event_count"]),
141
+ tool_event_count=int(sync_result["tool_event_count"]),
142
+ system_event_count=int(sync_result["system_event_count"]),
143
+ tool_type_counts=dict(sync_result["tool_type_counts"]),
111
144
  )
112
- _record_status(
113
- repo_root=repo_root,
114
- host_app=host_app,
115
- host_session_key=str(candidate["host_session_key"]),
116
- last_successful_sync_at=_utc_now().isoformat(),
117
- last_error=None,
118
- )
119
- _record_sync_telemetry_best_effort(
120
- uow_factory=uow_factory,
121
- repo_id=repo_id,
122
- host_app=host_app,
123
- host_session_key=str(candidate["host_session_key"]),
124
- thread_id=str(sync_result["thread_id"]),
125
- episode_id=str(sync_result["episode_id"]),
126
- transcript_path=str(sync_result["transcript_path"]),
127
- outcome="ok",
128
- error_stage=None,
129
- error_message=None,
130
- duration_ms=int((perf_counter() - sync_started_at) * 1000),
131
- imported_event_count=int(sync_result["imported_event_count"]),
132
- total_event_count=int(sync_result["total_event_count"]),
133
- user_event_count=int(sync_result["user_event_count"]),
134
- assistant_event_count=int(sync_result["assistant_event_count"]),
135
- tool_event_count=int(sync_result["tool_event_count"]),
136
- system_event_count=int(sync_result["system_event_count"]),
137
- tool_type_counts=dict(sync_result["tool_type_counts"]),
138
- )
139
- saw_change = True
140
- except Exception as exc:
141
- _record_status(
142
- repo_root=repo_root,
143
- host_app=host_app,
144
- host_session_key=str(candidate["host_session_key"]),
145
- last_successful_sync_at=None,
146
- last_error=str(exc),
147
- )
148
- _record_sync_telemetry_best_effort(
149
- uow_factory=uow_factory,
150
- repo_id=repo_id,
151
- host_app=host_app,
152
- host_session_key=str(candidate["host_session_key"]),
153
- thread_id=f"{host_app}:{candidate['host_session_key']}",
154
- episode_id=None,
155
- transcript_path=str(transcript_path),
156
- outcome="error",
157
- error_stage="sync",
158
- error_message=str(exc),
159
- duration_ms=int((perf_counter() - sync_started_at) * 1000),
160
- imported_event_count=0,
161
- total_event_count=0,
162
- user_event_count=0,
163
- assistant_event_count=0,
164
- tool_event_count=0,
165
- system_event_count=0,
166
- tool_type_counts={},
167
- )
145
+ saw_change = True
146
+ except Exception as exc:
147
+ _record_status(
148
+ repo_root=repo_root,
149
+ host_app=host_app,
150
+ host_session_key=str(candidate["host_session_key"]),
151
+ last_successful_sync_at=None,
152
+ last_error=str(exc),
153
+ )
154
+ _record_sync_telemetry_best_effort(
155
+ uow_factory=uow_factory,
156
+ repo_id=repo_id,
157
+ host_app=host_app,
158
+ host_session_key=str(candidate["host_session_key"]),
159
+ thread_id=f"{host_app}:{candidate['host_session_key']}",
160
+ episode_id=None,
161
+ transcript_path=str(transcript_path),
162
+ outcome="error",
163
+ error_stage="sync",
164
+ error_message=str(exc),
165
+ duration_ms=int((perf_counter() - sync_started_at) * 1000),
166
+ imported_event_count=0,
167
+ total_event_count=0,
168
+ user_event_count=0,
169
+ assistant_event_count=0,
170
+ tool_event_count=0,
171
+ system_event_count=0,
172
+ tool_type_counts={},
173
+ )
174
+
175
+ if saw_change:
176
+ last_change_at = time.monotonic()
177
+ elif time.monotonic() - last_change_at >= IDLE_EXIT_SECONDS:
178
+ break
179
+
180
+ time.sleep(POLL_INTERVAL_SECONDS)
181
+ finally:
182
+ lock_handle.release()
168
183
 
169
- if saw_change:
170
- last_change_at = time.monotonic()
171
- elif time.monotonic() - last_change_at >= IDLE_EXIT_SECONDS:
172
- break
173
184
 
174
- time.sleep(POLL_INTERVAL_SECONDS)
175
185
  def _close_episode(*, repo_id: str, host_app: str, host_session_key: str, uow_factory) -> None:
176
186
  """Close one active episode when a newer session replaces it."""
177
187
 
@@ -237,6 +247,12 @@ def _record_status(
237
247
  status_path.write_text(json.dumps(status, indent=2, sort_keys=True), encoding="utf-8")
238
248
 
239
249
 
250
+ def _write_pid_artifact(*, repo_root: Path) -> None:
251
+ """Persist the compatibility pid artifact for the current poller process."""
252
+
253
+ write_poller_pid_artifact(repo_root=repo_root)
254
+
255
+
240
256
  def _utc_now() -> datetime:
241
257
  """Return a timezone-aware current UTC time."""
242
258
 
@@ -0,0 +1,225 @@
1
+ """Repo-local singleton lock helpers for the episode poller."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import shutil
11
+ import socket
12
+
13
+
14
+ _LOCK_DIR_NAME = "episode_sync.lock"
15
+ _OWNER_FILENAME = "owner.json"
16
+ _PID_FILENAME = "episode_sync.pid"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PollerLockInspection:
21
+ """Current status of the repo-local poller singleton lock."""
22
+
23
+ lock_root: Path
24
+ owner_path: Path
25
+ status: str
26
+ owner: dict[str, object] | None
27
+
28
+ @property
29
+ def active(self) -> bool:
30
+ """Return whether the lock currently belongs to a live owner."""
31
+
32
+ return self.status in {"active", "foreign_active"}
33
+
34
+
35
+ @dataclass
36
+ class PollerLockHandle:
37
+ """Lease for one acquired poller lock."""
38
+
39
+ repo_root: Path
40
+ lock_root: Path
41
+ owner_path: Path
42
+ owner: dict[str, object]
43
+ released: bool = False
44
+
45
+ def release(self) -> None:
46
+ """Release the repo-local lock when still owned by this process."""
47
+
48
+ if self.released:
49
+ return
50
+ release_poller_lock(self)
51
+ self.released = True
52
+
53
+
54
+ def inspect_poller_lock(*, repo_root: Path) -> PollerLockInspection:
55
+ """Inspect the current poller singleton lock for one repo root."""
56
+
57
+ resolved_repo_root = repo_root.resolve()
58
+ lock_root = _lock_root(resolved_repo_root)
59
+ owner_path = lock_root / _OWNER_FILENAME
60
+ if not lock_root.exists():
61
+ return PollerLockInspection(lock_root=lock_root, owner_path=owner_path, status="unlocked", owner=None)
62
+ if not lock_root.is_dir():
63
+ return PollerLockInspection(lock_root=lock_root, owner_path=owner_path, status="stale", owner=None)
64
+
65
+ owner = _read_owner_payload(owner_path)
66
+ if owner is None or not _owner_payload_is_well_formed(owner=owner, repo_root=resolved_repo_root):
67
+ return PollerLockInspection(lock_root=lock_root, owner_path=owner_path, status="stale", owner=owner)
68
+
69
+ hostname = str(owner["hostname"])
70
+ if hostname != _current_hostname():
71
+ return PollerLockInspection(lock_root=lock_root, owner_path=owner_path, status="foreign_active", owner=owner)
72
+
73
+ pid = int(owner["pid"])
74
+ if _is_process_running(pid):
75
+ return PollerLockInspection(lock_root=lock_root, owner_path=owner_path, status="active", owner=owner)
76
+ return PollerLockInspection(lock_root=lock_root, owner_path=owner_path, status="stale", owner=owner)
77
+
78
+
79
+ def acquire_poller_lock(*, repo_id: str, repo_root: Path) -> PollerLockHandle | None:
80
+ """Acquire the repo-local singleton lock, returning None when another owner is active."""
81
+
82
+ resolved_repo_root = repo_root.resolve()
83
+ runtime_dir = resolved_repo_root / ".shellbrain"
84
+ runtime_dir.mkdir(parents=True, exist_ok=True)
85
+ lock_root = _lock_root(resolved_repo_root)
86
+ owner_path = lock_root / _OWNER_FILENAME
87
+ owner = _build_owner_payload(repo_id=repo_id, repo_root=resolved_repo_root)
88
+
89
+ for _attempt in range(2):
90
+ try:
91
+ lock_root.mkdir()
92
+ except FileExistsError:
93
+ inspection = inspect_poller_lock(repo_root=resolved_repo_root)
94
+ if inspection.active:
95
+ return None
96
+ if inspection.status == "stale":
97
+ _remove_stale_lock(lock_root=lock_root, expected_owner=inspection.owner)
98
+ continue
99
+ return None
100
+
101
+ try:
102
+ owner_path.write_text(json.dumps(owner, indent=2, sort_keys=True), encoding="utf-8")
103
+ except Exception:
104
+ _remove_path(lock_root)
105
+ raise
106
+ return PollerLockHandle(
107
+ repo_root=resolved_repo_root,
108
+ lock_root=lock_root,
109
+ owner_path=owner_path,
110
+ owner=owner,
111
+ )
112
+
113
+ return None
114
+
115
+
116
+ def release_poller_lock(handle: PollerLockHandle) -> None:
117
+ """Release the singleton lock when the on-disk owner still matches this handle."""
118
+
119
+ inspection = inspect_poller_lock(repo_root=handle.repo_root)
120
+ if inspection.status == "unlocked":
121
+ return
122
+ if inspection.owner != handle.owner:
123
+ return
124
+
125
+ try:
126
+ handle.owner_path.unlink(missing_ok=True)
127
+ except OSError:
128
+ return
129
+ try:
130
+ handle.lock_root.rmdir()
131
+ except OSError:
132
+ return
133
+
134
+
135
+ def write_poller_pid_artifact(*, repo_root: Path) -> Path:
136
+ """Persist the compatibility pid artifact for the current poller process."""
137
+
138
+ resolved_repo_root = repo_root.resolve()
139
+ runtime_dir = resolved_repo_root / ".shellbrain"
140
+ runtime_dir.mkdir(parents=True, exist_ok=True)
141
+ pid_path = runtime_dir / _PID_FILENAME
142
+ pid_path.write_text(json.dumps({"pid": os.getpid()}, indent=2, sort_keys=True), encoding="utf-8")
143
+ return pid_path
144
+
145
+
146
+ def _lock_root(repo_root: Path) -> Path:
147
+ """Return the canonical lock directory path for one repo root."""
148
+
149
+ return repo_root / ".shellbrain" / _LOCK_DIR_NAME
150
+
151
+
152
+ def _build_owner_payload(*, repo_id: str, repo_root: Path) -> dict[str, object]:
153
+ """Build the owner metadata stored inside the singleton lock."""
154
+
155
+ return {
156
+ "hostname": _current_hostname(),
157
+ "pid": os.getpid(),
158
+ "repo_id": repo_id,
159
+ "repo_root": str(repo_root),
160
+ "started_at": datetime.now(timezone.utc).isoformat(),
161
+ }
162
+
163
+
164
+ def _read_owner_payload(owner_path: Path) -> dict[str, object] | None:
165
+ """Read the owner metadata for one existing lock when present."""
166
+
167
+ try:
168
+ payload = json.loads(owner_path.read_text(encoding="utf-8"))
169
+ except (FileNotFoundError, NotADirectoryError, json.JSONDecodeError):
170
+ return None
171
+ return payload if isinstance(payload, dict) else None
172
+
173
+
174
+ def _owner_payload_is_well_formed(*, owner: dict[str, object], repo_root: Path) -> bool:
175
+ """Return whether one owner payload is usable for lock inspection."""
176
+
177
+ return (
178
+ isinstance(owner.get("pid"), int)
179
+ and isinstance(owner.get("hostname"), str)
180
+ and bool(str(owner.get("hostname")))
181
+ and isinstance(owner.get("repo_id"), str)
182
+ and owner.get("repo_root") == str(repo_root)
183
+ and isinstance(owner.get("started_at"), str)
184
+ )
185
+
186
+
187
+ def _remove_stale_lock(*, lock_root: Path, expected_owner: dict[str, object] | None) -> None:
188
+ """Remove one stale lock only when the current stale owner still matches the expected state."""
189
+
190
+ if not lock_root.exists():
191
+ return
192
+ inspection = inspect_poller_lock(repo_root=lock_root.parent.parent)
193
+ if inspection.status != "stale":
194
+ return
195
+ if expected_owner is not None and inspection.owner != expected_owner:
196
+ return
197
+ _remove_path(lock_root)
198
+
199
+
200
+ def _remove_path(path: Path) -> None:
201
+ """Remove one filesystem path whether it is a file or directory."""
202
+
203
+ if path.is_dir():
204
+ shutil.rmtree(path, ignore_errors=True)
205
+ return
206
+ try:
207
+ path.unlink()
208
+ except FileNotFoundError:
209
+ return
210
+
211
+
212
+ def _current_hostname() -> str:
213
+ """Return the normalized current hostname for lock ownership checks."""
214
+
215
+ return socket.gethostname().strip().lower()
216
+
217
+
218
+ def _is_process_running(pid: int) -> bool:
219
+ """Return whether one pid is currently alive on this host."""
220
+
221
+ try:
222
+ os.kill(pid, 0)
223
+ except OSError:
224
+ return False
225
+ return True
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "shellbrain"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "Repo-scoped Shellbrain CLI with explicit evidence-backed writes."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shellbrain
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Repo-scoped Shellbrain CLI with explicit evidence-backed writes.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -152,6 +152,7 @@ app/periphery/episodes/codex.py
152
152
  app/periphery/episodes/launcher.py
153
153
  app/periphery/episodes/normalization.py
154
154
  app/periphery/episodes/poller.py
155
+ app/periphery/episodes/poller_lock.py
155
156
  app/periphery/episodes/source_discovery.py
156
157
  app/periphery/episodes/tool_filter.py
157
158
  app/periphery/identity/__init__.py
@@ -1,66 +0,0 @@
1
- """Best-effort startup for the repo-local episodic sync poller."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- from pathlib import Path
8
- import subprocess
9
- import sys
10
-
11
-
12
- _PID_FILE = "episode_sync.pid"
13
-
14
-
15
- def ensure_episode_sync_started(*, repo_id: str, repo_root: Path) -> bool:
16
- """Start one detached poller process for the repo when needed."""
17
-
18
- runtime_dir = repo_root / ".shellbrain"
19
- runtime_dir.mkdir(parents=True, exist_ok=True)
20
- pid_path = runtime_dir / _PID_FILE
21
-
22
- existing_pid = _read_pid(pid_path)
23
- if existing_pid is not None and _is_running(existing_pid):
24
- return False
25
-
26
- command = [
27
- sys.executable,
28
- "-m",
29
- "app.periphery.episodes.poller",
30
- "--repo-id",
31
- repo_id,
32
- "--repo-root",
33
- str(repo_root),
34
- ]
35
- process = subprocess.Popen(
36
- command,
37
- cwd=repo_root,
38
- stdout=subprocess.DEVNULL,
39
- stderr=subprocess.DEVNULL,
40
- start_new_session=True,
41
- )
42
- pid_path.write_text(json.dumps({"pid": process.pid}), encoding="utf-8")
43
- return True
44
-
45
-
46
- def _read_pid(pid_path: Path) -> int | None:
47
- """Read one stored pid from disk when available."""
48
-
49
- if not pid_path.exists():
50
- return None
51
- try:
52
- payload = json.loads(pid_path.read_text(encoding="utf-8"))
53
- except json.JSONDecodeError:
54
- return None
55
- pid = payload.get("pid")
56
- return int(pid) if isinstance(pid, int) else None
57
-
58
-
59
- def _is_running(pid: int) -> bool:
60
- """Return whether one process id is still alive."""
61
-
62
- try:
63
- os.kill(pid, 0)
64
- except OSError:
65
- return False
66
- return True
File without changes
File without changes
File without changes
File without changes