attune-ai 2.0.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.
- attune/__init__.py +358 -0
- attune/adaptive/__init__.py +13 -0
- attune/adaptive/task_complexity.py +127 -0
- attune/agent_monitoring.py +414 -0
- attune/cache/__init__.py +117 -0
- attune/cache/base.py +166 -0
- attune/cache/dependency_manager.py +256 -0
- attune/cache/hash_only.py +251 -0
- attune/cache/hybrid.py +457 -0
- attune/cache/storage.py +285 -0
- attune/cache_monitor.py +356 -0
- attune/cache_stats.py +298 -0
- attune/cli/__init__.py +152 -0
- attune/cli/__main__.py +12 -0
- attune/cli/commands/__init__.py +1 -0
- attune/cli/commands/batch.py +264 -0
- attune/cli/commands/cache.py +248 -0
- attune/cli/commands/help.py +331 -0
- attune/cli/commands/info.py +140 -0
- attune/cli/commands/inspect.py +436 -0
- attune/cli/commands/inspection.py +57 -0
- attune/cli/commands/memory.py +48 -0
- attune/cli/commands/metrics.py +92 -0
- attune/cli/commands/orchestrate.py +184 -0
- attune/cli/commands/patterns.py +207 -0
- attune/cli/commands/profiling.py +202 -0
- attune/cli/commands/provider.py +98 -0
- attune/cli/commands/routing.py +285 -0
- attune/cli/commands/setup.py +96 -0
- attune/cli/commands/status.py +235 -0
- attune/cli/commands/sync.py +166 -0
- attune/cli/commands/tier.py +121 -0
- attune/cli/commands/utilities.py +114 -0
- attune/cli/commands/workflow.py +579 -0
- attune/cli/core.py +32 -0
- attune/cli/parsers/__init__.py +68 -0
- attune/cli/parsers/batch.py +118 -0
- attune/cli/parsers/cache.py +65 -0
- attune/cli/parsers/help.py +41 -0
- attune/cli/parsers/info.py +26 -0
- attune/cli/parsers/inspect.py +66 -0
- attune/cli/parsers/metrics.py +42 -0
- attune/cli/parsers/orchestrate.py +61 -0
- attune/cli/parsers/patterns.py +54 -0
- attune/cli/parsers/provider.py +40 -0
- attune/cli/parsers/routing.py +110 -0
- attune/cli/parsers/setup.py +42 -0
- attune/cli/parsers/status.py +47 -0
- attune/cli/parsers/sync.py +31 -0
- attune/cli/parsers/tier.py +33 -0
- attune/cli/parsers/workflow.py +77 -0
- attune/cli/utils/__init__.py +1 -0
- attune/cli/utils/data.py +242 -0
- attune/cli/utils/helpers.py +68 -0
- attune/cli_legacy.py +3957 -0
- attune/cli_minimal.py +1159 -0
- attune/cli_router.py +437 -0
- attune/cli_unified.py +814 -0
- attune/config/__init__.py +66 -0
- attune/config/xml_config.py +286 -0
- attune/config.py +545 -0
- attune/coordination.py +870 -0
- attune/core.py +1511 -0
- attune/core_modules/__init__.py +15 -0
- attune/cost_tracker.py +626 -0
- attune/dashboard/__init__.py +41 -0
- attune/dashboard/app.py +512 -0
- attune/dashboard/simple_server.py +435 -0
- attune/dashboard/standalone_server.py +547 -0
- attune/discovery.py +306 -0
- attune/emergence.py +306 -0
- attune/exceptions.py +123 -0
- attune/feedback_loops.py +373 -0
- attune/hot_reload/README.md +473 -0
- attune/hot_reload/__init__.py +62 -0
- attune/hot_reload/config.py +83 -0
- attune/hot_reload/integration.py +229 -0
- attune/hot_reload/reloader.py +298 -0
- attune/hot_reload/watcher.py +183 -0
- attune/hot_reload/websocket.py +177 -0
- attune/levels.py +577 -0
- attune/leverage_points.py +441 -0
- attune/logging_config.py +261 -0
- attune/mcp/__init__.py +10 -0
- attune/mcp/server.py +506 -0
- attune/memory/__init__.py +237 -0
- attune/memory/claude_memory.py +469 -0
- attune/memory/config.py +224 -0
- attune/memory/control_panel.py +1290 -0
- attune/memory/control_panel_support.py +145 -0
- attune/memory/cross_session.py +845 -0
- attune/memory/edges.py +179 -0
- attune/memory/encryption.py +159 -0
- attune/memory/file_session.py +770 -0
- attune/memory/graph.py +570 -0
- attune/memory/long_term.py +913 -0
- attune/memory/long_term_types.py +99 -0
- attune/memory/mixins/__init__.py +25 -0
- attune/memory/mixins/backend_init_mixin.py +249 -0
- attune/memory/mixins/capabilities_mixin.py +208 -0
- attune/memory/mixins/handoff_mixin.py +208 -0
- attune/memory/mixins/lifecycle_mixin.py +49 -0
- attune/memory/mixins/long_term_mixin.py +352 -0
- attune/memory/mixins/promotion_mixin.py +109 -0
- attune/memory/mixins/short_term_mixin.py +182 -0
- attune/memory/nodes.py +179 -0
- attune/memory/redis_bootstrap.py +540 -0
- attune/memory/security/__init__.py +31 -0
- attune/memory/security/audit_logger.py +932 -0
- attune/memory/security/pii_scrubber.py +640 -0
- attune/memory/security/secrets_detector.py +678 -0
- attune/memory/short_term.py +2192 -0
- attune/memory/simple_storage.py +302 -0
- attune/memory/storage/__init__.py +15 -0
- attune/memory/storage_backend.py +167 -0
- attune/memory/summary_index.py +583 -0
- attune/memory/types.py +446 -0
- attune/memory/unified.py +182 -0
- attune/meta_workflows/__init__.py +74 -0
- attune/meta_workflows/agent_creator.py +248 -0
- attune/meta_workflows/builtin_templates.py +567 -0
- attune/meta_workflows/cli_commands/__init__.py +56 -0
- attune/meta_workflows/cli_commands/agent_commands.py +321 -0
- attune/meta_workflows/cli_commands/analytics_commands.py +442 -0
- attune/meta_workflows/cli_commands/config_commands.py +232 -0
- attune/meta_workflows/cli_commands/memory_commands.py +182 -0
- attune/meta_workflows/cli_commands/template_commands.py +354 -0
- attune/meta_workflows/cli_commands/workflow_commands.py +382 -0
- attune/meta_workflows/cli_meta_workflows.py +59 -0
- attune/meta_workflows/form_engine.py +292 -0
- attune/meta_workflows/intent_detector.py +409 -0
- attune/meta_workflows/models.py +569 -0
- attune/meta_workflows/pattern_learner.py +738 -0
- attune/meta_workflows/plan_generator.py +384 -0
- attune/meta_workflows/session_context.py +397 -0
- attune/meta_workflows/template_registry.py +229 -0
- attune/meta_workflows/workflow.py +984 -0
- attune/metrics/__init__.py +12 -0
- attune/metrics/collector.py +31 -0
- attune/metrics/prompt_metrics.py +194 -0
- attune/models/__init__.py +172 -0
- attune/models/__main__.py +13 -0
- attune/models/adaptive_routing.py +437 -0
- attune/models/auth_cli.py +444 -0
- attune/models/auth_strategy.py +450 -0
- attune/models/cli.py +655 -0
- attune/models/empathy_executor.py +354 -0
- attune/models/executor.py +257 -0
- attune/models/fallback.py +762 -0
- attune/models/provider_config.py +282 -0
- attune/models/registry.py +472 -0
- attune/models/tasks.py +359 -0
- attune/models/telemetry/__init__.py +71 -0
- attune/models/telemetry/analytics.py +594 -0
- attune/models/telemetry/backend.py +196 -0
- attune/models/telemetry/data_models.py +431 -0
- attune/models/telemetry/storage.py +489 -0
- attune/models/token_estimator.py +420 -0
- attune/models/validation.py +280 -0
- attune/monitoring/__init__.py +52 -0
- attune/monitoring/alerts.py +946 -0
- attune/monitoring/alerts_cli.py +448 -0
- attune/monitoring/multi_backend.py +271 -0
- attune/monitoring/otel_backend.py +362 -0
- attune/optimization/__init__.py +19 -0
- attune/optimization/context_optimizer.py +272 -0
- attune/orchestration/__init__.py +67 -0
- attune/orchestration/agent_templates.py +707 -0
- attune/orchestration/config_store.py +499 -0
- attune/orchestration/execution_strategies.py +2111 -0
- attune/orchestration/meta_orchestrator.py +1168 -0
- attune/orchestration/pattern_learner.py +696 -0
- attune/orchestration/real_tools.py +931 -0
- attune/pattern_cache.py +187 -0
- attune/pattern_library.py +542 -0
- attune/patterns/debugging/all_patterns.json +81 -0
- attune/patterns/debugging/workflow_20260107_1770825e.json +77 -0
- attune/patterns/refactoring_memory.json +89 -0
- attune/persistence.py +564 -0
- attune/platform_utils.py +265 -0
- attune/plugins/__init__.py +28 -0
- attune/plugins/base.py +361 -0
- attune/plugins/registry.py +268 -0
- attune/project_index/__init__.py +32 -0
- attune/project_index/cli.py +335 -0
- attune/project_index/index.py +667 -0
- attune/project_index/models.py +504 -0
- attune/project_index/reports.py +474 -0
- attune/project_index/scanner.py +777 -0
- attune/project_index/scanner_parallel.py +291 -0
- attune/prompts/__init__.py +61 -0
- attune/prompts/config.py +77 -0
- attune/prompts/context.py +177 -0
- attune/prompts/parser.py +285 -0
- attune/prompts/registry.py +313 -0
- attune/prompts/templates.py +208 -0
- attune/redis_config.py +302 -0
- attune/redis_memory.py +799 -0
- attune/resilience/__init__.py +56 -0
- attune/resilience/circuit_breaker.py +256 -0
- attune/resilience/fallback.py +179 -0
- attune/resilience/health.py +300 -0
- attune/resilience/retry.py +209 -0
- attune/resilience/timeout.py +135 -0
- attune/routing/__init__.py +43 -0
- attune/routing/chain_executor.py +433 -0
- attune/routing/classifier.py +217 -0
- attune/routing/smart_router.py +234 -0
- attune/routing/workflow_registry.py +343 -0
- attune/scaffolding/README.md +589 -0
- attune/scaffolding/__init__.py +35 -0
- attune/scaffolding/__main__.py +14 -0
- attune/scaffolding/cli.py +240 -0
- attune/scaffolding/templates/base_wizard.py.jinja2 +121 -0
- attune/scaffolding/templates/coach_wizard.py.jinja2 +321 -0
- attune/scaffolding/templates/domain_wizard.py.jinja2 +408 -0
- attune/scaffolding/templates/linear_flow_wizard.py.jinja2 +203 -0
- attune/socratic/__init__.py +256 -0
- attune/socratic/ab_testing.py +958 -0
- attune/socratic/blueprint.py +533 -0
- attune/socratic/cli.py +703 -0
- attune/socratic/collaboration.py +1114 -0
- attune/socratic/domain_templates.py +924 -0
- attune/socratic/embeddings.py +738 -0
- attune/socratic/engine.py +794 -0
- attune/socratic/explainer.py +682 -0
- attune/socratic/feedback.py +772 -0
- attune/socratic/forms.py +629 -0
- attune/socratic/generator.py +732 -0
- attune/socratic/llm_analyzer.py +637 -0
- attune/socratic/mcp_server.py +702 -0
- attune/socratic/session.py +312 -0
- attune/socratic/storage.py +667 -0
- attune/socratic/success.py +730 -0
- attune/socratic/visual_editor.py +860 -0
- attune/socratic/web_ui.py +958 -0
- attune/telemetry/__init__.py +39 -0
- attune/telemetry/agent_coordination.py +475 -0
- attune/telemetry/agent_tracking.py +367 -0
- attune/telemetry/approval_gates.py +545 -0
- attune/telemetry/cli.py +1231 -0
- attune/telemetry/commands/__init__.py +14 -0
- attune/telemetry/commands/dashboard_commands.py +696 -0
- attune/telemetry/event_streaming.py +409 -0
- attune/telemetry/feedback_loop.py +567 -0
- attune/telemetry/usage_tracker.py +591 -0
- attune/templates.py +754 -0
- attune/test_generator/__init__.py +38 -0
- attune/test_generator/__main__.py +14 -0
- attune/test_generator/cli.py +234 -0
- attune/test_generator/generator.py +355 -0
- attune/test_generator/risk_analyzer.py +216 -0
- attune/test_generator/templates/unit_test.py.jinja2 +272 -0
- attune/tier_recommender.py +384 -0
- attune/tools.py +183 -0
- attune/trust/__init__.py +28 -0
- attune/trust/circuit_breaker.py +579 -0
- attune/trust_building.py +527 -0
- attune/validation/__init__.py +19 -0
- attune/validation/xml_validator.py +281 -0
- attune/vscode_bridge.py +173 -0
- attune/workflow_commands.py +780 -0
- attune/workflow_patterns/__init__.py +33 -0
- attune/workflow_patterns/behavior.py +249 -0
- attune/workflow_patterns/core.py +76 -0
- attune/workflow_patterns/output.py +99 -0
- attune/workflow_patterns/registry.py +255 -0
- attune/workflow_patterns/structural.py +288 -0
- attune/workflows/__init__.py +539 -0
- attune/workflows/autonomous_test_gen.py +1268 -0
- attune/workflows/base.py +2667 -0
- attune/workflows/batch_processing.py +342 -0
- attune/workflows/bug_predict.py +1084 -0
- attune/workflows/builder.py +273 -0
- attune/workflows/caching.py +253 -0
- attune/workflows/code_review.py +1048 -0
- attune/workflows/code_review_adapters.py +312 -0
- attune/workflows/code_review_pipeline.py +722 -0
- attune/workflows/config.py +645 -0
- attune/workflows/dependency_check.py +644 -0
- attune/workflows/document_gen/__init__.py +25 -0
- attune/workflows/document_gen/config.py +30 -0
- attune/workflows/document_gen/report_formatter.py +162 -0
- attune/workflows/document_gen/workflow.py +1426 -0
- attune/workflows/document_manager.py +216 -0
- attune/workflows/document_manager_README.md +134 -0
- attune/workflows/documentation_orchestrator.py +1205 -0
- attune/workflows/history.py +510 -0
- attune/workflows/keyboard_shortcuts/__init__.py +39 -0
- attune/workflows/keyboard_shortcuts/generators.py +391 -0
- attune/workflows/keyboard_shortcuts/parsers.py +416 -0
- attune/workflows/keyboard_shortcuts/prompts.py +295 -0
- attune/workflows/keyboard_shortcuts/schema.py +193 -0
- attune/workflows/keyboard_shortcuts/workflow.py +509 -0
- attune/workflows/llm_base.py +363 -0
- attune/workflows/manage_docs.py +87 -0
- attune/workflows/manage_docs_README.md +134 -0
- attune/workflows/manage_documentation.py +821 -0
- attune/workflows/new_sample_workflow1.py +149 -0
- attune/workflows/new_sample_workflow1_README.md +150 -0
- attune/workflows/orchestrated_health_check.py +849 -0
- attune/workflows/orchestrated_release_prep.py +600 -0
- attune/workflows/output.py +413 -0
- attune/workflows/perf_audit.py +863 -0
- attune/workflows/pr_review.py +762 -0
- attune/workflows/progress.py +785 -0
- attune/workflows/progress_server.py +322 -0
- attune/workflows/progressive/README 2.md +454 -0
- attune/workflows/progressive/README.md +454 -0
- attune/workflows/progressive/__init__.py +82 -0
- attune/workflows/progressive/cli.py +219 -0
- attune/workflows/progressive/core.py +488 -0
- attune/workflows/progressive/orchestrator.py +723 -0
- attune/workflows/progressive/reports.py +520 -0
- attune/workflows/progressive/telemetry.py +274 -0
- attune/workflows/progressive/test_gen.py +495 -0
- attune/workflows/progressive/workflow.py +589 -0
- attune/workflows/refactor_plan.py +694 -0
- attune/workflows/release_prep.py +895 -0
- attune/workflows/release_prep_crew.py +969 -0
- attune/workflows/research_synthesis.py +404 -0
- attune/workflows/routing.py +168 -0
- attune/workflows/secure_release.py +593 -0
- attune/workflows/security_adapters.py +297 -0
- attune/workflows/security_audit.py +1329 -0
- attune/workflows/security_audit_phase3.py +355 -0
- attune/workflows/seo_optimization.py +633 -0
- attune/workflows/step_config.py +234 -0
- attune/workflows/telemetry_mixin.py +269 -0
- attune/workflows/test5.py +125 -0
- attune/workflows/test5_README.md +158 -0
- attune/workflows/test_coverage_boost_crew.py +849 -0
- attune/workflows/test_gen/__init__.py +52 -0
- attune/workflows/test_gen/ast_analyzer.py +249 -0
- attune/workflows/test_gen/config.py +88 -0
- attune/workflows/test_gen/data_models.py +38 -0
- attune/workflows/test_gen/report_formatter.py +289 -0
- attune/workflows/test_gen/test_templates.py +381 -0
- attune/workflows/test_gen/workflow.py +655 -0
- attune/workflows/test_gen.py +54 -0
- attune/workflows/test_gen_behavioral.py +477 -0
- attune/workflows/test_gen_parallel.py +341 -0
- attune/workflows/test_lifecycle.py +526 -0
- attune/workflows/test_maintenance.py +627 -0
- attune/workflows/test_maintenance_cli.py +590 -0
- attune/workflows/test_maintenance_crew.py +840 -0
- attune/workflows/test_runner.py +622 -0
- attune/workflows/tier_tracking.py +531 -0
- attune/workflows/xml_enhanced_crew.py +285 -0
- attune_ai-2.0.0.dist-info/METADATA +1026 -0
- attune_ai-2.0.0.dist-info/RECORD +457 -0
- attune_ai-2.0.0.dist-info/WHEEL +5 -0
- attune_ai-2.0.0.dist-info/entry_points.txt +26 -0
- attune_ai-2.0.0.dist-info/licenses/LICENSE +201 -0
- attune_ai-2.0.0.dist-info/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +101 -0
- attune_ai-2.0.0.dist-info/top_level.txt +5 -0
- attune_healthcare/__init__.py +13 -0
- attune_healthcare/monitors/__init__.py +9 -0
- attune_healthcare/monitors/clinical_protocol_monitor.py +315 -0
- attune_healthcare/monitors/monitoring/__init__.py +44 -0
- attune_healthcare/monitors/monitoring/protocol_checker.py +300 -0
- attune_healthcare/monitors/monitoring/protocol_loader.py +214 -0
- attune_healthcare/monitors/monitoring/sensor_parsers.py +306 -0
- attune_healthcare/monitors/monitoring/trajectory_analyzer.py +389 -0
- attune_llm/README.md +553 -0
- attune_llm/__init__.py +28 -0
- attune_llm/agent_factory/__init__.py +53 -0
- attune_llm/agent_factory/adapters/__init__.py +85 -0
- attune_llm/agent_factory/adapters/autogen_adapter.py +312 -0
- attune_llm/agent_factory/adapters/crewai_adapter.py +483 -0
- attune_llm/agent_factory/adapters/haystack_adapter.py +298 -0
- attune_llm/agent_factory/adapters/langchain_adapter.py +362 -0
- attune_llm/agent_factory/adapters/langgraph_adapter.py +333 -0
- attune_llm/agent_factory/adapters/native.py +228 -0
- attune_llm/agent_factory/adapters/wizard_adapter.py +423 -0
- attune_llm/agent_factory/base.py +305 -0
- attune_llm/agent_factory/crews/__init__.py +67 -0
- attune_llm/agent_factory/crews/code_review.py +1113 -0
- attune_llm/agent_factory/crews/health_check.py +1262 -0
- attune_llm/agent_factory/crews/refactoring.py +1128 -0
- attune_llm/agent_factory/crews/security_audit.py +1018 -0
- attune_llm/agent_factory/decorators.py +287 -0
- attune_llm/agent_factory/factory.py +558 -0
- attune_llm/agent_factory/framework.py +193 -0
- attune_llm/agent_factory/memory_integration.py +328 -0
- attune_llm/agent_factory/resilient.py +320 -0
- attune_llm/agents_md/__init__.py +22 -0
- attune_llm/agents_md/loader.py +218 -0
- attune_llm/agents_md/parser.py +271 -0
- attune_llm/agents_md/registry.py +307 -0
- attune_llm/claude_memory.py +466 -0
- attune_llm/cli/__init__.py +8 -0
- attune_llm/cli/sync_claude.py +487 -0
- attune_llm/code_health.py +1313 -0
- attune_llm/commands/__init__.py +51 -0
- attune_llm/commands/context.py +375 -0
- attune_llm/commands/loader.py +301 -0
- attune_llm/commands/models.py +231 -0
- attune_llm/commands/parser.py +371 -0
- attune_llm/commands/registry.py +429 -0
- attune_llm/config/__init__.py +29 -0
- attune_llm/config/unified.py +291 -0
- attune_llm/context/__init__.py +22 -0
- attune_llm/context/compaction.py +455 -0
- attune_llm/context/manager.py +434 -0
- attune_llm/contextual_patterns.py +361 -0
- attune_llm/core.py +907 -0
- attune_llm/git_pattern_extractor.py +435 -0
- attune_llm/hooks/__init__.py +24 -0
- attune_llm/hooks/config.py +306 -0
- attune_llm/hooks/executor.py +289 -0
- attune_llm/hooks/registry.py +302 -0
- attune_llm/hooks/scripts/__init__.py +39 -0
- attune_llm/hooks/scripts/evaluate_session.py +201 -0
- attune_llm/hooks/scripts/first_time_init.py +285 -0
- attune_llm/hooks/scripts/pre_compact.py +207 -0
- attune_llm/hooks/scripts/session_end.py +183 -0
- attune_llm/hooks/scripts/session_start.py +163 -0
- attune_llm/hooks/scripts/suggest_compact.py +225 -0
- attune_llm/learning/__init__.py +30 -0
- attune_llm/learning/evaluator.py +438 -0
- attune_llm/learning/extractor.py +514 -0
- attune_llm/learning/storage.py +560 -0
- attune_llm/levels.py +227 -0
- attune_llm/pattern_confidence.py +414 -0
- attune_llm/pattern_resolver.py +272 -0
- attune_llm/pattern_summary.py +350 -0
- attune_llm/providers.py +967 -0
- attune_llm/routing/__init__.py +32 -0
- attune_llm/routing/model_router.py +362 -0
- attune_llm/security/IMPLEMENTATION_SUMMARY.md +413 -0
- attune_llm/security/PHASE2_COMPLETE.md +384 -0
- attune_llm/security/PHASE2_SECRETS_DETECTOR_COMPLETE.md +271 -0
- attune_llm/security/QUICK_REFERENCE.md +316 -0
- attune_llm/security/README.md +262 -0
- attune_llm/security/__init__.py +62 -0
- attune_llm/security/audit_logger.py +929 -0
- attune_llm/security/audit_logger_example.py +152 -0
- attune_llm/security/pii_scrubber.py +640 -0
- attune_llm/security/secrets_detector.py +678 -0
- attune_llm/security/secrets_detector_example.py +304 -0
- attune_llm/security/secure_memdocs.py +1192 -0
- attune_llm/security/secure_memdocs_example.py +278 -0
- attune_llm/session_status.py +745 -0
- attune_llm/state.py +246 -0
- attune_llm/utils/__init__.py +5 -0
- attune_llm/utils/tokens.py +349 -0
- attune_software/SOFTWARE_PLUGIN_README.md +57 -0
- attune_software/__init__.py +13 -0
- attune_software/cli/__init__.py +120 -0
- attune_software/cli/inspect.py +362 -0
- attune_software/cli.py +574 -0
- attune_software/plugin.py +188 -0
- workflow_scaffolding/__init__.py +11 -0
- workflow_scaffolding/__main__.py +12 -0
- workflow_scaffolding/cli.py +206 -0
- workflow_scaffolding/generator.py +265 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
"""Cross-Session Agent Communication Protocol.
|
|
2
|
+
|
|
3
|
+
This module enables agents across different Claude Code sessions to communicate
|
|
4
|
+
and coordinate via Redis-backed short-term memory.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Session discovery and announcement
|
|
8
|
+
- Priority-based conflict resolution
|
|
9
|
+
- Task queue coordination
|
|
10
|
+
- Shared state management
|
|
11
|
+
- Agent-to-agent signaling
|
|
12
|
+
|
|
13
|
+
Requires Redis (not available in mock mode).
|
|
14
|
+
|
|
15
|
+
Copyright 2025-2026 Smart AI Memory, LLC
|
|
16
|
+
Licensed under Fair Source 0.9
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import secrets
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import datetime, timedelta
|
|
29
|
+
from enum import Enum
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import structlog
|
|
33
|
+
|
|
34
|
+
from .short_term import AccessTier, AgentCredentials, RedisShortTermMemory
|
|
35
|
+
|
|
36
|
+
logger = structlog.get_logger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# === Constants ===
|
|
40
|
+
|
|
41
|
+
CHANNEL_SESSIONS = "empathy:sessions"
|
|
42
|
+
KEY_ACTIVE_AGENTS = "empathy:active_agents"
|
|
43
|
+
KEY_SERVICE_LOCK = "empathy:service_lock"
|
|
44
|
+
KEY_SERVICE_HEARTBEAT = "empathy:service_heartbeat"
|
|
45
|
+
|
|
46
|
+
HEARTBEAT_INTERVAL_SECONDS = 30
|
|
47
|
+
STALE_THRESHOLD_SECONDS = 90
|
|
48
|
+
SERVICE_LOCK_TTL_SECONDS = 60
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SessionType(Enum):
|
|
52
|
+
"""Type of session/agent."""
|
|
53
|
+
|
|
54
|
+
CLAUDE = "claude" # Interactive Claude Code session
|
|
55
|
+
SERVICE = "service" # Background service/daemon
|
|
56
|
+
WORKER = "worker" # Task worker agent
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConflictStrategy(Enum):
|
|
60
|
+
"""Strategy for resolving conflicts between agents."""
|
|
61
|
+
|
|
62
|
+
PRIORITY_BASED = "priority" # Higher access tier wins
|
|
63
|
+
FIRST_WRITE_WINS = "first_write" # First to write wins
|
|
64
|
+
LAST_WRITE_WINS = "last_write" # Last to write wins
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class SessionInfo:
|
|
69
|
+
"""Information about an active session."""
|
|
70
|
+
|
|
71
|
+
agent_id: str
|
|
72
|
+
session_type: SessionType
|
|
73
|
+
access_tier: AccessTier
|
|
74
|
+
capabilities: list[str]
|
|
75
|
+
started_at: datetime
|
|
76
|
+
last_heartbeat: datetime
|
|
77
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict[str, Any]:
|
|
80
|
+
"""Convert to dictionary for storage."""
|
|
81
|
+
return {
|
|
82
|
+
"agent_id": self.agent_id,
|
|
83
|
+
"session_type": self.session_type.value,
|
|
84
|
+
"access_tier": self.access_tier.value,
|
|
85
|
+
"capabilities": self.capabilities,
|
|
86
|
+
"started_at": self.started_at.isoformat(),
|
|
87
|
+
"last_heartbeat": self.last_heartbeat.isoformat(),
|
|
88
|
+
"metadata": self.metadata,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_dict(cls, data: dict[str, Any]) -> SessionInfo:
|
|
93
|
+
"""Create from dictionary."""
|
|
94
|
+
return cls(
|
|
95
|
+
agent_id=data["agent_id"],
|
|
96
|
+
session_type=SessionType(data["session_type"]),
|
|
97
|
+
access_tier=AccessTier(data["access_tier"]),
|
|
98
|
+
capabilities=data.get("capabilities", []),
|
|
99
|
+
started_at=datetime.fromisoformat(data["started_at"]),
|
|
100
|
+
last_heartbeat=datetime.fromisoformat(data["last_heartbeat"]),
|
|
101
|
+
metadata=data.get("metadata", {}),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def is_stale(self) -> bool:
|
|
106
|
+
"""Check if session is stale (no recent heartbeat)."""
|
|
107
|
+
threshold = datetime.now() - timedelta(seconds=STALE_THRESHOLD_SECONDS)
|
|
108
|
+
return self.last_heartbeat < threshold
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class ConflictResult:
|
|
113
|
+
"""Result of a conflict resolution."""
|
|
114
|
+
|
|
115
|
+
winner_agent_id: str
|
|
116
|
+
loser_agent_id: str
|
|
117
|
+
resource_key: str
|
|
118
|
+
strategy_used: ConflictStrategy
|
|
119
|
+
reason: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def generate_agent_id(session_type: SessionType) -> str:
|
|
123
|
+
"""Generate a unique agent ID.
|
|
124
|
+
|
|
125
|
+
Format: {session_type}_{timestamp}_{random_suffix}
|
|
126
|
+
Example: claude_20260120_a1b2c3
|
|
127
|
+
"""
|
|
128
|
+
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
129
|
+
suffix = secrets.token_hex(3) # 6 character hex string
|
|
130
|
+
return f"{session_type.value}_{timestamp}_{suffix}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class CrossSessionCoordinator:
|
|
134
|
+
"""Coordinator for cross-session agent communication.
|
|
135
|
+
|
|
136
|
+
This class manages session discovery, conflict resolution, and
|
|
137
|
+
coordination between agents across different Claude Code sessions.
|
|
138
|
+
|
|
139
|
+
Requires Redis - not available in mock mode.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
memory: RedisShortTermMemory,
|
|
145
|
+
session_type: SessionType = SessionType.CLAUDE,
|
|
146
|
+
access_tier: AccessTier = AccessTier.CONTRIBUTOR,
|
|
147
|
+
capabilities: list[str] | None = None,
|
|
148
|
+
auto_announce: bool = True,
|
|
149
|
+
):
|
|
150
|
+
"""Initialize cross-session coordinator.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
memory: RedisShortTermMemory instance (must not be mock)
|
|
154
|
+
session_type: Type of this session
|
|
155
|
+
access_tier: Access tier for this session
|
|
156
|
+
capabilities: List of capabilities this session supports
|
|
157
|
+
auto_announce: Whether to announce presence on init
|
|
158
|
+
"""
|
|
159
|
+
if memory.use_mock:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
"Cross-session communication requires Redis. "
|
|
162
|
+
"Mock mode does not support cross-session features."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self._memory = memory
|
|
166
|
+
self._session_type = session_type
|
|
167
|
+
self._access_tier = access_tier
|
|
168
|
+
self._capabilities = capabilities or ["stash", "retrieve", "queue", "signal"]
|
|
169
|
+
|
|
170
|
+
# Generate unique agent ID
|
|
171
|
+
self._agent_id = generate_agent_id(session_type)
|
|
172
|
+
self._credentials = AgentCredentials(
|
|
173
|
+
agent_id=self._agent_id,
|
|
174
|
+
tier=access_tier,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Session info
|
|
178
|
+
self._session_info = SessionInfo(
|
|
179
|
+
agent_id=self._agent_id,
|
|
180
|
+
session_type=session_type,
|
|
181
|
+
access_tier=access_tier,
|
|
182
|
+
capabilities=self._capabilities,
|
|
183
|
+
started_at=datetime.now(),
|
|
184
|
+
last_heartbeat=datetime.now(),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Heartbeat thread
|
|
188
|
+
self._heartbeat_thread: threading.Thread | None = None
|
|
189
|
+
self._heartbeat_stop = threading.Event()
|
|
190
|
+
|
|
191
|
+
# Event handlers
|
|
192
|
+
self._on_session_joined: list[Callable[[SessionInfo], None]] = []
|
|
193
|
+
self._on_session_left: list[Callable[[str], None]] = []
|
|
194
|
+
|
|
195
|
+
# Auto-announce if requested
|
|
196
|
+
if auto_announce:
|
|
197
|
+
self.announce()
|
|
198
|
+
self.start_heartbeat()
|
|
199
|
+
|
|
200
|
+
logger.info(
|
|
201
|
+
"cross_session_coordinator_initialized",
|
|
202
|
+
agent_id=self._agent_id,
|
|
203
|
+
session_type=session_type.value,
|
|
204
|
+
access_tier=access_tier.name,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def agent_id(self) -> str:
|
|
209
|
+
"""Get this session's agent ID."""
|
|
210
|
+
return self._agent_id
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def credentials(self) -> AgentCredentials:
|
|
214
|
+
"""Get this session's credentials."""
|
|
215
|
+
return self._credentials
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def session_info(self) -> SessionInfo:
|
|
219
|
+
"""Get this session's info."""
|
|
220
|
+
return self._session_info
|
|
221
|
+
|
|
222
|
+
# === Session Discovery ===
|
|
223
|
+
|
|
224
|
+
def announce(self) -> None:
|
|
225
|
+
"""Announce this session's presence to other sessions."""
|
|
226
|
+
client = self._memory._client
|
|
227
|
+
if client is None:
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
# Update active agents registry
|
|
231
|
+
session_data = json.dumps(self._session_info.to_dict())
|
|
232
|
+
client.hset(KEY_ACTIVE_AGENTS, self._agent_id, session_data)
|
|
233
|
+
|
|
234
|
+
# Publish announcement to sessions channel
|
|
235
|
+
announcement = {
|
|
236
|
+
"event": "session_joined",
|
|
237
|
+
"session": self._session_info.to_dict(),
|
|
238
|
+
}
|
|
239
|
+
client.publish(CHANNEL_SESSIONS, json.dumps(announcement))
|
|
240
|
+
|
|
241
|
+
logger.info(
|
|
242
|
+
"session_announced",
|
|
243
|
+
agent_id=self._agent_id,
|
|
244
|
+
session_type=self._session_type.value,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def depart(self) -> None:
|
|
248
|
+
"""Announce this session's departure."""
|
|
249
|
+
self.stop_heartbeat()
|
|
250
|
+
|
|
251
|
+
client = self._memory._client
|
|
252
|
+
if client is None:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
# Remove from active agents registry
|
|
256
|
+
client.hdel(KEY_ACTIVE_AGENTS, self._agent_id)
|
|
257
|
+
|
|
258
|
+
# Publish departure to sessions channel
|
|
259
|
+
departure = {
|
|
260
|
+
"event": "session_left",
|
|
261
|
+
"agent_id": self._agent_id,
|
|
262
|
+
}
|
|
263
|
+
client.publish(CHANNEL_SESSIONS, json.dumps(departure))
|
|
264
|
+
|
|
265
|
+
logger.info("session_departed", agent_id=self._agent_id)
|
|
266
|
+
|
|
267
|
+
def get_active_sessions(self) -> list[SessionInfo]:
|
|
268
|
+
"""Get all active sessions.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
List of SessionInfo for all active sessions
|
|
272
|
+
"""
|
|
273
|
+
client = self._memory._client
|
|
274
|
+
if client is None:
|
|
275
|
+
return []
|
|
276
|
+
|
|
277
|
+
sessions = []
|
|
278
|
+
all_agents = client.hgetall(KEY_ACTIVE_AGENTS)
|
|
279
|
+
|
|
280
|
+
for agent_id, session_data in all_agents.items():
|
|
281
|
+
try:
|
|
282
|
+
# Decode bytes if necessary
|
|
283
|
+
if isinstance(agent_id, bytes):
|
|
284
|
+
agent_id = agent_id.decode()
|
|
285
|
+
if isinstance(session_data, bytes):
|
|
286
|
+
session_data = session_data.decode()
|
|
287
|
+
|
|
288
|
+
info = SessionInfo.from_dict(json.loads(session_data))
|
|
289
|
+
|
|
290
|
+
# Skip stale sessions
|
|
291
|
+
if info.is_stale:
|
|
292
|
+
# Clean up stale session
|
|
293
|
+
client.hdel(KEY_ACTIVE_AGENTS, agent_id)
|
|
294
|
+
logger.debug("cleaned_stale_session", agent_id=agent_id)
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
sessions.append(info)
|
|
298
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
299
|
+
logger.warning(
|
|
300
|
+
"invalid_session_data",
|
|
301
|
+
agent_id=agent_id,
|
|
302
|
+
error=str(e),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
return sessions
|
|
306
|
+
|
|
307
|
+
def get_session(self, agent_id: str) -> SessionInfo | None:
|
|
308
|
+
"""Get info for a specific session.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
agent_id: Agent ID to look up
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
SessionInfo if found and not stale, None otherwise
|
|
315
|
+
"""
|
|
316
|
+
client = self._memory._client
|
|
317
|
+
if client is None:
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
session_data = client.hget(KEY_ACTIVE_AGENTS, agent_id)
|
|
321
|
+
if session_data is None:
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
if isinstance(session_data, bytes):
|
|
326
|
+
session_data = session_data.decode()
|
|
327
|
+
info = SessionInfo.from_dict(json.loads(session_data))
|
|
328
|
+
return info if not info.is_stale else None
|
|
329
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
# === Heartbeat ===
|
|
333
|
+
|
|
334
|
+
def start_heartbeat(self) -> None:
|
|
335
|
+
"""Start the heartbeat thread."""
|
|
336
|
+
if self._heartbeat_thread is not None:
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
self._heartbeat_stop.clear()
|
|
340
|
+
self._heartbeat_thread = threading.Thread(
|
|
341
|
+
target=self._heartbeat_loop,
|
|
342
|
+
daemon=True,
|
|
343
|
+
name=f"heartbeat-{self._agent_id}",
|
|
344
|
+
)
|
|
345
|
+
self._heartbeat_thread.start()
|
|
346
|
+
logger.debug("heartbeat_started", agent_id=self._agent_id)
|
|
347
|
+
|
|
348
|
+
def stop_heartbeat(self) -> None:
|
|
349
|
+
"""Stop the heartbeat thread."""
|
|
350
|
+
if self._heartbeat_thread is None:
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
self._heartbeat_stop.set()
|
|
354
|
+
self._heartbeat_thread.join(timeout=5)
|
|
355
|
+
self._heartbeat_thread = None
|
|
356
|
+
logger.debug("heartbeat_stopped", agent_id=self._agent_id)
|
|
357
|
+
|
|
358
|
+
def _heartbeat_loop(self) -> None:
|
|
359
|
+
"""Heartbeat loop - runs in background thread."""
|
|
360
|
+
while not self._heartbeat_stop.wait(HEARTBEAT_INTERVAL_SECONDS):
|
|
361
|
+
self._send_heartbeat()
|
|
362
|
+
|
|
363
|
+
def _send_heartbeat(self) -> None:
|
|
364
|
+
"""Send a heartbeat update."""
|
|
365
|
+
client = self._memory._client
|
|
366
|
+
if client is None:
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
# Update last heartbeat
|
|
370
|
+
self._session_info.last_heartbeat = datetime.now()
|
|
371
|
+
session_data = json.dumps(self._session_info.to_dict())
|
|
372
|
+
client.hset(KEY_ACTIVE_AGENTS, self._agent_id, session_data)
|
|
373
|
+
|
|
374
|
+
# === Conflict Resolution ===
|
|
375
|
+
|
|
376
|
+
def resolve_conflict(
|
|
377
|
+
self,
|
|
378
|
+
resource_key: str,
|
|
379
|
+
other_agent_id: str,
|
|
380
|
+
strategy: ConflictStrategy = ConflictStrategy.PRIORITY_BASED,
|
|
381
|
+
) -> ConflictResult:
|
|
382
|
+
"""Resolve a conflict between this session and another.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
resource_key: Key of the contested resource
|
|
386
|
+
other_agent_id: Agent ID of the other party
|
|
387
|
+
strategy: Strategy to use for resolution
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
ConflictResult with winner and loser
|
|
391
|
+
"""
|
|
392
|
+
other_session = self.get_session(other_agent_id)
|
|
393
|
+
|
|
394
|
+
if strategy == ConflictStrategy.PRIORITY_BASED:
|
|
395
|
+
return self._resolve_by_priority(resource_key, other_session)
|
|
396
|
+
elif strategy == ConflictStrategy.FIRST_WRITE_WINS:
|
|
397
|
+
return self._resolve_first_write(resource_key, other_session)
|
|
398
|
+
else: # LAST_WRITE_WINS
|
|
399
|
+
return self._resolve_last_write(resource_key, other_session)
|
|
400
|
+
|
|
401
|
+
def _resolve_by_priority(
|
|
402
|
+
self,
|
|
403
|
+
resource_key: str,
|
|
404
|
+
other_session: SessionInfo | None,
|
|
405
|
+
) -> ConflictResult:
|
|
406
|
+
"""Resolve conflict using priority (access tier)."""
|
|
407
|
+
my_tier = self._access_tier.value
|
|
408
|
+
other_tier = other_session.access_tier.value if other_session else 0
|
|
409
|
+
other_id = other_session.agent_id if other_session else "unknown"
|
|
410
|
+
|
|
411
|
+
if my_tier > other_tier:
|
|
412
|
+
winner, loser = self._agent_id, other_id
|
|
413
|
+
reason = f"Higher tier ({self._access_tier.name} > {other_session.access_tier.name if other_session else 'N/A'})"
|
|
414
|
+
elif my_tier < other_tier:
|
|
415
|
+
winner, loser = other_id, self._agent_id
|
|
416
|
+
reason = f"Higher tier ({other_session.access_tier.name if other_session else 'N/A'} > {self._access_tier.name})"
|
|
417
|
+
else:
|
|
418
|
+
# Equal tier - use timestamp (first write wins)
|
|
419
|
+
if other_session and other_session.started_at < self._session_info.started_at:
|
|
420
|
+
winner, loser = other_id, self._agent_id
|
|
421
|
+
reason = "Equal tier, earlier session wins"
|
|
422
|
+
else:
|
|
423
|
+
winner, loser = self._agent_id, other_id
|
|
424
|
+
reason = "Equal tier, earlier session wins"
|
|
425
|
+
|
|
426
|
+
return ConflictResult(
|
|
427
|
+
winner_agent_id=winner,
|
|
428
|
+
loser_agent_id=loser,
|
|
429
|
+
resource_key=resource_key,
|
|
430
|
+
strategy_used=ConflictStrategy.PRIORITY_BASED,
|
|
431
|
+
reason=reason,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def _resolve_first_write(
|
|
435
|
+
self,
|
|
436
|
+
resource_key: str,
|
|
437
|
+
other_session: SessionInfo | None,
|
|
438
|
+
) -> ConflictResult:
|
|
439
|
+
"""Resolve conflict using first-write-wins."""
|
|
440
|
+
# Check who owns the resource
|
|
441
|
+
client = self._memory._client
|
|
442
|
+
if client is None:
|
|
443
|
+
return ConflictResult(
|
|
444
|
+
winner_agent_id=self._agent_id,
|
|
445
|
+
loser_agent_id=other_session.agent_id if other_session else "unknown",
|
|
446
|
+
resource_key=resource_key,
|
|
447
|
+
strategy_used=ConflictStrategy.FIRST_WRITE_WINS,
|
|
448
|
+
reason="No Redis connection - local wins",
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Try to get lock on resource
|
|
452
|
+
lock_key = f"empathy:lock:{resource_key}"
|
|
453
|
+
acquired = client.setnx(lock_key, self._agent_id)
|
|
454
|
+
|
|
455
|
+
if acquired:
|
|
456
|
+
client.expire(lock_key, 300) # 5 minute lock
|
|
457
|
+
winner, loser = self._agent_id, other_session.agent_id if other_session else "unknown"
|
|
458
|
+
reason = "First to acquire lock"
|
|
459
|
+
else:
|
|
460
|
+
current_owner = client.get(lock_key)
|
|
461
|
+
if isinstance(current_owner, bytes):
|
|
462
|
+
current_owner = current_owner.decode()
|
|
463
|
+
winner = current_owner or "unknown"
|
|
464
|
+
loser = self._agent_id
|
|
465
|
+
reason = "Lock already held"
|
|
466
|
+
|
|
467
|
+
return ConflictResult(
|
|
468
|
+
winner_agent_id=winner,
|
|
469
|
+
loser_agent_id=loser,
|
|
470
|
+
resource_key=resource_key,
|
|
471
|
+
strategy_used=ConflictStrategy.FIRST_WRITE_WINS,
|
|
472
|
+
reason=reason,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
def _resolve_last_write(
|
|
476
|
+
self,
|
|
477
|
+
resource_key: str,
|
|
478
|
+
other_session: SessionInfo | None,
|
|
479
|
+
) -> ConflictResult:
|
|
480
|
+
"""Resolve conflict using last-write-wins (current writer wins)."""
|
|
481
|
+
return ConflictResult(
|
|
482
|
+
winner_agent_id=self._agent_id,
|
|
483
|
+
loser_agent_id=other_session.agent_id if other_session else "unknown",
|
|
484
|
+
resource_key=resource_key,
|
|
485
|
+
strategy_used=ConflictStrategy.LAST_WRITE_WINS,
|
|
486
|
+
reason="Last write wins - current writer takes precedence",
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# === Distributed Locking ===
|
|
490
|
+
|
|
491
|
+
def acquire_lock(
|
|
492
|
+
self,
|
|
493
|
+
resource_key: str,
|
|
494
|
+
timeout_seconds: int = 300,
|
|
495
|
+
) -> bool:
|
|
496
|
+
"""Acquire a distributed lock on a resource.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
resource_key: Key of the resource to lock
|
|
500
|
+
timeout_seconds: Lock timeout in seconds
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
True if lock acquired, False otherwise
|
|
504
|
+
"""
|
|
505
|
+
client = self._memory._client
|
|
506
|
+
if client is None:
|
|
507
|
+
return False
|
|
508
|
+
|
|
509
|
+
lock_key = f"empathy:lock:{resource_key}"
|
|
510
|
+
acquired = client.setnx(lock_key, self._agent_id)
|
|
511
|
+
|
|
512
|
+
if acquired:
|
|
513
|
+
client.expire(lock_key, timeout_seconds)
|
|
514
|
+
logger.debug(
|
|
515
|
+
"lock_acquired",
|
|
516
|
+
resource_key=resource_key,
|
|
517
|
+
agent_id=self._agent_id,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
return bool(acquired)
|
|
521
|
+
|
|
522
|
+
def release_lock(self, resource_key: str) -> bool:
|
|
523
|
+
"""Release a distributed lock.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
resource_key: Key of the resource to unlock
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
True if lock released, False if not owner
|
|
530
|
+
"""
|
|
531
|
+
client = self._memory._client
|
|
532
|
+
if client is None:
|
|
533
|
+
return False
|
|
534
|
+
|
|
535
|
+
lock_key = f"empathy:lock:{resource_key}"
|
|
536
|
+
current_owner = client.get(lock_key)
|
|
537
|
+
|
|
538
|
+
if current_owner:
|
|
539
|
+
if isinstance(current_owner, bytes):
|
|
540
|
+
current_owner = current_owner.decode()
|
|
541
|
+
if current_owner == self._agent_id:
|
|
542
|
+
client.delete(lock_key)
|
|
543
|
+
logger.debug(
|
|
544
|
+
"lock_released",
|
|
545
|
+
resource_key=resource_key,
|
|
546
|
+
agent_id=self._agent_id,
|
|
547
|
+
)
|
|
548
|
+
return True
|
|
549
|
+
|
|
550
|
+
return False
|
|
551
|
+
|
|
552
|
+
def check_lock(self, resource_key: str) -> str | None:
|
|
553
|
+
"""Check who holds a lock on a resource.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
resource_key: Key of the resource
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
Agent ID of lock holder, or None if unlocked
|
|
560
|
+
"""
|
|
561
|
+
client = self._memory._client
|
|
562
|
+
if client is None:
|
|
563
|
+
return None
|
|
564
|
+
|
|
565
|
+
lock_key = f"empathy:lock:{resource_key}"
|
|
566
|
+
owner = client.get(lock_key)
|
|
567
|
+
|
|
568
|
+
if owner:
|
|
569
|
+
if isinstance(owner, bytes):
|
|
570
|
+
return owner.decode()
|
|
571
|
+
return str(owner)
|
|
572
|
+
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
# === Event Handlers ===
|
|
576
|
+
|
|
577
|
+
def on_session_joined(self, handler: Callable[[SessionInfo], None]) -> None:
|
|
578
|
+
"""Register handler for when a session joins.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
handler: Callback receiving SessionInfo of joining session
|
|
582
|
+
"""
|
|
583
|
+
self._on_session_joined.append(handler)
|
|
584
|
+
|
|
585
|
+
def on_session_left(self, handler: Callable[[str], None]) -> None:
|
|
586
|
+
"""Register handler for when a session leaves.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
handler: Callback receiving agent_id of departing session
|
|
590
|
+
"""
|
|
591
|
+
self._on_session_left.append(handler)
|
|
592
|
+
|
|
593
|
+
def subscribe_to_sessions(self) -> None:
|
|
594
|
+
"""Subscribe to session events (join/leave).
|
|
595
|
+
|
|
596
|
+
Note: This blocks and should be called in a separate thread.
|
|
597
|
+
"""
|
|
598
|
+
client = self._memory._client
|
|
599
|
+
if client is None:
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
pubsub = client.pubsub()
|
|
603
|
+
pubsub.subscribe(CHANNEL_SESSIONS)
|
|
604
|
+
|
|
605
|
+
for message in pubsub.listen():
|
|
606
|
+
if message["type"] != "message":
|
|
607
|
+
continue
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
data = message["data"]
|
|
611
|
+
if isinstance(data, bytes):
|
|
612
|
+
data = data.decode()
|
|
613
|
+
event = json.loads(data)
|
|
614
|
+
|
|
615
|
+
if event.get("event") == "session_joined":
|
|
616
|
+
session_info = SessionInfo.from_dict(event["session"])
|
|
617
|
+
for joined_handler in self._on_session_joined:
|
|
618
|
+
joined_handler(session_info)
|
|
619
|
+
elif event.get("event") == "session_left":
|
|
620
|
+
agent_id = event["agent_id"]
|
|
621
|
+
for left_handler in self._on_session_left:
|
|
622
|
+
left_handler(agent_id)
|
|
623
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
624
|
+
logger.warning("invalid_session_event", error=str(e))
|
|
625
|
+
|
|
626
|
+
# === Cleanup ===
|
|
627
|
+
|
|
628
|
+
def close(self) -> None:
|
|
629
|
+
"""Clean up and depart."""
|
|
630
|
+
self.depart()
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# === Background Service ===
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class BackgroundService:
|
|
637
|
+
"""Background service daemon for cross-session coordination.
|
|
638
|
+
|
|
639
|
+
This service runs persistently to:
|
|
640
|
+
- Maintain registry of active sessions
|
|
641
|
+
- Aggregate results from completed tasks
|
|
642
|
+
- Clean up stale session data
|
|
643
|
+
- Coordinate conflict resolution
|
|
644
|
+
- Promote patterns to long-term memory (when ready)
|
|
645
|
+
"""
|
|
646
|
+
|
|
647
|
+
def __init__(
|
|
648
|
+
self,
|
|
649
|
+
memory: RedisShortTermMemory,
|
|
650
|
+
auto_start_on_connect: bool = True,
|
|
651
|
+
):
|
|
652
|
+
"""Initialize background service.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
memory: RedisShortTermMemory instance
|
|
656
|
+
auto_start_on_connect: Start automatically when first session connects
|
|
657
|
+
"""
|
|
658
|
+
if memory.use_mock:
|
|
659
|
+
raise ValueError(
|
|
660
|
+
"Background service requires Redis. "
|
|
661
|
+
"Mock mode does not support cross-session features."
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
self._memory = memory
|
|
665
|
+
self._auto_start = auto_start_on_connect
|
|
666
|
+
self._coordinator: CrossSessionCoordinator | None = None
|
|
667
|
+
self._running = False
|
|
668
|
+
self._service_thread: threading.Thread | None = None
|
|
669
|
+
self._stop_event = threading.Event()
|
|
670
|
+
|
|
671
|
+
logger.info("background_service_initialized")
|
|
672
|
+
|
|
673
|
+
@property
|
|
674
|
+
def is_running(self) -> bool:
|
|
675
|
+
"""Check if service is running."""
|
|
676
|
+
return self._running
|
|
677
|
+
|
|
678
|
+
def start(self) -> bool:
|
|
679
|
+
"""Start the background service.
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
True if started, False if already running or couldn't acquire lock
|
|
683
|
+
"""
|
|
684
|
+
if self._running:
|
|
685
|
+
logger.warning("service_already_running")
|
|
686
|
+
return False
|
|
687
|
+
|
|
688
|
+
# Try to acquire service lock (only one service can run)
|
|
689
|
+
if not self._acquire_service_lock():
|
|
690
|
+
logger.warning("service_lock_held_by_another")
|
|
691
|
+
return False
|
|
692
|
+
|
|
693
|
+
# Create coordinator for service
|
|
694
|
+
self._coordinator = CrossSessionCoordinator(
|
|
695
|
+
memory=self._memory,
|
|
696
|
+
session_type=SessionType.SERVICE,
|
|
697
|
+
access_tier=AccessTier.STEWARD,
|
|
698
|
+
capabilities=["coordinate", "aggregate", "cleanup", "promote"],
|
|
699
|
+
auto_announce=True,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
# Start service loop
|
|
703
|
+
self._running = True
|
|
704
|
+
self._stop_event.clear()
|
|
705
|
+
self._service_thread = threading.Thread(
|
|
706
|
+
target=self._service_loop,
|
|
707
|
+
daemon=True,
|
|
708
|
+
name="empathy-service",
|
|
709
|
+
)
|
|
710
|
+
self._service_thread.start()
|
|
711
|
+
|
|
712
|
+
logger.info(
|
|
713
|
+
"background_service_started",
|
|
714
|
+
agent_id=self._coordinator.agent_id,
|
|
715
|
+
)
|
|
716
|
+
return True
|
|
717
|
+
|
|
718
|
+
def stop(self) -> None:
|
|
719
|
+
"""Stop the background service."""
|
|
720
|
+
if not self._running:
|
|
721
|
+
return
|
|
722
|
+
|
|
723
|
+
self._stop_event.set()
|
|
724
|
+
|
|
725
|
+
if self._service_thread:
|
|
726
|
+
self._service_thread.join(timeout=10)
|
|
727
|
+
self._service_thread = None
|
|
728
|
+
|
|
729
|
+
if self._coordinator:
|
|
730
|
+
self._coordinator.close()
|
|
731
|
+
self._coordinator = None
|
|
732
|
+
|
|
733
|
+
self._release_service_lock()
|
|
734
|
+
self._running = False
|
|
735
|
+
|
|
736
|
+
logger.info("background_service_stopped")
|
|
737
|
+
|
|
738
|
+
def _acquire_service_lock(self) -> bool:
|
|
739
|
+
"""Try to acquire the service lock."""
|
|
740
|
+
client = self._memory._client
|
|
741
|
+
if client is None:
|
|
742
|
+
return False
|
|
743
|
+
|
|
744
|
+
# Use SETNX for atomic lock acquisition
|
|
745
|
+
acquired = client.setnx(KEY_SERVICE_LOCK, os.getpid())
|
|
746
|
+
if acquired:
|
|
747
|
+
client.expire(KEY_SERVICE_LOCK, SERVICE_LOCK_TTL_SECONDS)
|
|
748
|
+
return bool(acquired)
|
|
749
|
+
|
|
750
|
+
def _release_service_lock(self) -> None:
|
|
751
|
+
"""Release the service lock."""
|
|
752
|
+
client = self._memory._client
|
|
753
|
+
if client:
|
|
754
|
+
client.delete(KEY_SERVICE_LOCK)
|
|
755
|
+
|
|
756
|
+
def _refresh_service_lock(self) -> None:
|
|
757
|
+
"""Refresh the service lock TTL."""
|
|
758
|
+
client = self._memory._client
|
|
759
|
+
if client:
|
|
760
|
+
client.expire(KEY_SERVICE_LOCK, SERVICE_LOCK_TTL_SECONDS)
|
|
761
|
+
client.set(KEY_SERVICE_HEARTBEAT, datetime.now().isoformat())
|
|
762
|
+
|
|
763
|
+
def _service_loop(self) -> None:
|
|
764
|
+
"""Main service loop."""
|
|
765
|
+
cleanup_interval = 60 # Clean up stale sessions every 60 seconds
|
|
766
|
+
last_cleanup = time.time()
|
|
767
|
+
|
|
768
|
+
while not self._stop_event.wait(10): # Check every 10 seconds
|
|
769
|
+
try:
|
|
770
|
+
# Refresh service lock
|
|
771
|
+
self._refresh_service_lock()
|
|
772
|
+
|
|
773
|
+
# Periodic cleanup
|
|
774
|
+
if time.time() - last_cleanup > cleanup_interval:
|
|
775
|
+
self._cleanup_stale_sessions()
|
|
776
|
+
last_cleanup = time.time()
|
|
777
|
+
|
|
778
|
+
except Exception as e:
|
|
779
|
+
logger.exception("service_loop_error", error=str(e))
|
|
780
|
+
|
|
781
|
+
def _cleanup_stale_sessions(self) -> None:
|
|
782
|
+
"""Clean up stale session data."""
|
|
783
|
+
if not self._coordinator:
|
|
784
|
+
return
|
|
785
|
+
|
|
786
|
+
# Get all sessions (this already cleans stale ones)
|
|
787
|
+
sessions = self._coordinator.get_active_sessions()
|
|
788
|
+
logger.debug(
|
|
789
|
+
"cleanup_completed",
|
|
790
|
+
active_sessions=len(sessions),
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
def get_status(self) -> dict[str, Any]:
|
|
794
|
+
"""Get service status.
|
|
795
|
+
|
|
796
|
+
Returns:
|
|
797
|
+
Dict with service status information
|
|
798
|
+
"""
|
|
799
|
+
status: dict[str, Any] = {
|
|
800
|
+
"running": self._running,
|
|
801
|
+
"agent_id": self._coordinator.agent_id if self._coordinator else None,
|
|
802
|
+
"active_sessions": 0,
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if self._coordinator:
|
|
806
|
+
sessions = self._coordinator.get_active_sessions()
|
|
807
|
+
status["active_sessions"] = len(sessions)
|
|
808
|
+
status["sessions"] = [s.to_dict() for s in sessions]
|
|
809
|
+
|
|
810
|
+
return status
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
# === Convenience Functions ===
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def check_redis_cross_session_support(memory: RedisShortTermMemory) -> bool:
|
|
817
|
+
"""Check if Redis supports cross-session communication.
|
|
818
|
+
|
|
819
|
+
Args:
|
|
820
|
+
memory: RedisShortTermMemory instance
|
|
821
|
+
|
|
822
|
+
Returns:
|
|
823
|
+
True if Redis is available and not in mock mode
|
|
824
|
+
"""
|
|
825
|
+
return not memory.use_mock and memory._client is not None
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def get_or_start_service(memory: RedisShortTermMemory) -> BackgroundService | None:
|
|
829
|
+
"""Get existing service or start a new one.
|
|
830
|
+
|
|
831
|
+
Args:
|
|
832
|
+
memory: RedisShortTermMemory instance
|
|
833
|
+
|
|
834
|
+
Returns:
|
|
835
|
+
BackgroundService if started/running, None if unavailable
|
|
836
|
+
"""
|
|
837
|
+
if not check_redis_cross_session_support(memory):
|
|
838
|
+
return None
|
|
839
|
+
|
|
840
|
+
service = BackgroundService(memory)
|
|
841
|
+
if service.start():
|
|
842
|
+
return service
|
|
843
|
+
|
|
844
|
+
# Service already running elsewhere
|
|
845
|
+
return None
|