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.
- {shellbrain-0.1.3 → shellbrain-0.1.4}/PKG-INFO +1 -1
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/cli/main.py +7 -2
- shellbrain-0.1.4/app/periphery/episodes/launcher.py +36 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/poller.py +123 -107
- shellbrain-0.1.4/app/periphery/episodes/poller_lock.py +225 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/pyproject.toml +1 -1
- {shellbrain-0.1.3 → shellbrain-0.1.4}/shellbrain.egg-info/PKG-INFO +1 -1
- {shellbrain-0.1.3 → shellbrain-0.1.4}/shellbrain.egg-info/SOURCES.txt +1 -0
- shellbrain-0.1.3/app/periphery/episodes/launcher.py +0 -66
- {shellbrain-0.1.3 → shellbrain-0.1.4}/README.md +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/__main__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/admin_db.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/config.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/create_policy.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/db.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/embeddings.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/home.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/migrations.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/read_policy.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/repos.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/retrieval.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/thresholds.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/update_policy.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/boot/use_cases.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/defaults/create_policy.yaml +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/defaults/read_policy.yaml +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/defaults/runtime.yaml +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/defaults/thresholds.yaml +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/defaults/update_policy.yaml +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/config/loader.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/contracts/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/contracts/errors.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/contracts/requests.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/contracts/responses.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/associations.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/episodes.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/evidence.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/facts.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/guidance.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/identity.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/memory.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/runtime_context.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/session_state.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/telemetry.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/entities/utility.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/clock.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/config.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/embeddings.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/idgen.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/repos.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/retrieval.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/session_state_store.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/interfaces/unit_of_work.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/_shared/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/_shared/executor.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/_shared/side_effects.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/create_policy/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/create_policy/pipeline.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/bm25.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/context_pack_builder.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/expansion.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/fusion_rrf.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/lexical_query.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/pipeline.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/scenario_lift.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/scoring.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/seed_retrieval.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/read_policy/utility_prior.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/update_policy/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/policies/update_policy/pipeline.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/build_guidance.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/create_memory.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/manage_session_state.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/read_memory.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/record_episode_sync_telemetry.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/record_operation_telemetry.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/sync_episode.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/core/use_cases/update_memory.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/env.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260226_0001_initial_schema.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260312_0002_add_hard_invariants.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260312_0003_drop_create_confidence.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260313_0004_episode_sync_hardening.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260313_0005_evidence_episode_event_refs.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260318_0006_usage_telemetry_schema.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260319_0007_identity_session_guidance.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/20260320_0008_instance_metadata_and_backup_safety.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/migrations/versions/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/claude/skills/shellbrain-session-start/SKILL.md +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/codex/shellbrain-session-start/SKILL.md +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/codex/shellbrain-session-start/agents/openai.yaml +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/codex/shellbrain-session-start/assets/shellbrain-large.svg +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/codex/shellbrain-session-start/assets/shellbrain-small.svg +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/codex/shellbrain-session-start/references/request-shapes.md +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/onboarding_assets/codex/shellbrain-session-start/references/session-workflow.md +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/backup.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/destructive_guard.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/doctor.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/init.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/instance_guard.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/machine_state.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/privileges.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/repo_state.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/restore.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/admin/upgrade.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/cli/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/cli/handlers.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/cli/hydration.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/cli/presenter_json.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/cli/schema_validation.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/engine.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/associations.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/episodes.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/evidence.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/experiences.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/instance_metadata.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/memories.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/metadata.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/registry.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/telemetry.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/utility.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/models/views.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/associations_repo.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/episodes_repo.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/evidence_repo.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/experiences_repo.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/memories_repo.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/read_policy_repo.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/telemetry_repo.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/relational/utility_repo.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/semantic/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/semantic/keyword_retrieval_repo.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/repos/semantic/semantic_retrieval_repo.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/session.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/db/uow.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/embeddings/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/embeddings/local_provider.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/embeddings/query_vector_search.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/claude_code.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/codex.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/normalization.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/source_discovery.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/episodes/tool_filter.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/identity/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/identity/claude_hook_install.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/identity/claude_runtime.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/identity/codex_runtime.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/identity/compatibility.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/identity/resolver.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/onboarding/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/onboarding/host_assets.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/session_state/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/session_state/file_store.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/telemetry/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/telemetry/operation_summary.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/telemetry/session_selection.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/telemetry/sync_summary.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/validation/__init__.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/validation/integrity_validation.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/app/periphery/validation/semantic_validation.py +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/setup.cfg +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/shellbrain.egg-info/dependency_links.txt +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/shellbrain.egg-info/entry_points.txt +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/shellbrain.egg-info/requires.txt +0 -0
- {shellbrain-0.1.3 → shellbrain-0.1.4}/shellbrain.egg-info/top_level.txt +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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=
|
|
75
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|