apsimo 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apsimo/__init__.py +38 -0
- apsimo/__main__.py +6 -0
- apsimo/agent/__init__.py +6 -0
- apsimo/agent/client.py +276 -0
- apsimo/agent/models.py +46 -0
- apsimo/agents/__init__.py +20 -0
- apsimo/agents/models.py +264 -0
- apsimo/agents/store.py +861 -0
- apsimo/agents/websocket.py +522 -0
- apsimo/api/__init__.py +1 -0
- apsimo/api/auth_telemetry.py +287 -0
- apsimo/api/authority.py +1203 -0
- apsimo/api/contact_grants.py +347 -0
- apsimo/api/middleware.py +483 -0
- apsimo/api/routers/__init__.py +1 -0
- apsimo/api/routers/commitment_work.py +265 -0
- apsimo/api/routers/context_gate.py +123 -0
- apsimo/api/routers/executions.py +140 -0
- apsimo/api/routers/followup_plans.py +147 -0
- apsimo/api/routers/governed_actions.py +162 -0
- apsimo/api/routers/host.py +14473 -0
- apsimo/api/routers/initiative_work.py +115 -0
- apsimo/api/routers/mining.py +104 -0
- apsimo/api/routers/observations.py +110 -0
- apsimo/api/routers/social_state.py +225 -0
- apsimo/api/routers/task_queue.py +2715 -0
- apsimo/api/routers/temporal_followups.py +251 -0
- apsimo/api/routers/transport.py +110 -0
- apsimo/api/routers/transport_ingress_api.py +240 -0
- apsimo/api/schemas/__init__.py +1 -0
- apsimo/api/schemas/host.py +1949 -0
- apsimo/autonomy/cli.py +110 -0
- apsimo/autonomy/condition_worker.py +437 -0
- apsimo/autonomy/config.py +424 -0
- apsimo/autonomy/loop.py +4316 -0
- apsimo/autonomy/registry.py +339 -0
- apsimo/autonomy/scheduler.py +1822 -0
- apsimo/autonomy/synthesis.py +449 -0
- apsimo/backup.py +962 -0
- apsimo/beliefs/__init__.py +23 -0
- apsimo/beliefs/contradictions.py +109 -0
- apsimo/beliefs/decay.py +61 -0
- apsimo/beliefs/engine.py +479 -0
- apsimo/beliefs/models.py +67 -0
- apsimo/beliefs/promotion.py +41 -0
- apsimo/beliefs/resolve.py +58 -0
- apsimo/beliefs/source_claims.py +690 -0
- apsimo/beliefs/source_projection.py +883 -0
- apsimo/beliefs/source_time.py +208 -0
- apsimo/beliefs/store.py +133 -0
- apsimo/briefings/aggregators.py +824 -0
- apsimo/briefings/composer.py +420 -0
- apsimo/briefings/config.py +55 -0
- apsimo/briefings/delivery.py +439 -0
- apsimo/briefings/engagement.py +97 -0
- apsimo/briefings/engine.py +274 -0
- apsimo/briefings/enhancer.py +99 -0
- apsimo/briefings/models.py +183 -0
- apsimo/briefings/scheduler.py +382 -0
- apsimo/briefings/store.py +435 -0
- apsimo/chain/__init__.py +48 -0
- apsimo/chain/block.py +100 -0
- apsimo/chain/cli.py +704 -0
- apsimo/chain/genesis.py +443 -0
- apsimo/chain/identity.py +416 -0
- apsimo/chain/keys.py +1025 -0
- apsimo/chain/local_keys.py +187 -0
- apsimo/chain/manager.py +290 -0
- apsimo/chain/node.py +163 -0
- apsimo/chain/plugin_transactions.py +371 -0
- apsimo/chain/protocol.py +220 -0
- apsimo/chain/state_machine.py +676 -0
- apsimo/chain/storage.py +503 -0
- apsimo/chain/transactions.py +250 -0
- apsimo/chain/validation.py +397 -0
- apsimo/channels/__init__.py +1 -0
- apsimo/channels/manifest.py +31 -0
- apsimo/channels/migrations/001_channels_schema.sql +12 -0
- apsimo/channels/phone_gateways.py +42 -0
- apsimo/channels/presence.py +188 -0
- apsimo/channels/router.py +235 -0
- apsimo/channels/store.py +231 -0
- apsimo/cli.py +2688 -0
- apsimo/cognition/__init__.py +11 -0
- apsimo/cognition/charter.py +398 -0
- apsimo/cognition/drive_governance.py +3530 -0
- apsimo/cognition/evidence_pipeline.py +1627 -0
- apsimo/cognition/external_events.py +932 -0
- apsimo/cognition/goal_spine.py +3488 -0
- apsimo/cognition/introspection.py +214 -0
- apsimo/cognition/prompt.py +150 -0
- apsimo/cognition/runtime.py +108 -0
- apsimo/cognition/trigger.py +154 -0
- apsimo/commitments/__init__.py +18 -0
- apsimo/commitments/local_work.py +355 -0
- apsimo/commitments/store.py +1052 -0
- apsimo/commitments/work.py +91 -0
- apsimo/compat.py +53 -0
- apsimo/compression/__init__.py +467 -0
- apsimo/connectors/__init__.py +21 -0
- apsimo/connectors/base.py +152 -0
- apsimo/connectors/caldav_calendar.py +125 -0
- apsimo/connectors/fs_documents.py +85 -0
- apsimo/connectors/imap_email.py +138 -0
- apsimo/connectors/manager.py +218 -0
- apsimo/connectors/webhook_pull.py +88 -0
- apsimo/contacts/__init__.py +33 -0
- apsimo/contacts/comms.py +357 -0
- apsimo/contacts/config.py +79 -0
- apsimo/contacts/exporters/__init__.py +1 -0
- apsimo/contacts/exporters/vcard.py +71 -0
- apsimo/contacts/identity_links.py +251 -0
- apsimo/contacts/importer.py +280 -0
- apsimo/contacts/importers/__init__.py +1 -0
- apsimo/contacts/importers/batch.py +43 -0
- apsimo/contacts/importers/macos_contacts.py +101 -0
- apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
- apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
- apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
- apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
- apsimo/contacts/migrations/005_identity_links.sql +27 -0
- apsimo/contacts/models.py +308 -0
- apsimo/contacts/scoring.py +16 -0
- apsimo/contacts/store.py +1623 -0
- apsimo/contacts/transport_ingress.py +252 -0
- apsimo/contacts/world_bridge.py +314 -0
- apsimo/contextgate/__init__.py +69 -0
- apsimo/contextgate/chunker.py +169 -0
- apsimo/contextgate/estimate.py +54 -0
- apsimo/contextgate/gate.py +313 -0
- apsimo/contextgate/retrieve.py +115 -0
- apsimo/delivery/__init__.py +16 -0
- apsimo/delivery/bridge.py +1260 -0
- apsimo/delivery/channels.py +526 -0
- apsimo/delivery/classification.py +50 -0
- apsimo/delivery/rate_limiter.py +268 -0
- apsimo/delivery/reachout_policy.py +206 -0
- apsimo/directed/__init__.py +22 -0
- apsimo/directed/audit.py +167 -0
- apsimo/directed/intake.py +95 -0
- apsimo/directed/models.py +191 -0
- apsimo/directed/service.py +509 -0
- apsimo/directives/__init__.py +25 -0
- apsimo/directives/evidence.py +87 -0
- apsimo/directives/extractor.py +188 -0
- apsimo/directives/guard.py +364 -0
- apsimo/directives/models.py +206 -0
- apsimo/directives/service.py +372 -0
- apsimo/directives/store.py +167 -0
- apsimo/doctor.py +2173 -0
- apsimo/environment.py +43 -0
- apsimo/events/__init__.py +33 -0
- apsimo/events/broadcaster.py +98 -0
- apsimo/events/bus.py +217 -0
- apsimo/events/journal.py +863 -0
- apsimo/events/stream.py +131 -0
- apsimo/events/types.py +150 -0
- apsimo/execution_results.py +357 -0
- apsimo/feedback/__init__.py +5 -0
- apsimo/feedback/store.py +76 -0
- apsimo/feeds/__init__.py +19 -0
- apsimo/feeds/cli.py +84 -0
- apsimo/feeds/engine.py +437 -0
- apsimo/feeds/example-feed.yaml +77 -0
- apsimo/feeds/hermes_cron.py +126 -0
- apsimo/feeds/manager.py +235 -0
- apsimo/feeds/spec.py +250 -0
- apsimo/feeds/template.py +202 -0
- apsimo/gate/__init__.py +18 -0
- apsimo/gate/audit.py +61 -0
- apsimo/gate/communication_policy.py +166 -0
- apsimo/gate/config.py +72 -0
- apsimo/gate/context_provenance.py +170 -0
- apsimo/gate/env_risk.py +226 -0
- apsimo/gate/guard_audit.py +353 -0
- apsimo/gate/layers/__init__.py +1 -0
- apsimo/gate/layers/base.py +15 -0
- apsimo/gate/layers/l1_recipient.py +66 -0
- apsimo/gate/layers/l2_pii.py +134 -0
- apsimo/gate/layers/l3_cross_context.py +50 -0
- apsimo/gate/layers/l4_trust_tier.py +78 -0
- apsimo/gate/layers/l5_injection.py +199 -0
- apsimo/gate/layers/l6_review.py +86 -0
- apsimo/gate/layers/l7_delay.py +100 -0
- apsimo/gate/layers/tom2_epistemic.py +185 -0
- apsimo/gate/models.py +64 -0
- apsimo/gate/pending_dispatch.py +5 -0
- apsimo/gate/pipeline.py +206 -0
- apsimo/gate/rejection.py +259 -0
- apsimo/gate/response_guard.py +700 -0
- apsimo/gate/rulesets/injection_v1.yaml +51 -0
- apsimo/gate/surface_policy.py +189 -0
- apsimo/gate/taint.py +226 -0
- apsimo/genesis.json +9 -0
- apsimo/goals/__init__.py +100 -0
- apsimo/goals/config.py +38 -0
- apsimo/goals/decomposer.py +421 -0
- apsimo/goals/engine.py +617 -0
- apsimo/goals/inference.py +354 -0
- apsimo/goals/models.py +302 -0
- apsimo/goals/priority.py +270 -0
- apsimo/goals/queue_bridge.py +149 -0
- apsimo/goals/replan.py +450 -0
- apsimo/goals/schema.sql +89 -0
- apsimo/goals/store.py +692 -0
- apsimo/governed_actions.py +1708 -0
- apsimo/harness_integration/__init__.py +45 -0
- apsimo/harness_integration/context.py +41 -0
- apsimo/harness_integration/skills.py +231 -0
- apsimo/identity/__init__.py +26 -0
- apsimo/identity/participants.py +181 -0
- apsimo/identity/resolver.py +329 -0
- apsimo/identity_bootstrap/__init__.py +5 -0
- apsimo/identity_bootstrap/builder.py +208 -0
- apsimo/identity_bootstrap/corpus.py +443 -0
- apsimo/identity_bootstrap/models.py +54 -0
- apsimo/identity_bootstrap/runner.py +353 -0
- apsimo/identity_bootstrap/seeders/__init__.py +25 -0
- apsimo/identity_bootstrap/seeders/briefings.py +109 -0
- apsimo/identity_bootstrap/seeders/chain.py +57 -0
- apsimo/identity_bootstrap/seeders/goals.py +128 -0
- apsimo/identity_bootstrap/seeders/memory.py +191 -0
- apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
- apsimo/identity_bootstrap/seeders/relationship.py +152 -0
- apsimo/identity_bootstrap/seeders/sessions.py +67 -0
- apsimo/identity_bootstrap/seeders/skills.py +92 -0
- apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
- apsimo/identity_bootstrap/seeders/world_model.py +143 -0
- apsimo/identity_bootstrap/self_query.py +92 -0
- apsimo/identity_bootstrap/self_reflection.py +155 -0
- apsimo/identity_bootstrap/skill.py +37 -0
- apsimo/identity_bootstrap/verifier.py +436 -0
- apsimo/initiatives/__init__.py +20 -0
- apsimo/initiatives/action_registry.py +454 -0
- apsimo/initiatives/approval_authority.py +2105 -0
- apsimo/initiatives/approval_policy.py +123 -0
- apsimo/initiatives/assignment.py +263 -0
- apsimo/initiatives/backup_evidence.py +100 -0
- apsimo/initiatives/context_freshness.py +103 -0
- apsimo/initiatives/models.py +318 -0
- apsimo/initiatives/native_work.py +270 -0
- apsimo/initiatives/standing_approvals.py +232 -0
- apsimo/initiatives/store.py +1081 -0
- apsimo/initiatives/temporal_followup.py +410 -0
- apsimo/intelligence/__init__.py +1 -0
- apsimo/intelligence/cognition/__init__.py +24 -0
- apsimo/intelligence/cognition/gap_detector.py +148 -0
- apsimo/intelligence/cognition/metalearner.py +547 -0
- apsimo/intelligence/cognition/metrics_collector.py +217 -0
- apsimo/intelligence/cognition/performance_index.py +299 -0
- apsimo/intelligence/cognition/registry.py +192 -0
- apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
- apsimo/intelligence/cognition/types.py +16 -0
- apsimo/intelligence/components/__init__.py +66 -0
- apsimo/intelligence/components/anomaly_detector.py +413 -0
- apsimo/intelligence/components/initiative_engine.py +2643 -0
- apsimo/intelligence/components/preference_learner.py +521 -0
- apsimo/intelligence/components/research_orchestrator.py +358 -0
- apsimo/intelligence/components/self_directed_thinker.py +221 -0
- apsimo/intelligence/components/self_reflector.py +252 -0
- apsimo/intelligence/components/session_continuity.py +154 -0
- apsimo/intelligence/components/task_planner.py +320 -0
- apsimo/intelligence/components/tool_learner.py +217 -0
- apsimo/intelligence/graph/__init__.py +79 -0
- apsimo/intelligence/graph/client.py +2483 -0
- apsimo/intelligence/graph/consolidator.py +405 -0
- apsimo/intelligence/graph/distiller.py +312 -0
- apsimo/intelligence/graph/migrations.py +129 -0
- apsimo/intelligence/graph/queries.py +248 -0
- apsimo/intelligence/graph/recall.py +281 -0
- apsimo/intelligence/graph/reconciler.py +144 -0
- apsimo/intelligence/graph/schema.py +337 -0
- apsimo/intelligence/graph/selection.py +252 -0
- apsimo/intelligence/learning/__init__.py +17 -0
- apsimo/intelligence/learning/continuous_learner.py +245 -0
- apsimo/intelligence/learning/feedback_store.py +321 -0
- apsimo/intelligence/mind_model/__init__.py +1 -0
- apsimo/intelligence/mind_model/graph_baseline.py +136 -0
- apsimo/intelligence/mind_model/signal_collector.py +361 -0
- apsimo/intelligence/relationships/__init__.py +11 -0
- apsimo/intelligence/relationships/profiler.py +389 -0
- apsimo/intelligence/relationships/scorer.py +560 -0
- apsimo/intelligence/relationships/signal_floor.py +66 -0
- apsimo/intelligence/relationships/trust_tiers.py +300 -0
- apsimo/intelligence/synthesis/__init__.py +40 -0
- apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
- apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
- apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
- apsimo/intelligence/synthesis/insight_store.py +79 -0
- apsimo/intelligence/synthesis/insight_validator.py +183 -0
- apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
- apsimo/intelligence/turn_middleware/__init__.py +15 -0
- apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
- apsimo/mcp/__init__.py +41 -0
- apsimo/mcp/__main__.py +6 -0
- apsimo/mcp/config.py +287 -0
- apsimo/mcp/server.py +501 -0
- apsimo/migrations.py +187 -0
- apsimo/mining/__init__.py +27 -0
- apsimo/mining/corpus.py +239 -0
- apsimo/mining/escalations.py +289 -0
- apsimo/mining/models.py +169 -0
- apsimo/mining/store.py +210 -0
- apsimo/models/__init__.py +30 -0
- apsimo/models/memory.py +80 -0
- apsimo/models/mesh.py +72 -0
- apsimo/models/person.py +104 -0
- apsimo/models/signal.py +108 -0
- apsimo/observations/__init__.py +15 -0
- apsimo/observations/store.py +277 -0
- apsimo/patterns/__init__.py +6 -0
- apsimo/patterns/extract.py +187 -0
- apsimo/patterns/store.py +227 -0
- apsimo/persona/__init__.py +1 -0
- apsimo/persona/engine.py +611 -0
- apsimo/persona/manifest.py +140 -0
- apsimo/projects/__init__.py +28 -0
- apsimo/projects/engine.py +1681 -0
- apsimo/projects/event_outbox.py +188 -0
- apsimo/projects/models.py +216 -0
- apsimo/projects/planner.py +181 -0
- apsimo/projects/store.py +1446 -0
- apsimo/proposals/__init__.py +12 -0
- apsimo/proposals/engine.py +114 -0
- apsimo/proposals/models.py +207 -0
- apsimo/qualification/__init__.py +1 -0
- apsimo/qualification/cases.py +75 -0
- apsimo/qualification/cli.py +51 -0
- apsimo/qualification/memory_cases.py +209 -0
- apsimo/qualification/records.py +92 -0
- apsimo/qualification/report.py +87 -0
- apsimo/qualification/runner.py +311 -0
- apsimo/qualification/structured_cases.py +131 -0
- apsimo/reasoning/__init__.py +13 -0
- apsimo/reasoning/executor.py +506 -0
- apsimo/reasoning/loop.py +373 -0
- apsimo/reasoning/native_tools/__init__.py +16 -0
- apsimo/reasoning/native_tools/calculate.py +141 -0
- apsimo/reasoning/native_tools/file_ops.py +150 -0
- apsimo/reasoning/native_tools/web_search.py +49 -0
- apsimo/reasoning/tool_policy.py +182 -0
- apsimo/redact/__init__.py +176 -0
- apsimo/repos/__init__.py +5 -0
- apsimo/repos/mirrors.py +204 -0
- apsimo/research/__init__.py +41 -0
- apsimo/research/artifact.py +482 -0
- apsimo/research/gatherer.py +387 -0
- apsimo/research/pipeline.py +513 -0
- apsimo/research/search/__init__.py +7 -0
- apsimo/research/search/base.py +41 -0
- apsimo/research/search/brave.py +59 -0
- apsimo/research/search/cache.py +51 -0
- apsimo/research/search/duckduckgo.py +103 -0
- apsimo/research/search/orchestrator.py +119 -0
- apsimo/research/search/serpapi.py +59 -0
- apsimo/research/search/tavily.py +59 -0
- apsimo/research/synthesizer.py +309 -0
- apsimo/router/__init__.py +30 -0
- apsimo/router/complexity_scorer.py +148 -0
- apsimo/router/endpoints.py +153 -0
- apsimo/router/fallback.py +58 -0
- apsimo/router/functions.py +243 -0
- apsimo/router/native_policy.py +52 -0
- apsimo/router/router.py +762 -0
- apsimo/router/self_learning.py +174 -0
- apsimo/router/tiers.py +677 -0
- apsimo/sandbox/__init__.py +21 -0
- apsimo/sandbox/backend.py +195 -0
- apsimo/sandbox/manager.py +173 -0
- apsimo/scope_bounds.py +7 -0
- apsimo/secrets/__init__.py +6 -0
- apsimo/secrets/backends/__init__.py +8 -0
- apsimo/secrets/backends/base.py +42 -0
- apsimo/secrets/backends/env.py +110 -0
- apsimo/secrets/backends/keyring.py +72 -0
- apsimo/secrets/backends/onepassword.py +232 -0
- apsimo/secrets/cli.py +191 -0
- apsimo/secrets/manager.py +160 -0
- apsimo/secrets/migration.py +101 -0
- apsimo/secrets/types.py +98 -0
- apsimo/seed.py +41 -0
- apsimo/self_model/__init__.py +37 -0
- apsimo/self_model/appraisals.py +673 -0
- apsimo/self_model/benchmark.py +1314 -0
- apsimo/self_model/brief.py +40 -0
- apsimo/self_model/event_concerns.py +1128 -0
- apsimo/self_model/execution_forecasts.py +353 -0
- apsimo/self_model/expectations.py +1595 -0
- apsimo/self_model/experiments.py +1150 -0
- apsimo/self_model/journal.py +148 -0
- apsimo/self_model/judgments.py +705 -0
- apsimo/self_model/native_outcomes.py +55 -0
- apsimo/self_model/params.py +220 -0
- apsimo/self_model/perspective.py +246 -0
- apsimo/self_model/reconcile.py +183 -0
- apsimo/self_model/reply_forecasts.py +381 -0
- apsimo/self_model/runtime_forecasts.py +296 -0
- apsimo/self_model/runtime_models.py +67 -0
- apsimo/self_model/settlement.py +207 -0
- apsimo/self_model/situation.py +1731 -0
- apsimo/self_model/store.py +883 -0
- apsimo/self_model/supervised.py +137 -0
- apsimo/self_model/thinker.py +99 -0
- apsimo/self_model/trust.py +388 -0
- apsimo/self_model/workspace.py +2388 -0
- apsimo/server.py +4197 -0
- apsimo/services/__init__.py +1 -0
- apsimo/services/agent_bridge.py +474 -0
- apsimo/services/initiative_executor.py +914 -0
- apsimo/services/instance.py +297 -0
- apsimo/sessions/__init__.py +22 -0
- apsimo/sessions/config.py +13 -0
- apsimo/sessions/context_loader.py +88 -0
- apsimo/sessions/federation_session.py +75 -0
- apsimo/sessions/isolated_session.py +98 -0
- apsimo/sessions/reports.py +84 -0
- apsimo/sessions/store.py +148 -0
- apsimo/setup.py +2818 -0
- apsimo/setup_hermes.py +879 -0
- apsimo/setup_local_work.py +218 -0
- apsimo/setup_native_goals.py +134 -0
- apsimo/setup_native_reviews.py +115 -0
- apsimo/skills/__init__.py +10 -0
- apsimo/skills/base.py +108 -0
- apsimo/skills/budget.py +28 -0
- apsimo/skills/executor.py +493 -0
- apsimo/skills/executors/__init__.py +1 -0
- apsimo/skills/executors/behavioral_correction.py +75 -0
- apsimo/skills/executors/capability_gap.py +38 -0
- apsimo/skills/executors/data_quality.py +163 -0
- apsimo/skills/executors/knowledge_acquisition.py +41 -0
- apsimo/skills/executors/operational_hygiene.py +185 -0
- apsimo/skills/executors/subsystem_health.py +169 -0
- apsimo/skills/hermes_export.py +431 -0
- apsimo/skills/index.py +123 -0
- apsimo/skills/learning/__init__.py +21 -0
- apsimo/skills/learning/novelty_detector.py +206 -0
- apsimo/skills/learning/pattern_extractor.py +199 -0
- apsimo/skills/learning/triggers.py +159 -0
- apsimo/skills/loader.py +246 -0
- apsimo/skills/migrations/002_progressive_loading.sql +6 -0
- apsimo/skills/migrations/backfill_triggers.py +20 -0
- apsimo/skills/models.py +202 -0
- apsimo/skills/packager.py +128 -0
- apsimo/skills/protocols.py +70 -0
- apsimo/skills/registry.py +191 -0
- apsimo/skills/runtime.py +58 -0
- apsimo/skills/sandbox_runner.py +229 -0
- apsimo/skills/scheduler.py +129 -0
- apsimo/skills/schema.py +79 -0
- apsimo/skills/security/__init__.py +12 -0
- apsimo/skills/security/guards.py +53 -0
- apsimo/skills/security/scanner.py +223 -0
- apsimo/skills_memory/__init__.py +26 -0
- apsimo/skills_memory/distill.py +159 -0
- apsimo/skills_memory/models.py +85 -0
- apsimo/skills_memory/retrieve.py +62 -0
- apsimo/skills_memory/store.py +172 -0
- apsimo/surprise/__init__.py +6 -0
- apsimo/surprise/accumulation.py +57 -0
- apsimo/surprise/scorer.py +102 -0
- apsimo/surprise/store.py +203 -0
- apsimo/task_queue/__init__.py +69 -0
- apsimo/task_queue/action_receipts.py +148 -0
- apsimo/task_queue/approval_relay_canary.py +108 -0
- apsimo/task_queue/config.py +85 -0
- apsimo/task_queue/contract.py +361 -0
- apsimo/task_queue/events.py +130 -0
- apsimo/task_queue/governor.py +1031 -0
- apsimo/task_queue/handlers/__init__.py +16 -0
- apsimo/task_queue/handlers/base.py +37 -0
- apsimo/task_queue/handlers/inference.py +640 -0
- apsimo/task_queue/handlers/monitoring.py +116 -0
- apsimo/task_queue/handlers/registry.py +75 -0
- apsimo/task_queue/handlers/subtask_handler.py +173 -0
- apsimo/task_queue/handlers/system_maintenance.py +147 -0
- apsimo/task_queue/mesh_integration.py +111 -0
- apsimo/task_queue/models.py +317 -0
- apsimo/task_queue/queue_manager.py +8286 -0
- apsimo/task_queue/routing.py +287 -0
- apsimo/task_queue/scheduler.py +252 -0
- apsimo/task_queue/schema.sql +197 -0
- apsimo/task_queue/work_control.py +342 -0
- apsimo/task_queue/worker.py +993 -0
- apsimo/telemetry.py +145 -0
- apsimo/tom/__init__.py +6 -0
- apsimo/tom/affect.py +387 -0
- apsimo/tom/approvals.py +171 -0
- apsimo/tom/arcs.py +896 -0
- apsimo/tom/asymmetry.py +131 -0
- apsimo/tom/eligibility.py +248 -0
- apsimo/tom/engagement.py +214 -0
- apsimo/tom/exposure.py +214 -0
- apsimo/tom/extractor.py +306 -0
- apsimo/tom/fact_adapters.py +144 -0
- apsimo/tom/facts.py +326 -0
- apsimo/tom/integration.py +592 -0
- apsimo/tom/leveled.py +118 -0
- apsimo/tom/levels.py +247 -0
- apsimo/tom/recipient_audit.py +995 -0
- apsimo/tom/recipient_simulator.py +593 -0
- apsimo/tom/source_lineage.py +93 -0
- apsimo/tom/tom2.py +277 -0
- apsimo/tom/visibility.py +559 -0
- apsimo/tom/visibility_store.py +414 -0
- apsimo/tools/__init__.py +0 -0
- apsimo/tools/definitions.py +740 -0
- apsimo/tools/handlers.py +943 -0
- apsimo/toolsmith/__init__.py +26 -0
- apsimo/toolsmith/authority.py +166 -0
- apsimo/toolsmith/engine.py +559 -0
- apsimo/toolsmith/integrity.py +100 -0
- apsimo/toolsmith/miner.py +145 -0
- apsimo/toolsmith/policy.py +110 -0
- apsimo/toolsmith/registry.py +635 -0
- apsimo/turns/__init__.py +17 -0
- apsimo/turns/audio.py +134 -0
- apsimo/turns/documents.py +235 -0
- apsimo/turns/executions.py +486 -0
- apsimo/turns/hermes_history.py +245 -0
- apsimo/turns/hermes_kanban.py +268 -0
- apsimo/turns/hermes_work.py +96 -0
- apsimo/turns/idempotency.py +752 -0
- apsimo/turns/local_work.py +115 -0
- apsimo/turns/media.py +581 -0
- apsimo/turns/reported_workers.py +196 -0
- apsimo/turns/source_annotations.py +283 -0
- apsimo/turns/source_attribution.py +154 -0
- apsimo/turns/source_read.py +351 -0
- apsimo/turns/source_vectors.py +263 -0
- apsimo/turns/video.py +210 -0
- apsimo/util/autonomy_preset.py +220 -0
- apsimo/util/instance.py +92 -0
- apsimo/util/model_output.py +25 -0
- apsimo/util/quiet_hours.py +27 -0
- apsimo/util/session_safety.py +37 -0
- apsimo/util/temporal.py +343 -0
- apsimo/vector/__init__.py +75 -0
- apsimo/vector/backfill.py +171 -0
- apsimo/vector/caption.py +114 -0
- apsimo/vector/collections.py +51 -0
- apsimo/vector/config.py +102 -0
- apsimo/vector/embedder.py +670 -0
- apsimo/vector/image_preprocess.py +406 -0
- apsimo/vector/image_store.py +296 -0
- apsimo/vector/indexes.py +162 -0
- apsimo/vector/migrate.py +334 -0
- apsimo/vector/multimodal_provider.py +417 -0
- apsimo/vector/multimodal_types.py +87 -0
- apsimo/vector/openai_provider.py +119 -0
- apsimo/vector/query.py +49 -0
- apsimo/vector/reranker.py +565 -0
- apsimo/vector/safety_image.py +159 -0
- apsimo/vector/scanner.py +197 -0
- apsimo/vector/setup.py +289 -0
- apsimo/vector/store.py +533 -0
- apsimo/vector/tiers.py +263 -0
- apsimo/work_orders.py +925 -0
- apsimo/workers/__init__.py +21 -0
- apsimo/workers/agent_bridge.py +640 -0
- apsimo/workers/colony_worker.py +382 -0
- apsimo/workers/queue_worker.py +441 -0
- apsimo/workers/skills_sync.py +152 -0
- apsimo/world_model/__init__.py +71 -0
- apsimo/world_model/causal_maintenance.py +131 -0
- apsimo/world_model/causal_policy.py +43 -0
- apsimo/world_model/causal_query.py +125 -0
- apsimo/world_model/confidence.py +54 -0
- apsimo/world_model/config.py +64 -0
- apsimo/world_model/constants.py +97 -0
- apsimo/world_model/entities.py +145 -0
- apsimo/world_model/expectation_resolvers.py +177 -0
- apsimo/world_model/extraction/__init__.py +7 -0
- apsimo/world_model/extraction/base.py +62 -0
- apsimo/world_model/extraction/conversation_extractor.py +262 -0
- apsimo/world_model/extraction/detector.py +74 -0
- apsimo/world_model/extraction/document_extractor.py +78 -0
- apsimo/world_model/extraction/formats/__init__.py +24 -0
- apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
- apsimo/world_model/extraction/formats/html_fmt.py +72 -0
- apsimo/world_model/extraction/formats/json_fmt.py +68 -0
- apsimo/world_model/extraction/formats/pdf.py +43 -0
- apsimo/world_model/extraction/formats/text.py +27 -0
- apsimo/world_model/extraction/llm_extractor.py +164 -0
- apsimo/world_model/extraction/pipeline.py +73 -0
- apsimo/world_model/integrations/__init__.py +5 -0
- apsimo/world_model/integrations/mind_model_bridge.py +115 -0
- apsimo/world_model/integrations/social_intel_bridge.py +120 -0
- apsimo/world_model/jobs/__init__.py +4 -0
- apsimo/world_model/jobs/extraction_job.py +168 -0
- apsimo/world_model/llm_extract.py +572 -0
- apsimo/world_model/neo4j/__init__.py +5 -0
- apsimo/world_model/neo4j/backend.py +654 -0
- apsimo/world_model/observations.py +155 -0
- apsimo/world_model/populator.py +307 -0
- apsimo/world_model/postgres/__init__.py +1 -0
- apsimo/world_model/postgres/backend.py +683 -0
- apsimo/world_model/relationships.py +25 -0
- apsimo/world_model/resolution/__init__.py +13 -0
- apsimo/world_model/resolution/entity_resolver.py +232 -0
- apsimo/world_model/resolution/merge_audit.py +16 -0
- apsimo/world_model/resolution/merge_workflow.py +117 -0
- apsimo/world_model/source_reports.py +121 -0
- apsimo/world_model/sqlite/__init__.py +4 -0
- apsimo/world_model/sqlite/backend.py +855 -0
- apsimo/world_model/sqlite/schema.sql +132 -0
- apsimo/world_model/store.py +545 -0
- apsimo-1.3.0.dist-info/METADATA +78 -0
- apsimo-1.3.0.dist-info/RECORD +614 -0
- apsimo-1.3.0.dist-info/WHEEL +5 -0
- apsimo-1.3.0.dist-info/entry_points.txt +11 -0
- apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
- apsimo-1.3.0.dist-info/top_level.txt +2 -0
- colony_sidecar/__init__.py +4 -0
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
"""Initiative store for multi-agent Colony.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- InitiativeStore: SQLite persistence for initiatives
|
|
5
|
+
- Assignment history tracking
|
|
6
|
+
- Dead letter queue
|
|
7
|
+
- Timeout and expiry checks
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import shutil
|
|
14
|
+
import sqlite3
|
|
15
|
+
import uuid
|
|
16
|
+
from contextlib import closing
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from .models import AssignmentHistory, InitiativeStatus, StoredInitiative
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Maximum pending initiatives before rejecting new ones
|
|
26
|
+
MAX_PENDING_INITIATIVES = 1000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_state_dir() -> Path:
|
|
30
|
+
"""Get Colony state directory."""
|
|
31
|
+
import os
|
|
32
|
+
state_dir = os.environ.get("COLONY_STATE_DIR")
|
|
33
|
+
if state_dir:
|
|
34
|
+
return Path(state_dir)
|
|
35
|
+
return Path.home() / ".colony" / "data"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InitiativeStore:
|
|
39
|
+
"""Manages initiative persistence with SQLite."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, state_dir: Optional[Path] = None):
|
|
42
|
+
self._state_dir = Path(state_dir) if state_dir else get_state_dir()
|
|
43
|
+
self._state_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
self._db_path = self._state_dir / "initiatives.db"
|
|
45
|
+
self._backup_path = self._state_dir / "initiatives.db.backup"
|
|
46
|
+
self._dlq_path = self._state_dir / "dead-letter-queue.jsonl"
|
|
47
|
+
|
|
48
|
+
self._db = self._init_db()
|
|
49
|
+
|
|
50
|
+
def _init_db(self) -> sqlite3.Connection:
|
|
51
|
+
"""Initialize database with recovery."""
|
|
52
|
+
try:
|
|
53
|
+
return self._connect()
|
|
54
|
+
except sqlite3.DatabaseError:
|
|
55
|
+
logger.warning("initiatives.db corrupted, attempting recovery")
|
|
56
|
+
|
|
57
|
+
if self._backup_path.exists():
|
|
58
|
+
shutil.copy(self._backup_path, self._db_path)
|
|
59
|
+
logger.info("Restored initiatives.db from backup")
|
|
60
|
+
else:
|
|
61
|
+
self._db_path.unlink(missing_ok=True)
|
|
62
|
+
logger.warning("No backup available, starting fresh")
|
|
63
|
+
|
|
64
|
+
return self._connect()
|
|
65
|
+
|
|
66
|
+
def _connect(self) -> sqlite3.Connection:
|
|
67
|
+
"""Connect to database with WAL mode."""
|
|
68
|
+
# check_same_thread=False allows TestClient to access the DB from
|
|
69
|
+
# a different thread (test thread vs event loop thread).
|
|
70
|
+
conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
71
|
+
conn.row_factory = sqlite3.Row
|
|
72
|
+
|
|
73
|
+
# WAL mode for better crash recovery
|
|
74
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
75
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
76
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
77
|
+
|
|
78
|
+
self._create_tables(conn)
|
|
79
|
+
|
|
80
|
+
return conn
|
|
81
|
+
|
|
82
|
+
def _create_tables(self, conn: sqlite3.Connection) -> None:
|
|
83
|
+
"""Create initiatives and history tables."""
|
|
84
|
+
conn.execute(
|
|
85
|
+
"""
|
|
86
|
+
CREATE TABLE IF NOT EXISTS initiatives (
|
|
87
|
+
id TEXT PRIMARY KEY,
|
|
88
|
+
dedup_key TEXT UNIQUE,
|
|
89
|
+
type TEXT NOT NULL,
|
|
90
|
+
description TEXT NOT NULL,
|
|
91
|
+
priority REAL DEFAULT 0.5,
|
|
92
|
+
rationale TEXT,
|
|
93
|
+
action_hint TEXT,
|
|
94
|
+
entity_id TEXT,
|
|
95
|
+
|
|
96
|
+
source_type TEXT,
|
|
97
|
+
source_id TEXT,
|
|
98
|
+
created_by TEXT,
|
|
99
|
+
|
|
100
|
+
status TEXT DEFAULT 'pending',
|
|
101
|
+
assigned_agent_id TEXT,
|
|
102
|
+
assigned_agent_name TEXT,
|
|
103
|
+
assigned_at TIMESTAMP,
|
|
104
|
+
acknowledged_at TIMESTAMP,
|
|
105
|
+
completed_at TIMESTAMP,
|
|
106
|
+
cancelled_at TIMESTAMP,
|
|
107
|
+
cancelled_by TEXT,
|
|
108
|
+
cancelled_reason TEXT,
|
|
109
|
+
failed_at TIMESTAMP,
|
|
110
|
+
failed_reason TEXT,
|
|
111
|
+
|
|
112
|
+
attempt_count INTEGER DEFAULT 0,
|
|
113
|
+
max_attempts INTEGER DEFAULT 3,
|
|
114
|
+
timeout_seconds INTEGER DEFAULT 300,
|
|
115
|
+
last_attempt_at TIMESTAMP,
|
|
116
|
+
|
|
117
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
118
|
+
expires_at TIMESTAMP,
|
|
119
|
+
|
|
120
|
+
delivery_mode TEXT DEFAULT 'websocket',
|
|
121
|
+
delivery_attempts INTEGER DEFAULT 0,
|
|
122
|
+
last_delivery_at TIMESTAMP,
|
|
123
|
+
delivery_failed_at TIMESTAMP,
|
|
124
|
+
delivery_failed_reason TEXT,
|
|
125
|
+
|
|
126
|
+
result TEXT,
|
|
127
|
+
result_metadata TEXT DEFAULT '{}',
|
|
128
|
+
|
|
129
|
+
preferred_agent_id TEXT,
|
|
130
|
+
stale_reason TEXT,
|
|
131
|
+
recovery_reason TEXT,
|
|
132
|
+
job_id TEXT, -- v0.13.0: linked task-queue job
|
|
133
|
+
context TEXT -- v0.16.0: situational context snapshot (JSON)
|
|
134
|
+
)
|
|
135
|
+
"""
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Column migrations: v0.13.0 job_id, v0.16.0 context.
|
|
139
|
+
# Old rows keep context NULL — the API serializer maps that to {}.
|
|
140
|
+
try:
|
|
141
|
+
cursor = conn.execute("PRAGMA table_info(initiatives)")
|
|
142
|
+
columns = {row[1] for row in cursor.fetchall()}
|
|
143
|
+
if "job_id" not in columns:
|
|
144
|
+
conn.execute("ALTER TABLE initiatives ADD COLUMN job_id TEXT")
|
|
145
|
+
conn.commit()
|
|
146
|
+
logger.info("Migrated initiatives table: added job_id column")
|
|
147
|
+
if "context" not in columns:
|
|
148
|
+
conn.execute("ALTER TABLE initiatives ADD COLUMN context TEXT")
|
|
149
|
+
conn.commit()
|
|
150
|
+
logger.info("Migrated initiatives table: added context column")
|
|
151
|
+
if "dedup_base" not in columns:
|
|
152
|
+
# v0.21.23: logical (un-bucketed) dedup key for the cross-period
|
|
153
|
+
# in-flight guard. Old rows keep it NULL (they behave as before).
|
|
154
|
+
conn.execute("ALTER TABLE initiatives ADD COLUMN dedup_base TEXT")
|
|
155
|
+
conn.commit()
|
|
156
|
+
logger.info("Migrated initiatives table: added dedup_base column")
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
logger.warning("Initiative migration check failed (non-fatal): %s", exc)
|
|
159
|
+
|
|
160
|
+
# Indexes
|
|
161
|
+
conn.execute(
|
|
162
|
+
"CREATE INDEX IF NOT EXISTS idx_initiatives_status ON initiatives(status)"
|
|
163
|
+
)
|
|
164
|
+
conn.execute(
|
|
165
|
+
"CREATE INDEX IF NOT EXISTS idx_initiatives_assigned ON initiatives(assigned_agent_id)"
|
|
166
|
+
)
|
|
167
|
+
conn.execute(
|
|
168
|
+
"CREATE INDEX IF NOT EXISTS idx_initiatives_dedup ON initiatives(dedup_key)"
|
|
169
|
+
)
|
|
170
|
+
conn.execute(
|
|
171
|
+
"CREATE INDEX IF NOT EXISTS idx_initiatives_dedup_base ON initiatives(dedup_base)"
|
|
172
|
+
)
|
|
173
|
+
conn.execute(
|
|
174
|
+
"CREATE INDEX IF NOT EXISTS idx_initiatives_priority ON initiatives(priority DESC)"
|
|
175
|
+
)
|
|
176
|
+
conn.execute(
|
|
177
|
+
"CREATE INDEX IF NOT EXISTS idx_initiatives_created ON initiatives(created_at DESC)"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Assignment history table
|
|
181
|
+
conn.execute(
|
|
182
|
+
"""
|
|
183
|
+
CREATE TABLE IF NOT EXISTS assignment_history (
|
|
184
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
185
|
+
initiative_id TEXT NOT NULL,
|
|
186
|
+
agent_id TEXT NOT NULL,
|
|
187
|
+
agent_name TEXT,
|
|
188
|
+
action TEXT NOT NULL,
|
|
189
|
+
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
190
|
+
details TEXT,
|
|
191
|
+
|
|
192
|
+
FOREIGN KEY (initiative_id) REFERENCES initiatives(id)
|
|
193
|
+
)
|
|
194
|
+
"""
|
|
195
|
+
)
|
|
196
|
+
conn.execute(
|
|
197
|
+
"CREATE INDEX IF NOT EXISTS idx_history_initiative ON assignment_history(initiative_id)"
|
|
198
|
+
)
|
|
199
|
+
conn.execute(
|
|
200
|
+
"CREATE INDEX IF NOT EXISTS idx_history_agent ON assignment_history(agent_id)"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
conn.commit()
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# CRUD Operations
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def create(self, *args, **kwargs) -> StoredInitiative:
|
|
210
|
+
"""Create an initiative. Back-compat shim returning just the initiative; the
|
|
211
|
+
dispatch loop uses :meth:`create_with_outcome` to learn whether it was newly
|
|
212
|
+
created vs deduped."""
|
|
213
|
+
return self.create_with_outcome(*args, **kwargs)[0]
|
|
214
|
+
|
|
215
|
+
def create_with_outcome(
|
|
216
|
+
self,
|
|
217
|
+
type: str,
|
|
218
|
+
description: str,
|
|
219
|
+
priority: float = 0.5,
|
|
220
|
+
rationale: str = "",
|
|
221
|
+
action_hint: Optional[str] = None,
|
|
222
|
+
entity_id: Optional[str] = None,
|
|
223
|
+
dedup_key: Optional[str] = None,
|
|
224
|
+
dedup_base: Optional[str] = None,
|
|
225
|
+
source_type: Optional[str] = None,
|
|
226
|
+
source_id: Optional[str] = None,
|
|
227
|
+
created_by: Optional[str] = None,
|
|
228
|
+
timeout_seconds: int = 300,
|
|
229
|
+
expires_at: Optional[datetime] = None,
|
|
230
|
+
preferred_agent_id: Optional[str] = None,
|
|
231
|
+
job_id: Optional[str] = None,
|
|
232
|
+
context: Optional[Dict[str, Any]] = None,
|
|
233
|
+
**extra,
|
|
234
|
+
) -> Tuple[StoredInitiative, str]:
|
|
235
|
+
"""Create an initiative and report WHAT HAPPENED so the caller knows whether to
|
|
236
|
+
dispatch. The outcome is one of:
|
|
237
|
+
|
|
238
|
+
"created" — a fresh row was inserted; dispatch it.
|
|
239
|
+
"reactivated" — a previously FAILED instance was reset to pending; dispatch it.
|
|
240
|
+
"deduped_active" — an active instance already exists (same dedup_key, or any active
|
|
241
|
+
one sharing dedup_base across a period rollover); do NOT dispatch.
|
|
242
|
+
"deduped_terminal" — this period's instance already completed/cancelled; do NOT
|
|
243
|
+
dispatch (a recurring type re-arms on its next-period dedup_key).
|
|
244
|
+
|
|
245
|
+
This replaces the old id-comparison heuristic in the loop, which could never tell a
|
|
246
|
+
fresh create from a dedup hit (the store assigns its own uuid).
|
|
247
|
+
"""
|
|
248
|
+
# Check pending limit
|
|
249
|
+
pending_count = self.count(status=["pending"])
|
|
250
|
+
if pending_count >= MAX_PENDING_INITIATIVES:
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"Too many pending initiatives (max {MAX_PENDING_INITIATIVES})"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Cross-period in-flight guard: never create a second instance of the same logical
|
|
256
|
+
# work while one is still active, even after the period (dedup_key) rolls over.
|
|
257
|
+
if dedup_base:
|
|
258
|
+
active = self.get_active_by_dedup_base(dedup_base)
|
|
259
|
+
if active:
|
|
260
|
+
logger.debug(
|
|
261
|
+
"dedup_base %s already has active initiative %s; suppressing",
|
|
262
|
+
dedup_base, active.id,
|
|
263
|
+
)
|
|
264
|
+
return active, "deduped_active"
|
|
265
|
+
|
|
266
|
+
# A review of an old proposal may complete after its creation
|
|
267
|
+
# bucket expired. Anchor unchanged-condition re-review to that
|
|
268
|
+
# actual settlement, not immediately to the current wall bucket.
|
|
269
|
+
from .native_work import review_condition
|
|
270
|
+
evidence = review_condition({
|
|
271
|
+
"type": type, "source_type": source_type, "created_by": created_by,
|
|
272
|
+
"action_hint": action_hint, "entity_id": entity_id,
|
|
273
|
+
"description": description, "context": json.dumps(context or {}),
|
|
274
|
+
})
|
|
275
|
+
if evidence is not None:
|
|
276
|
+
from ..intelligence.components.initiative_engine import RECURRENCE_INTERVALS_SECS
|
|
277
|
+
interval = RECURRENCE_INTERVALS_SECS.get(type)
|
|
278
|
+
settled = self._db.execute(
|
|
279
|
+
"SELECT * FROM initiatives WHERE dedup_base=? AND status='completed' "
|
|
280
|
+
"AND completed_at IS NOT NULL "
|
|
281
|
+
"AND json_extract(context,'$.native_review') IS NOT NULL "
|
|
282
|
+
"ORDER BY completed_at DESC,created_at DESC LIMIT 1", (dedup_base,),
|
|
283
|
+
).fetchone()
|
|
284
|
+
if settled is not None and interval and review_condition(settled) == evidence:
|
|
285
|
+
completed = datetime.fromisoformat(settled['completed_at'])
|
|
286
|
+
if completed.tzinfo is not None and (
|
|
287
|
+
datetime.now(timezone.utc)-completed).total_seconds() < interval:
|
|
288
|
+
return StoredInitiative.from_row(dict(settled)), "deduped_terminal"
|
|
289
|
+
# Stable evidence plus the last settled generation admits a
|
|
290
|
+
# real change even within one old bucket, and re-arms when due.
|
|
291
|
+
# Candidate UUIDs, observation time and prose never form this key.
|
|
292
|
+
material = {"condition": evidence, "period": dedup_key,
|
|
293
|
+
"after": settled['id'] if settled is not None else None}
|
|
294
|
+
digest = hashlib.sha256(json.dumps(material, sort_keys=True).encode()).hexdigest()
|
|
295
|
+
dedup_key = f"{dedup_base}:review:{digest}"
|
|
296
|
+
|
|
297
|
+
# Period-key dedup (at most one row per dedup_key; UNIQUE).
|
|
298
|
+
if dedup_key:
|
|
299
|
+
existing = self.get_by_dedup_key(dedup_key)
|
|
300
|
+
if existing:
|
|
301
|
+
if existing.is_active:
|
|
302
|
+
logger.info(
|
|
303
|
+
"Initiative with dedup_key %s already exists: %s",
|
|
304
|
+
dedup_key, existing.id,
|
|
305
|
+
)
|
|
306
|
+
return existing, "deduped_active"
|
|
307
|
+
# FAILED reactivates so the work can be retried.
|
|
308
|
+
if existing.status == InitiativeStatus.FAILED.value:
|
|
309
|
+
logger.info(
|
|
310
|
+
"Reactivating failed initiative %s with dedup_key %s",
|
|
311
|
+
existing.id, dedup_key,
|
|
312
|
+
)
|
|
313
|
+
reactivated = self.update(
|
|
314
|
+
existing.id,
|
|
315
|
+
status=InitiativeStatus.PENDING.value,
|
|
316
|
+
failed_at=None,
|
|
317
|
+
failed_reason=None,
|
|
318
|
+
attempt_count=0,
|
|
319
|
+
assigned_agent_id=None,
|
|
320
|
+
assigned_agent_name=None,
|
|
321
|
+
assigned_at=None,
|
|
322
|
+
acknowledged_at=None,
|
|
323
|
+
)
|
|
324
|
+
return reactivated, "reactivated"
|
|
325
|
+
# Completed/cancelled for THIS period — already ran; don't re-fire within it.
|
|
326
|
+
# A recurring type gets a new dedup_key next period and re-arms there.
|
|
327
|
+
logger.debug(
|
|
328
|
+
"dedup_key %s already ran this period (status=%s); suppressing",
|
|
329
|
+
dedup_key, existing.status,
|
|
330
|
+
)
|
|
331
|
+
return existing, "deduped_terminal"
|
|
332
|
+
|
|
333
|
+
# Bug 26: Validate priority range
|
|
334
|
+
priority = max(0.0, min(1.0, priority))
|
|
335
|
+
|
|
336
|
+
initiative_id = str(uuid.uuid4())
|
|
337
|
+
now = datetime.now(timezone.utc)
|
|
338
|
+
|
|
339
|
+
self._db.execute(
|
|
340
|
+
"""
|
|
341
|
+
INSERT INTO initiatives (
|
|
342
|
+
id, dedup_key, dedup_base, type, description, priority, rationale,
|
|
343
|
+
action_hint, entity_id, source_type, source_id, created_by,
|
|
344
|
+
timeout_seconds, expires_at, preferred_agent_id, job_id,
|
|
345
|
+
context, created_at
|
|
346
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
347
|
+
""",
|
|
348
|
+
[
|
|
349
|
+
initiative_id,
|
|
350
|
+
dedup_key,
|
|
351
|
+
dedup_base,
|
|
352
|
+
type,
|
|
353
|
+
description,
|
|
354
|
+
priority,
|
|
355
|
+
rationale,
|
|
356
|
+
action_hint,
|
|
357
|
+
entity_id,
|
|
358
|
+
source_type,
|
|
359
|
+
source_id,
|
|
360
|
+
created_by,
|
|
361
|
+
timeout_seconds,
|
|
362
|
+
expires_at.isoformat() if expires_at else None,
|
|
363
|
+
preferred_agent_id,
|
|
364
|
+
job_id,
|
|
365
|
+
json.dumps(context) if context is not None else None,
|
|
366
|
+
now.isoformat(),
|
|
367
|
+
],
|
|
368
|
+
)
|
|
369
|
+
self._db.commit()
|
|
370
|
+
|
|
371
|
+
return self.get(initiative_id), "created"
|
|
372
|
+
|
|
373
|
+
def get(self, initiative_id: str) -> Optional[StoredInitiative]:
|
|
374
|
+
"""Get initiative by ID."""
|
|
375
|
+
cursor = self._db.execute(
|
|
376
|
+
"SELECT * FROM initiatives WHERE id = ?",
|
|
377
|
+
[initiative_id],
|
|
378
|
+
)
|
|
379
|
+
row = cursor.fetchone()
|
|
380
|
+
if row:
|
|
381
|
+
return StoredInitiative.from_row(dict(row))
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
def get_by_dedup_key(self, dedup_key: str) -> Optional[StoredInitiative]:
|
|
385
|
+
"""Get initiative by dedup key."""
|
|
386
|
+
cursor = self._db.execute(
|
|
387
|
+
"SELECT * FROM initiatives WHERE dedup_key = ?",
|
|
388
|
+
[dedup_key],
|
|
389
|
+
)
|
|
390
|
+
row = cursor.fetchone()
|
|
391
|
+
if row:
|
|
392
|
+
return StoredInitiative.from_row(dict(row))
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
def get_active_by_dedup_base(self, dedup_base: str) -> Optional[StoredInitiative]:
|
|
396
|
+
"""The most recent ACTIVE initiative sharing this logical (un-bucketed) key, or None.
|
|
397
|
+
Used to suppress a duplicate when a recurring initiative's period rolls over while a
|
|
398
|
+
prior instance is still in flight."""
|
|
399
|
+
cursor = self._db.execute(
|
|
400
|
+
"SELECT * FROM initiatives WHERE dedup_base = ? "
|
|
401
|
+
"AND status IN ('pending', 'assigned', 'acknowledged') "
|
|
402
|
+
"ORDER BY created_at DESC LIMIT 1",
|
|
403
|
+
[dedup_base],
|
|
404
|
+
)
|
|
405
|
+
row = cursor.fetchone()
|
|
406
|
+
if row:
|
|
407
|
+
return StoredInitiative.from_row(dict(row))
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
# Columns accepted by update(). Kept in sync with _create_tables() so a
|
|
411
|
+
# typo or a renamed column fails loudly at the API boundary instead of
|
|
412
|
+
# being swallowed by an outer except clause that calls the failure
|
|
413
|
+
# "non-fatal" (see autonomy/loop.py:_phase_ghost_cleanup for the bug class
|
|
414
|
+
# this whitelist was added to prevent).
|
|
415
|
+
_UPDATABLE_COLUMNS = frozenset({
|
|
416
|
+
"dedup_key", "dedup_base", "type", "description", "priority", "rationale",
|
|
417
|
+
"action_hint", "entity_id", "source_type", "source_id", "created_by",
|
|
418
|
+
"status", "assigned_agent_id", "assigned_agent_name", "assigned_at",
|
|
419
|
+
"acknowledged_at", "completed_at", "cancelled_at", "cancelled_by",
|
|
420
|
+
"cancelled_reason", "failed_at", "failed_reason",
|
|
421
|
+
"attempt_count", "max_attempts", "timeout_seconds", "last_attempt_at",
|
|
422
|
+
"expires_at",
|
|
423
|
+
"delivery_mode", "delivery_attempts", "last_delivery_at",
|
|
424
|
+
"delivery_failed_at", "delivery_failed_reason",
|
|
425
|
+
"result", "result_metadata",
|
|
426
|
+
"preferred_agent_id", "stale_reason", "recovery_reason", "job_id",
|
|
427
|
+
"context",
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
def update(self, initiative_id: str, **updates) -> Optional[StoredInitiative]:
|
|
431
|
+
"""Update initiative fields."""
|
|
432
|
+
if not updates:
|
|
433
|
+
return self.get(initiative_id)
|
|
434
|
+
|
|
435
|
+
unknown = set(updates) - self._UPDATABLE_COLUMNS
|
|
436
|
+
if unknown:
|
|
437
|
+
raise ValueError(
|
|
438
|
+
f"Unknown initiative column(s): {sorted(unknown)}"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# Build SET clause
|
|
442
|
+
set_parts = []
|
|
443
|
+
params = []
|
|
444
|
+
|
|
445
|
+
for key, value in updates.items():
|
|
446
|
+
if key in ("result_metadata", "context"):
|
|
447
|
+
set_parts.append(f"{key} = ?")
|
|
448
|
+
if value is None:
|
|
449
|
+
params.append(None)
|
|
450
|
+
else:
|
|
451
|
+
params.append(json.dumps(value) if not isinstance(value, str) else value)
|
|
452
|
+
elif key in (
|
|
453
|
+
"assigned_at",
|
|
454
|
+
"acknowledged_at",
|
|
455
|
+
"completed_at",
|
|
456
|
+
"cancelled_at",
|
|
457
|
+
"failed_at",
|
|
458
|
+
"expires_at",
|
|
459
|
+
"last_attempt_at",
|
|
460
|
+
"last_delivery_at",
|
|
461
|
+
"delivery_failed_at",
|
|
462
|
+
):
|
|
463
|
+
set_parts.append(f"{key} = ?")
|
|
464
|
+
if isinstance(value, datetime):
|
|
465
|
+
params.append(value.isoformat())
|
|
466
|
+
else:
|
|
467
|
+
params.append(value)
|
|
468
|
+
else:
|
|
469
|
+
set_parts.append(f"{key} = ?")
|
|
470
|
+
params.append(value)
|
|
471
|
+
|
|
472
|
+
if not set_parts:
|
|
473
|
+
return self.get(initiative_id)
|
|
474
|
+
|
|
475
|
+
params.append(initiative_id)
|
|
476
|
+
query = f"UPDATE initiatives SET {', '.join(set_parts)} WHERE id = ?"
|
|
477
|
+
|
|
478
|
+
self._db.execute(query, params)
|
|
479
|
+
self._db.commit()
|
|
480
|
+
|
|
481
|
+
return self.get(initiative_id)
|
|
482
|
+
|
|
483
|
+
def list(
|
|
484
|
+
self,
|
|
485
|
+
status: Optional[List[str]] = None,
|
|
486
|
+
type: Optional[str] = None,
|
|
487
|
+
assigned_agent_id: Optional[str] = None,
|
|
488
|
+
job_id: Optional[str] = None,
|
|
489
|
+
created_before: Optional[datetime] = None,
|
|
490
|
+
created_after: Optional[datetime] = None,
|
|
491
|
+
acknowledged_before: Optional[datetime] = None,
|
|
492
|
+
limit: int = 100,
|
|
493
|
+
offset: int = 0,
|
|
494
|
+
) -> List[StoredInitiative]:
|
|
495
|
+
"""List initiatives with filters."""
|
|
496
|
+
query = "SELECT * FROM initiatives WHERE 1=1"
|
|
497
|
+
params: List[Any] = []
|
|
498
|
+
|
|
499
|
+
if status:
|
|
500
|
+
placeholders = ",".join("?" * len(status))
|
|
501
|
+
query += f" AND status IN ({placeholders})"
|
|
502
|
+
params.extend(status)
|
|
503
|
+
|
|
504
|
+
if type:
|
|
505
|
+
query += " AND type = ?"
|
|
506
|
+
params.append(type)
|
|
507
|
+
|
|
508
|
+
if assigned_agent_id:
|
|
509
|
+
query += " AND assigned_agent_id = ?"
|
|
510
|
+
params.append(assigned_agent_id)
|
|
511
|
+
|
|
512
|
+
if job_id:
|
|
513
|
+
query += " AND job_id = ?"
|
|
514
|
+
params.append(job_id)
|
|
515
|
+
|
|
516
|
+
if created_before:
|
|
517
|
+
query += " AND created_at < ?"
|
|
518
|
+
params.append(created_before.isoformat())
|
|
519
|
+
|
|
520
|
+
if created_after:
|
|
521
|
+
query += " AND created_at > ?"
|
|
522
|
+
params.append(created_after.isoformat())
|
|
523
|
+
|
|
524
|
+
if acknowledged_before:
|
|
525
|
+
query += " AND acknowledged_at < ?"
|
|
526
|
+
params.append(acknowledged_before.isoformat())
|
|
527
|
+
|
|
528
|
+
query += " ORDER BY priority DESC, created_at ASC LIMIT ? OFFSET ?"
|
|
529
|
+
params.extend([limit, offset])
|
|
530
|
+
|
|
531
|
+
cursor = self._db.execute(query, params)
|
|
532
|
+
return [StoredInitiative.from_row(dict(row)) for row in cursor.fetchall()]
|
|
533
|
+
|
|
534
|
+
def count(
|
|
535
|
+
self,
|
|
536
|
+
status: Optional[List[str]] = None,
|
|
537
|
+
assigned_agent_id: Optional[str] = None,
|
|
538
|
+
job_id: Optional[str] = None,
|
|
539
|
+
) -> int:
|
|
540
|
+
"""Count initiatives with filters."""
|
|
541
|
+
query = "SELECT COUNT(*) FROM initiatives WHERE 1=1"
|
|
542
|
+
params: List[Any] = []
|
|
543
|
+
|
|
544
|
+
if status:
|
|
545
|
+
placeholders = ",".join("?" * len(status))
|
|
546
|
+
query += f" AND status IN ({placeholders})"
|
|
547
|
+
params.extend(status)
|
|
548
|
+
|
|
549
|
+
if assigned_agent_id:
|
|
550
|
+
query += " AND assigned_agent_id = ?"
|
|
551
|
+
params.append(assigned_agent_id)
|
|
552
|
+
|
|
553
|
+
if job_id:
|
|
554
|
+
query += " AND job_id = ?"
|
|
555
|
+
params.append(job_id)
|
|
556
|
+
|
|
557
|
+
cursor = self._db.execute(query, params)
|
|
558
|
+
return cursor.fetchone()[0]
|
|
559
|
+
|
|
560
|
+
# ------------------------------------------------------------------
|
|
561
|
+
# Status Transitions
|
|
562
|
+
# ------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
def assign(
|
|
565
|
+
self,
|
|
566
|
+
initiative_id: str,
|
|
567
|
+
agent_id: str,
|
|
568
|
+
agent_name: Optional[str] = None,
|
|
569
|
+
) -> Optional[StoredInitiative]:
|
|
570
|
+
"""Assign initiative to agent (atomic)."""
|
|
571
|
+
now = datetime.now(timezone.utc)
|
|
572
|
+
|
|
573
|
+
# Atomic UPDATE - only works on pending initiatives
|
|
574
|
+
cursor = self._db.execute(
|
|
575
|
+
"""
|
|
576
|
+
UPDATE initiatives
|
|
577
|
+
SET status = ?,
|
|
578
|
+
assigned_agent_id = ?,
|
|
579
|
+
assigned_agent_name = ?,
|
|
580
|
+
assigned_at = ?
|
|
581
|
+
WHERE id = ? AND status = ?
|
|
582
|
+
""",
|
|
583
|
+
[
|
|
584
|
+
InitiativeStatus.ASSIGNED.value,
|
|
585
|
+
agent_id,
|
|
586
|
+
agent_name,
|
|
587
|
+
now.isoformat(),
|
|
588
|
+
initiative_id,
|
|
589
|
+
InitiativeStatus.PENDING.value,
|
|
590
|
+
],
|
|
591
|
+
)
|
|
592
|
+
self._db.commit()
|
|
593
|
+
|
|
594
|
+
if cursor.rowcount == 0:
|
|
595
|
+
return None
|
|
596
|
+
|
|
597
|
+
# Log history
|
|
598
|
+
self.log_history(
|
|
599
|
+
initiative_id,
|
|
600
|
+
action="assigned",
|
|
601
|
+
agent_id=agent_id,
|
|
602
|
+
agent_name=agent_name,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
return self.get(initiative_id)
|
|
606
|
+
|
|
607
|
+
def acknowledge(
|
|
608
|
+
self,
|
|
609
|
+
initiative_id: str,
|
|
610
|
+
agent_id: str,
|
|
611
|
+
) -> Optional[StoredInitiative]:
|
|
612
|
+
"""Mark initiative as acknowledged by agent."""
|
|
613
|
+
now = datetime.now(timezone.utc)
|
|
614
|
+
|
|
615
|
+
initiative = self.get(initiative_id)
|
|
616
|
+
if not initiative or initiative.assigned_agent_id != agent_id:
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
updated = self.update(
|
|
620
|
+
initiative_id,
|
|
621
|
+
status=InitiativeStatus.ACKNOWLEDGED.value,
|
|
622
|
+
acknowledged_at=now,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
if updated:
|
|
626
|
+
self.log_history(
|
|
627
|
+
initiative_id,
|
|
628
|
+
action="acknowledged",
|
|
629
|
+
agent_id=agent_id,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
return updated
|
|
633
|
+
|
|
634
|
+
def complete(
|
|
635
|
+
self,
|
|
636
|
+
initiative_id: str,
|
|
637
|
+
agent_id: str,
|
|
638
|
+
result: Optional[str] = None,
|
|
639
|
+
result_metadata: Optional[Dict[str, Any]] = None,
|
|
640
|
+
) -> Optional[StoredInitiative]:
|
|
641
|
+
"""Mark initiative as completed."""
|
|
642
|
+
now = datetime.now(timezone.utc)
|
|
643
|
+
|
|
644
|
+
initiative = self.get(initiative_id)
|
|
645
|
+
if not initiative:
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
# Check if expired
|
|
649
|
+
if initiative.is_expired:
|
|
650
|
+
return self.update(
|
|
651
|
+
initiative_id,
|
|
652
|
+
status=InitiativeStatus.FAILED.value,
|
|
653
|
+
failed_at=now,
|
|
654
|
+
failed_reason="initiative_expired",
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
updated = self.update(
|
|
658
|
+
initiative_id,
|
|
659
|
+
status=InitiativeStatus.COMPLETED.value,
|
|
660
|
+
completed_at=now,
|
|
661
|
+
result=result,
|
|
662
|
+
result_metadata=result_metadata or {},
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
if updated:
|
|
666
|
+
self.log_history(
|
|
667
|
+
initiative_id,
|
|
668
|
+
action="completed",
|
|
669
|
+
agent_id=agent_id,
|
|
670
|
+
details={"result": result},
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
return updated
|
|
674
|
+
|
|
675
|
+
def fail(
|
|
676
|
+
self,
|
|
677
|
+
initiative_id: str,
|
|
678
|
+
agent_id: str,
|
|
679
|
+
reason: str,
|
|
680
|
+
retry: bool = False,
|
|
681
|
+
) -> Optional[StoredInitiative]:
|
|
682
|
+
"""Mark initiative as failed."""
|
|
683
|
+
now = datetime.now(timezone.utc)
|
|
684
|
+
|
|
685
|
+
initiative = self.get(initiative_id)
|
|
686
|
+
if not initiative:
|
|
687
|
+
return None
|
|
688
|
+
|
|
689
|
+
new_attempt_count = initiative.attempt_count + 1
|
|
690
|
+
|
|
691
|
+
# If retry requested and attempts remaining, reset to pending
|
|
692
|
+
if retry and new_attempt_count < initiative.max_attempts:
|
|
693
|
+
updated = self.update(
|
|
694
|
+
initiative_id,
|
|
695
|
+
status=InitiativeStatus.PENDING.value,
|
|
696
|
+
assigned_agent_id=None,
|
|
697
|
+
assigned_agent_name=None,
|
|
698
|
+
assigned_at=None,
|
|
699
|
+
acknowledged_at=None,
|
|
700
|
+
attempt_count=new_attempt_count,
|
|
701
|
+
last_attempt_at=now,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
self.log_history(
|
|
705
|
+
initiative_id,
|
|
706
|
+
action="retry_scheduled",
|
|
707
|
+
agent_id=agent_id,
|
|
708
|
+
details={"reason": reason, "attempt": new_attempt_count},
|
|
709
|
+
)
|
|
710
|
+
else:
|
|
711
|
+
# Mark as failed
|
|
712
|
+
updated = self.update(
|
|
713
|
+
initiative_id,
|
|
714
|
+
status=InitiativeStatus.FAILED.value,
|
|
715
|
+
failed_at=now,
|
|
716
|
+
failed_reason=reason,
|
|
717
|
+
attempt_count=new_attempt_count,
|
|
718
|
+
last_attempt_at=now,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
self.log_history(
|
|
722
|
+
initiative_id,
|
|
723
|
+
action="failed",
|
|
724
|
+
agent_id=agent_id,
|
|
725
|
+
details={"reason": reason, "attempt": new_attempt_count},
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
# Add to dead letter queue if max attempts reached
|
|
729
|
+
if new_attempt_count >= initiative.max_attempts:
|
|
730
|
+
self._add_to_dlq(initiative, reason)
|
|
731
|
+
|
|
732
|
+
return updated
|
|
733
|
+
|
|
734
|
+
def retry(self, initiative_id: str, actor_id: str) -> Optional[StoredInitiative]:
|
|
735
|
+
"""Requeue a failed initiative and its history in one transaction."""
|
|
736
|
+
if not isinstance(actor_id, str) or not actor_id.strip():
|
|
737
|
+
raise ValueError("Retry requires an actor")
|
|
738
|
+
# Do not borrow the shared connection: another operation must never
|
|
739
|
+
# commit this transition without its history or inherit a failed write.
|
|
740
|
+
with closing(sqlite3.connect(self._db_path, timeout=2)) as db, db:
|
|
741
|
+
db.row_factory = sqlite3.Row
|
|
742
|
+
db.execute("BEGIN IMMEDIATE")
|
|
743
|
+
row = db.execute("SELECT * FROM initiatives WHERE id=? AND status='failed'",
|
|
744
|
+
(initiative_id,)).fetchone()
|
|
745
|
+
if row is None:
|
|
746
|
+
return None
|
|
747
|
+
details = {
|
|
748
|
+
"attempt_count": row["attempt_count"],
|
|
749
|
+
"previous_agent_id": row["assigned_agent_id"],
|
|
750
|
+
"failed_reason": row["failed_reason"],
|
|
751
|
+
"failed_at": row["failed_at"],
|
|
752
|
+
}
|
|
753
|
+
db.execute("""UPDATE initiatives SET status='pending', assigned_agent_id=NULL,
|
|
754
|
+
failed_reason=NULL, failed_at=NULL WHERE id=?""", (initiative_id,))
|
|
755
|
+
db.execute("""INSERT INTO assignment_history(initiative_id,agent_id,action,details)
|
|
756
|
+
VALUES(?,?,'retry',?)""", (initiative_id, actor_id, json.dumps(details)))
|
|
757
|
+
return StoredInitiative.from_row(dict(db.execute(
|
|
758
|
+
"SELECT * FROM initiatives WHERE id=?", (initiative_id,)).fetchone()))
|
|
759
|
+
|
|
760
|
+
def cancel(
|
|
761
|
+
self,
|
|
762
|
+
initiative_id: str,
|
|
763
|
+
cancelled_by: str,
|
|
764
|
+
reason: Optional[str] = None,
|
|
765
|
+
) -> Optional[StoredInitiative]:
|
|
766
|
+
"""Cancel an initiative."""
|
|
767
|
+
now = datetime.now(timezone.utc)
|
|
768
|
+
|
|
769
|
+
initiative = self.get(initiative_id)
|
|
770
|
+
if not initiative or not initiative.is_active:
|
|
771
|
+
return None
|
|
772
|
+
|
|
773
|
+
updated = self.update(
|
|
774
|
+
initiative_id,
|
|
775
|
+
status=InitiativeStatus.CANCELLED.value,
|
|
776
|
+
cancelled_at=now,
|
|
777
|
+
cancelled_by=cancelled_by,
|
|
778
|
+
cancelled_reason=reason,
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
if updated:
|
|
782
|
+
self.log_history(
|
|
783
|
+
initiative_id,
|
|
784
|
+
action="cancelled",
|
|
785
|
+
agent_id=cancelled_by,
|
|
786
|
+
details={"reason": reason},
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
return updated
|
|
790
|
+
|
|
791
|
+
# ------------------------------------------------------------------
|
|
792
|
+
# Reassignment
|
|
793
|
+
# ------------------------------------------------------------------
|
|
794
|
+
|
|
795
|
+
def reassign_from_agent(
|
|
796
|
+
self,
|
|
797
|
+
agent_id: str,
|
|
798
|
+
only_pending: bool = True,
|
|
799
|
+
) -> int:
|
|
800
|
+
"""Reassign initiatives from an agent.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
agent_id: Agent to reassign from
|
|
804
|
+
only_pending: If True, only reassign PENDING (not ACKNOWLEDGED)
|
|
805
|
+
|
|
806
|
+
Returns:
|
|
807
|
+
Number of initiatives reassigned
|
|
808
|
+
"""
|
|
809
|
+
if only_pending:
|
|
810
|
+
initiatives = self.list(
|
|
811
|
+
status=[InitiativeStatus.PENDING.value],
|
|
812
|
+
assigned_agent_id=agent_id,
|
|
813
|
+
)
|
|
814
|
+
else:
|
|
815
|
+
initiatives = self.list(
|
|
816
|
+
status=[
|
|
817
|
+
InitiativeStatus.PENDING.value,
|
|
818
|
+
InitiativeStatus.ASSIGNED.value,
|
|
819
|
+
InitiativeStatus.ACKNOWLEDGED.value,
|
|
820
|
+
],
|
|
821
|
+
assigned_agent_id=agent_id,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
reassigned = 0
|
|
825
|
+
for init in initiatives:
|
|
826
|
+
updated = self.update(
|
|
827
|
+
init.id,
|
|
828
|
+
status=InitiativeStatus.PENDING.value,
|
|
829
|
+
assigned_agent_id=None,
|
|
830
|
+
assigned_agent_name=None,
|
|
831
|
+
assigned_at=None,
|
|
832
|
+
acknowledged_at=None,
|
|
833
|
+
recovery_reason="agent_offline",
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
if updated:
|
|
837
|
+
self.log_history(
|
|
838
|
+
init.id,
|
|
839
|
+
action="reassigned",
|
|
840
|
+
agent_id=agent_id,
|
|
841
|
+
details={"reason": "agent_offline", "only_pending": only_pending},
|
|
842
|
+
)
|
|
843
|
+
reassigned += 1
|
|
844
|
+
|
|
845
|
+
return reassigned
|
|
846
|
+
|
|
847
|
+
# ------------------------------------------------------------------
|
|
848
|
+
# Timeout & Expiry
|
|
849
|
+
# ------------------------------------------------------------------
|
|
850
|
+
|
|
851
|
+
def find_timed_out(self, now: datetime) -> List[StoredInitiative]:
|
|
852
|
+
"""Find initiatives that have exceeded their timeout."""
|
|
853
|
+
cursor = self._db.execute(
|
|
854
|
+
"""
|
|
855
|
+
SELECT * FROM initiatives
|
|
856
|
+
WHERE status IN ('assigned', 'acknowledged')
|
|
857
|
+
AND timeout_seconds IS NOT NULL
|
|
858
|
+
AND assigned_at IS NOT NULL
|
|
859
|
+
AND datetime(assigned_at, '+' || timeout_seconds || ' seconds') < ?
|
|
860
|
+
""",
|
|
861
|
+
[now.isoformat()],
|
|
862
|
+
)
|
|
863
|
+
return [StoredInitiative.from_row(dict(row)) for row in cursor.fetchall()]
|
|
864
|
+
|
|
865
|
+
def find_expired(self, now: datetime) -> List[StoredInitiative]:
|
|
866
|
+
"""Find initiatives that have expired."""
|
|
867
|
+
cursor = self._db.execute(
|
|
868
|
+
"""
|
|
869
|
+
SELECT * FROM initiatives
|
|
870
|
+
WHERE status IN ('pending', 'assigned', 'acknowledged')
|
|
871
|
+
AND expires_at IS NOT NULL
|
|
872
|
+
AND expires_at < ?
|
|
873
|
+
""",
|
|
874
|
+
[now.isoformat()],
|
|
875
|
+
)
|
|
876
|
+
return [StoredInitiative.from_row(dict(row)) for row in cursor.fetchall()]
|
|
877
|
+
|
|
878
|
+
def find_stale_acknowledged(
|
|
879
|
+
self,
|
|
880
|
+
threshold: datetime,
|
|
881
|
+
) -> List[StoredInitiative]:
|
|
882
|
+
"""Find initiatives stuck in acknowledged state."""
|
|
883
|
+
cursor = self._db.execute(
|
|
884
|
+
"""
|
|
885
|
+
SELECT * FROM initiatives
|
|
886
|
+
WHERE status = ?
|
|
887
|
+
AND acknowledged_at < ?
|
|
888
|
+
""",
|
|
889
|
+
[InitiativeStatus.ACKNOWLEDGED.value, threshold.isoformat()],
|
|
890
|
+
)
|
|
891
|
+
return [StoredInitiative.from_row(dict(row)) for row in cursor.fetchall()]
|
|
892
|
+
|
|
893
|
+
# ------------------------------------------------------------------
|
|
894
|
+
# History
|
|
895
|
+
# ------------------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
def log_history(
|
|
898
|
+
self,
|
|
899
|
+
initiative_id: str,
|
|
900
|
+
action: str,
|
|
901
|
+
agent_id: str,
|
|
902
|
+
agent_name: Optional[str] = None,
|
|
903
|
+
details: Optional[Dict[str, Any]] = None,
|
|
904
|
+
) -> None:
|
|
905
|
+
"""Log assignment history."""
|
|
906
|
+
self._db.execute(
|
|
907
|
+
"""
|
|
908
|
+
INSERT INTO assignment_history (
|
|
909
|
+
initiative_id, agent_id, agent_name, action, details
|
|
910
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
911
|
+
""",
|
|
912
|
+
[
|
|
913
|
+
initiative_id,
|
|
914
|
+
agent_id,
|
|
915
|
+
agent_name,
|
|
916
|
+
action,
|
|
917
|
+
json.dumps(details) if details else None,
|
|
918
|
+
],
|
|
919
|
+
)
|
|
920
|
+
self._db.commit()
|
|
921
|
+
|
|
922
|
+
def get_history(
|
|
923
|
+
self,
|
|
924
|
+
initiative_id: str,
|
|
925
|
+
limit: int = 50,
|
|
926
|
+
) -> List[AssignmentHistory]:
|
|
927
|
+
"""Get assignment history for an initiative."""
|
|
928
|
+
cursor = self._db.execute(
|
|
929
|
+
"""
|
|
930
|
+
SELECT * FROM assignment_history
|
|
931
|
+
WHERE initiative_id = ?
|
|
932
|
+
ORDER BY timestamp DESC
|
|
933
|
+
LIMIT ?
|
|
934
|
+
""",
|
|
935
|
+
[initiative_id, limit],
|
|
936
|
+
)
|
|
937
|
+
return [AssignmentHistory.from_row(dict(row)) for row in cursor.fetchall()]
|
|
938
|
+
|
|
939
|
+
def get_agent_history(
|
|
940
|
+
self,
|
|
941
|
+
agent_id: str,
|
|
942
|
+
limit: int = 50,
|
|
943
|
+
offset: int = 0,
|
|
944
|
+
) -> List[AssignmentHistory]:
|
|
945
|
+
"""Get assignment history for an agent."""
|
|
946
|
+
cursor = self._db.execute(
|
|
947
|
+
"""
|
|
948
|
+
SELECT * FROM assignment_history
|
|
949
|
+
WHERE agent_id = ?
|
|
950
|
+
ORDER BY timestamp DESC
|
|
951
|
+
LIMIT ? OFFSET ?
|
|
952
|
+
""",
|
|
953
|
+
[agent_id, limit, offset],
|
|
954
|
+
)
|
|
955
|
+
return [AssignmentHistory.from_row(dict(row)) for row in cursor.fetchall()]
|
|
956
|
+
|
|
957
|
+
def count_agent_assignments_since(
|
|
958
|
+
self,
|
|
959
|
+
agent_id: str,
|
|
960
|
+
since: datetime,
|
|
961
|
+
) -> int:
|
|
962
|
+
"""Count `assigned` history rows for ``agent_id`` newer than ``since``.
|
|
963
|
+
|
|
964
|
+
Used by the assignment engine's hourly rate limit. Counts only the
|
|
965
|
+
``assigned`` action so reassignment churn (e.g. ghost cleanup) does
|
|
966
|
+
not eat into an agent's hourly budget.
|
|
967
|
+
"""
|
|
968
|
+
cursor = self._db.execute(
|
|
969
|
+
"""
|
|
970
|
+
SELECT COUNT(*) FROM assignment_history
|
|
971
|
+
WHERE agent_id = ?
|
|
972
|
+
AND action = 'assigned'
|
|
973
|
+
AND timestamp > ?
|
|
974
|
+
""",
|
|
975
|
+
[agent_id, since.isoformat()],
|
|
976
|
+
)
|
|
977
|
+
return cursor.fetchone()[0]
|
|
978
|
+
|
|
979
|
+
# ------------------------------------------------------------------
|
|
980
|
+
# Dead Letter Queue
|
|
981
|
+
# ------------------------------------------------------------------
|
|
982
|
+
|
|
983
|
+
def _add_to_dlq(
|
|
984
|
+
self,
|
|
985
|
+
initiative: StoredInitiative,
|
|
986
|
+
reason: str,
|
|
987
|
+
) -> None:
|
|
988
|
+
"""Add failed initiative to dead letter queue."""
|
|
989
|
+
entry = {
|
|
990
|
+
"initiative_id": initiative.id,
|
|
991
|
+
"type": initiative.type,
|
|
992
|
+
"description": initiative.description,
|
|
993
|
+
"reason": reason,
|
|
994
|
+
"attempt_count": initiative.attempt_count,
|
|
995
|
+
"failed_at": datetime.now(timezone.utc).isoformat(),
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
with open(self._dlq_path, "a") as f:
|
|
999
|
+
f.write(json.dumps(entry) + "\n")
|
|
1000
|
+
|
|
1001
|
+
logger.warning(
|
|
1002
|
+
"Added initiative %s to dead letter queue: %s",
|
|
1003
|
+
initiative.id,
|
|
1004
|
+
reason,
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
def get_dlq_entries(self, limit: int = 50) -> List[Dict[str, Any]]:
|
|
1008
|
+
"""Get entries from dead letter queue."""
|
|
1009
|
+
if not self._dlq_path.exists():
|
|
1010
|
+
return []
|
|
1011
|
+
|
|
1012
|
+
entries = []
|
|
1013
|
+
with open(self._dlq_path, "r") as f:
|
|
1014
|
+
for line in f:
|
|
1015
|
+
line = line.strip()
|
|
1016
|
+
if line:
|
|
1017
|
+
try:
|
|
1018
|
+
entries.append(json.loads(line))
|
|
1019
|
+
except json.JSONDecodeError:
|
|
1020
|
+
continue
|
|
1021
|
+
|
|
1022
|
+
return entries[-limit:]
|
|
1023
|
+
|
|
1024
|
+
def remove_from_dlq(self, initiative_id: str) -> bool:
|
|
1025
|
+
"""Remove initiative from dead letter queue."""
|
|
1026
|
+
if not self._dlq_path.exists():
|
|
1027
|
+
return False
|
|
1028
|
+
|
|
1029
|
+
entries = []
|
|
1030
|
+
removed = False
|
|
1031
|
+
|
|
1032
|
+
with open(self._dlq_path, "r") as f:
|
|
1033
|
+
for line in f:
|
|
1034
|
+
line = line.strip()
|
|
1035
|
+
if line:
|
|
1036
|
+
try:
|
|
1037
|
+
entry = json.loads(line)
|
|
1038
|
+
if entry.get("initiative_id") == initiative_id:
|
|
1039
|
+
removed = True
|
|
1040
|
+
else:
|
|
1041
|
+
entries.append(entry)
|
|
1042
|
+
except json.JSONDecodeError:
|
|
1043
|
+
continue
|
|
1044
|
+
|
|
1045
|
+
if removed:
|
|
1046
|
+
with open(self._dlq_path, "w") as f:
|
|
1047
|
+
for entry in entries:
|
|
1048
|
+
f.write(json.dumps(entry) + "\n")
|
|
1049
|
+
|
|
1050
|
+
return removed
|
|
1051
|
+
|
|
1052
|
+
# ------------------------------------------------------------------
|
|
1053
|
+
# Cleanup
|
|
1054
|
+
# ------------------------------------------------------------------
|
|
1055
|
+
|
|
1056
|
+
def delete_old(
|
|
1057
|
+
self,
|
|
1058
|
+
status: List[str],
|
|
1059
|
+
before: datetime,
|
|
1060
|
+
) -> int:
|
|
1061
|
+
"""Delete old initiatives with given statuses."""
|
|
1062
|
+
placeholders = ",".join("?" * len(status))
|
|
1063
|
+
cursor = self._db.execute(
|
|
1064
|
+
f"""
|
|
1065
|
+
DELETE FROM initiatives
|
|
1066
|
+
WHERE status IN ({placeholders})
|
|
1067
|
+
AND created_at < ?
|
|
1068
|
+
""",
|
|
1069
|
+
[*status, before.isoformat()],
|
|
1070
|
+
)
|
|
1071
|
+
self._db.commit()
|
|
1072
|
+
return cursor.rowcount
|
|
1073
|
+
|
|
1074
|
+
def backup(self) -> None:
|
|
1075
|
+
"""Create backup of database."""
|
|
1076
|
+
shutil.copy2(self._db_path, self._backup_path)
|
|
1077
|
+
|
|
1078
|
+
def close(self) -> None:
|
|
1079
|
+
"""Close connection and create backup."""
|
|
1080
|
+
self.backup()
|
|
1081
|
+
self._db.close()
|