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,993 @@
|
|
|
1
|
+
"""Distributed Task Queue — Worker Node.
|
|
2
|
+
|
|
3
|
+
A WorkerNode polls the queue for eligible jobs, executes them via
|
|
4
|
+
registered handlers, and sends periodic heartbeats.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from apsimo.skills.learning.triggers import SkillLearningService
|
|
20
|
+
|
|
21
|
+
from apsimo.task_queue.models import (
|
|
22
|
+
Job,
|
|
23
|
+
JobResult,
|
|
24
|
+
JobStatus,
|
|
25
|
+
JobType,
|
|
26
|
+
WorkerCapabilities,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _prepare_embedded_report(
|
|
33
|
+
job: Job,
|
|
34
|
+
output: Any,
|
|
35
|
+
) -> tuple[Dict[str, Any], bool]:
|
|
36
|
+
"""Normalize a trusted handler result and decide server attestation.
|
|
37
|
+
|
|
38
|
+
Local execution is not itself proof of success. The handler must return
|
|
39
|
+
an unambiguous completed contract with no failure/skip markers. WorkOrder
|
|
40
|
+
success always remains transport-only until its independent verifier runs.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
if not isinstance(output, dict):
|
|
44
|
+
raise TypeError("embedded job handler must return a dictionary")
|
|
45
|
+
report = dict(output)
|
|
46
|
+
action_plane = (
|
|
47
|
+
dict(report.get("action_plane"))
|
|
48
|
+
if isinstance(report.get("action_plane"), dict) else {}
|
|
49
|
+
)
|
|
50
|
+
semantic = str(report.get("status") or "").strip().lower()
|
|
51
|
+
action_state = str(action_plane.get("state") or "").strip().lower()
|
|
52
|
+
failure = bool(
|
|
53
|
+
report.get("success") is False
|
|
54
|
+
or report.get("errors")
|
|
55
|
+
or report.get("alerts")
|
|
56
|
+
or semantic in {"failed", "failure", "error"}
|
|
57
|
+
or action_state in {"failed", "failure", "error"}
|
|
58
|
+
)
|
|
59
|
+
skipped = bool(
|
|
60
|
+
report.get("gate_blocked") is True
|
|
61
|
+
or semantic in {"skipped", "cancelled", "canceled"}
|
|
62
|
+
or action_state in {"skipped", "cancelled", "canceled"}
|
|
63
|
+
)
|
|
64
|
+
if failure:
|
|
65
|
+
report["status"] = "failed"
|
|
66
|
+
report["action_plane"] = {**action_plane, "state": "failed"}
|
|
67
|
+
report.setdefault(
|
|
68
|
+
"summary",
|
|
69
|
+
str(report.get("error") or "embedded handler reported failure"),
|
|
70
|
+
)
|
|
71
|
+
return report, False
|
|
72
|
+
if skipped:
|
|
73
|
+
report["status"] = "skipped"
|
|
74
|
+
report["action_plane"] = {**action_plane, "state": "skipped"}
|
|
75
|
+
return report, False
|
|
76
|
+
attested = bool(
|
|
77
|
+
semantic in {"completed", "verified"}
|
|
78
|
+
and action_state in {"completed", "verified"}
|
|
79
|
+
and not (
|
|
80
|
+
isinstance(job.payload, dict)
|
|
81
|
+
and job.payload.get("schema") == "WorkOrderV1"
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
return report, attested
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def detect_local_capabilities() -> Dict[str, Any]:
|
|
88
|
+
"""Probe the local host and return a capabilities dict.
|
|
89
|
+
|
|
90
|
+
Detects:
|
|
91
|
+
- GPU presence and VRAM (nvidia-smi / Metal)
|
|
92
|
+
- CPU core count and architecture
|
|
93
|
+
- Available RAM (psutil if available)
|
|
94
|
+
- macOS/Linux platform
|
|
95
|
+
- Installed tools (docker, ollama, ffmpeg, git, etc.)
|
|
96
|
+
- Apple Silicon identifier
|
|
97
|
+
"""
|
|
98
|
+
caps: Dict[str, Any] = {}
|
|
99
|
+
capacity: Dict[str, float] = {}
|
|
100
|
+
|
|
101
|
+
# Platform
|
|
102
|
+
system = platform.system().lower()
|
|
103
|
+
machine = platform.machine().lower()
|
|
104
|
+
caps["os_" + system] = True
|
|
105
|
+
if "arm" in machine or "aarch64" in machine:
|
|
106
|
+
caps["arm64"] = True
|
|
107
|
+
if system == "darwin" and ("arm" in machine or "aarch64" in machine):
|
|
108
|
+
caps["apple_silicon"] = True
|
|
109
|
+
caps["metal"] = True
|
|
110
|
+
|
|
111
|
+
# CPU
|
|
112
|
+
cpu_count = os.cpu_count() or 1
|
|
113
|
+
capacity["cpu_cores"] = float(cpu_count)
|
|
114
|
+
|
|
115
|
+
# RAM
|
|
116
|
+
try:
|
|
117
|
+
import psutil
|
|
118
|
+
ram_bytes = psutil.virtual_memory().total
|
|
119
|
+
capacity["ram_gb"] = round(ram_bytes / (1024 ** 3), 1)
|
|
120
|
+
except ImportError:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
# GPU (NVIDIA)
|
|
124
|
+
if shutil.which("nvidia-smi"):
|
|
125
|
+
try:
|
|
126
|
+
result = subprocess.run(
|
|
127
|
+
["nvidia-smi", "--query-gpu=name,memory.total",
|
|
128
|
+
"--format=csv,noheader,nounits"],
|
|
129
|
+
capture_output=True, text=True, timeout=5,
|
|
130
|
+
)
|
|
131
|
+
if result.returncode == 0:
|
|
132
|
+
gpus = result.stdout.strip().splitlines()
|
|
133
|
+
caps["gpu"] = True
|
|
134
|
+
caps["cuda"] = True
|
|
135
|
+
capacity["gpu_count"] = float(len(gpus))
|
|
136
|
+
total_vram = 0.0
|
|
137
|
+
for line in gpus:
|
|
138
|
+
if "," not in line:
|
|
139
|
+
continue
|
|
140
|
+
raw = line.split(",")[1].strip()
|
|
141
|
+
try:
|
|
142
|
+
total_vram += float(raw)
|
|
143
|
+
except ValueError:
|
|
144
|
+
pass # nvidia-smi returned "[N/A]" or similar non-numeric value
|
|
145
|
+
capacity["gpu_vram_gb"] = round(total_vram / 1024, 1)
|
|
146
|
+
except Exception:
|
|
147
|
+
logger.warning("GPU detection failed; treating node as CPU-only", exc_info=True)
|
|
148
|
+
|
|
149
|
+
# Installed tools
|
|
150
|
+
for tool in ["docker", "ollama", "ffmpeg", "git", "python3", "node"]:
|
|
151
|
+
if shutil.which(tool):
|
|
152
|
+
caps[tool] = True
|
|
153
|
+
|
|
154
|
+
return {"capabilities": set(caps.keys()), "capacity": capacity}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class JobHandler:
|
|
158
|
+
"""Base class for job type handlers registered on a worker node.
|
|
159
|
+
|
|
160
|
+
Subclass and implement ``execute`` to handle a specific JobType.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
# Opt in only when cancelling the owning asyncio task really stops all
|
|
164
|
+
# work. Handlers that spawn subprocesses/background effects need their own
|
|
165
|
+
# wrapper and must leave this false.
|
|
166
|
+
work_control_interrupt_safe = False
|
|
167
|
+
# Steering can cross a response-loss/restart boundary. An override is not
|
|
168
|
+
# advertised unless the handler guarantees that ``operation_id`` is a
|
|
169
|
+
# durable idempotency key, so replay after the tiny handler/ledger crash
|
|
170
|
+
# window cannot apply guidance twice.
|
|
171
|
+
work_control_steer_idempotent = False
|
|
172
|
+
|
|
173
|
+
async def execute(self, job: Job) -> Dict[str, Any]:
|
|
174
|
+
"""Execute the job and return an output dict.
|
|
175
|
+
|
|
176
|
+
Raise any exception on failure. WorkerNode catches all exceptions
|
|
177
|
+
and transitions the job to FAILED.
|
|
178
|
+
"""
|
|
179
|
+
raise NotImplementedError
|
|
180
|
+
|
|
181
|
+
async def apply_steer(
|
|
182
|
+
self,
|
|
183
|
+
job: Job,
|
|
184
|
+
*,
|
|
185
|
+
directive: str,
|
|
186
|
+
context_refs: list[str],
|
|
187
|
+
operation_id: str,
|
|
188
|
+
authority_digest: str,
|
|
189
|
+
) -> Dict[str, Any]:
|
|
190
|
+
"""Cooperatively apply a directive to an in-flight handler.
|
|
191
|
+
|
|
192
|
+
Handlers that can truthfully change an ongoing execution override
|
|
193
|
+
this method. Worker registration advertises steer support per job
|
|
194
|
+
type only for an override, so this default is never presented to a
|
|
195
|
+
controller as usable capability. Steering is subordinate guidance,
|
|
196
|
+
never new authority: it may not expand the durable payload,
|
|
197
|
+
capabilities, recipient, effect/risk class, approval, deadline, or
|
|
198
|
+
attempt budget. An expansion requires a new governed queue job.
|
|
199
|
+
|
|
200
|
+
An implementation must return the exact ``authority_digest`` and
|
|
201
|
+
``authority_expanded=False``. The worker rejects any other report.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
raise NotImplementedError("handler does not support live steering")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class WorkerNode:
|
|
208
|
+
"""A mesh node that polls and executes jobs from the distributed queue.
|
|
209
|
+
|
|
210
|
+
Usage::
|
|
211
|
+
|
|
212
|
+
worker = WorkerNode(
|
|
213
|
+
node_id="node-1",
|
|
214
|
+
queue=queue_manager,
|
|
215
|
+
handlers={JobType.INFERENCE: MyInferenceHandler()},
|
|
216
|
+
)
|
|
217
|
+
await worker.start()
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
node_id: str,
|
|
223
|
+
queue: Any, # QueueManager — avoid circular import
|
|
224
|
+
handlers: Optional[Dict[JobType, JobHandler]] = None,
|
|
225
|
+
capabilities: Optional[WorkerCapabilities] = None,
|
|
226
|
+
poll_interval_secs: float = 5.0,
|
|
227
|
+
heartbeat_interval_secs: float = 15.0,
|
|
228
|
+
skill_learning_service: "Optional[SkillLearningService]" = None,
|
|
229
|
+
) -> None:
|
|
230
|
+
self.node_id = node_id
|
|
231
|
+
self._queue = queue
|
|
232
|
+
self._handlers: Dict[JobType, JobHandler] = handlers or {}
|
|
233
|
+
self._capabilities = capabilities or self._build_capabilities()
|
|
234
|
+
# Advertise ONLY the job types we can actually run. Handlers passed via the
|
|
235
|
+
# constructor (build_default_handlers) never touched job_types — only
|
|
236
|
+
# register_handler did — so an embedded worker left job_types empty, which
|
|
237
|
+
# WorkerCapabilities.can_accept treats as "accept ALL types". It then claimed
|
|
238
|
+
# AGENT_ACTION jobs it has no handler for and failed every one ("No handler
|
|
239
|
+
# registered for job_type=..."). AGENT_ACTION is executed by a separate
|
|
240
|
+
# agent-backed worker; keep this node scoped to the types it handles.
|
|
241
|
+
if self._handlers and not self._capabilities.job_types:
|
|
242
|
+
self._capabilities.job_types = set(self._handlers.keys())
|
|
243
|
+
if JobType.THOUGHT in self._handlers:
|
|
244
|
+
# Only a node that deliberately loaded the strict thought handler
|
|
245
|
+
# may claim owner-private cognition jobs. Job-type matching alone
|
|
246
|
+
# is insufficient because an empty advertised set means "all".
|
|
247
|
+
self._configure_thought_handler_capabilities()
|
|
248
|
+
self._rebuild_work_control_capabilities()
|
|
249
|
+
self._poll_interval = poll_interval_secs
|
|
250
|
+
self._heartbeat_interval = heartbeat_interval_secs
|
|
251
|
+
self._running_jobs: Dict[str, asyncio.Task] = {}
|
|
252
|
+
self._skill_tasks: set[asyncio.Task] = set()
|
|
253
|
+
self._job_attempt_ids: Dict[str, str] = {}
|
|
254
|
+
self._job_start_times: Dict[str, datetime] = {}
|
|
255
|
+
self._running = False
|
|
256
|
+
self._registered = False
|
|
257
|
+
self._loop_tasks: set[asyncio.Task] = set()
|
|
258
|
+
self._stop_in_progress = False
|
|
259
|
+
self._stop_cleanup_done = asyncio.Event()
|
|
260
|
+
self._stop_cleanup_done.set()
|
|
261
|
+
self._skill_learning: Optional[Any] = skill_learning_service
|
|
262
|
+
|
|
263
|
+
def _build_capabilities(self) -> WorkerCapabilities:
|
|
264
|
+
detected = detect_local_capabilities()
|
|
265
|
+
return WorkerCapabilities(
|
|
266
|
+
node_id=self.node_id,
|
|
267
|
+
capabilities=detected["capabilities"],
|
|
268
|
+
capacity=detected["capacity"],
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def register_handler(self, job_type: JobType, handler: JobHandler) -> None:
|
|
272
|
+
"""Register a handler for a specific job type."""
|
|
273
|
+
if self._running or self._registered:
|
|
274
|
+
raise RuntimeError(
|
|
275
|
+
"handlers must be registered before the worker starts"
|
|
276
|
+
)
|
|
277
|
+
self._handlers[job_type] = handler
|
|
278
|
+
self._capabilities.job_types.add(job_type)
|
|
279
|
+
if job_type is JobType.THOUGHT:
|
|
280
|
+
self._configure_thought_handler_capabilities()
|
|
281
|
+
self._rebuild_work_control_capabilities()
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _handler_supports_steer(handler: JobHandler) -> bool:
|
|
285
|
+
implementation = getattr(type(handler), "apply_steer", None)
|
|
286
|
+
return bool(
|
|
287
|
+
implementation is not None
|
|
288
|
+
and implementation is not JobHandler.apply_steer
|
|
289
|
+
and getattr(
|
|
290
|
+
handler, "work_control_steer_idempotent", False,
|
|
291
|
+
) is True
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _rebuild_work_control_capabilities(self) -> None:
|
|
295
|
+
"""Derive every WorkControl capability from the final handler map.
|
|
296
|
+
|
|
297
|
+
Caller-preseeded values and capabilities from a replaced handler are
|
|
298
|
+
removed first. This makes registration an attestation of current code,
|
|
299
|
+
never a sticky string supplied by configuration.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
from apsimo.task_queue.work_control import (
|
|
303
|
+
interrupt_capability,
|
|
304
|
+
steer_capability,
|
|
305
|
+
work_control_mode,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
capabilities = {
|
|
309
|
+
str(value)
|
|
310
|
+
for value in (self._capabilities.capabilities or ())
|
|
311
|
+
if not str(value).startswith("work_control:")
|
|
312
|
+
}
|
|
313
|
+
if work_control_mode() != "off":
|
|
314
|
+
for job_type, handler in self._handlers.items():
|
|
315
|
+
if getattr(
|
|
316
|
+
handler, "work_control_interrupt_safe", False,
|
|
317
|
+
) is True:
|
|
318
|
+
capabilities.add(interrupt_capability(job_type))
|
|
319
|
+
if self._handler_supports_steer(handler):
|
|
320
|
+
capabilities.add(steer_capability(job_type))
|
|
321
|
+
self._capabilities.capabilities = capabilities
|
|
322
|
+
|
|
323
|
+
def _configure_thought_handler_capabilities(self) -> None:
|
|
324
|
+
handler = self._handlers.get(JobType.THOUGHT)
|
|
325
|
+
if not bool(getattr(handler, "thought_only", False)):
|
|
326
|
+
raise ValueError(
|
|
327
|
+
"ThoughtJobV1 requires the strict thought-only handler"
|
|
328
|
+
)
|
|
329
|
+
self._capabilities.capabilities = set(
|
|
330
|
+
self._capabilities.capabilities or (),
|
|
331
|
+
)
|
|
332
|
+
self._capabilities.capabilities.update({
|
|
333
|
+
"cognition_scoped", "thought_engine:v1",
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
async def start(self) -> None:
|
|
337
|
+
"""Register with the queue and start poll + heartbeat loops."""
|
|
338
|
+
if self._running or self._registered:
|
|
339
|
+
raise RuntimeError("worker is already started")
|
|
340
|
+
self._running = True
|
|
341
|
+
registration_attempted = False
|
|
342
|
+
thought_attested = False
|
|
343
|
+
try:
|
|
344
|
+
registration_attempted = True
|
|
345
|
+
# Handler attributes may have been configured after construction;
|
|
346
|
+
# register only the final, locally re-derived capability truth.
|
|
347
|
+
self._rebuild_work_control_capabilities()
|
|
348
|
+
await self._queue.register_worker(self._capabilities)
|
|
349
|
+
self._registered = True
|
|
350
|
+
if not self._running:
|
|
351
|
+
return
|
|
352
|
+
if JobType.THOUGHT in self._handlers:
|
|
353
|
+
thought_attested = self._queue.set_thought_runtime_ready(
|
|
354
|
+
True, node_id=self.node_id,
|
|
355
|
+
)
|
|
356
|
+
if not thought_attested:
|
|
357
|
+
raise RuntimeError(
|
|
358
|
+
"ThoughtJobV1 handler node does not match "
|
|
359
|
+
"COLONY_THOUGHT_WORKER_NODE_ID"
|
|
360
|
+
)
|
|
361
|
+
logger.info(
|
|
362
|
+
"Worker %s started (caps=%s)",
|
|
363
|
+
self.node_id, self._capabilities.capabilities,
|
|
364
|
+
)
|
|
365
|
+
await self._run_worker_loops()
|
|
366
|
+
finally:
|
|
367
|
+
if JobType.THOUGHT in self._handlers:
|
|
368
|
+
try:
|
|
369
|
+
self._queue.set_thought_runtime_ready(
|
|
370
|
+
False,
|
|
371
|
+
node_id=self.node_id,
|
|
372
|
+
reason="thought_worker_loop_exited",
|
|
373
|
+
)
|
|
374
|
+
except Exception:
|
|
375
|
+
logger.exception(
|
|
376
|
+
"Worker %s failed to clear Thought readiness",
|
|
377
|
+
self.node_id,
|
|
378
|
+
)
|
|
379
|
+
self._running = False
|
|
380
|
+
if self._stop_in_progress:
|
|
381
|
+
await self._stop_cleanup_done.wait()
|
|
382
|
+
elif self._registered or registration_attempted:
|
|
383
|
+
self._registered = False
|
|
384
|
+
try:
|
|
385
|
+
await self._queue.deregister_worker(self.node_id)
|
|
386
|
+
except Exception:
|
|
387
|
+
logger.exception(
|
|
388
|
+
"Worker %s failed to deregister after loop exit",
|
|
389
|
+
self.node_id,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
async def _run_worker_loops(self) -> None:
|
|
393
|
+
"""Run both owned loops with sibling cancellation on any failure."""
|
|
394
|
+
|
|
395
|
+
tasks = {
|
|
396
|
+
asyncio.create_task(
|
|
397
|
+
self._poll_loop(), name=f"{self.node_id}:poll",
|
|
398
|
+
),
|
|
399
|
+
asyncio.create_task(
|
|
400
|
+
self._heartbeat_loop(), name=f"{self.node_id}:heartbeat",
|
|
401
|
+
),
|
|
402
|
+
}
|
|
403
|
+
from apsimo.task_queue.work_control import work_control_mode
|
|
404
|
+
|
|
405
|
+
if work_control_mode() != "off":
|
|
406
|
+
tasks.add(asyncio.create_task(
|
|
407
|
+
self._work_control_loop(), name=f"{self.node_id}:control",
|
|
408
|
+
))
|
|
409
|
+
self._loop_tasks = tasks
|
|
410
|
+
try:
|
|
411
|
+
done, pending = await asyncio.wait(
|
|
412
|
+
tasks,
|
|
413
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
414
|
+
)
|
|
415
|
+
failure: Optional[BaseException] = None
|
|
416
|
+
for task in done:
|
|
417
|
+
if task.cancelled():
|
|
418
|
+
if self._running:
|
|
419
|
+
failure = asyncio.CancelledError()
|
|
420
|
+
break
|
|
421
|
+
continue
|
|
422
|
+
exc = task.exception()
|
|
423
|
+
if exc is not None:
|
|
424
|
+
failure = exc
|
|
425
|
+
break
|
|
426
|
+
if failure is None and self._running:
|
|
427
|
+
failure = RuntimeError("worker lifecycle loop exited unexpectedly")
|
|
428
|
+
if failure is not None:
|
|
429
|
+
for task in pending:
|
|
430
|
+
task.cancel()
|
|
431
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
432
|
+
raise failure
|
|
433
|
+
if pending:
|
|
434
|
+
await asyncio.gather(*pending)
|
|
435
|
+
finally:
|
|
436
|
+
for task in tasks:
|
|
437
|
+
if not task.done():
|
|
438
|
+
task.cancel()
|
|
439
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
440
|
+
if self._loop_tasks is tasks:
|
|
441
|
+
self._loop_tasks = set()
|
|
442
|
+
|
|
443
|
+
async def stop(self, drain_timeout: float = 30.0) -> None:
|
|
444
|
+
"""Gracefully stop: wait for in-flight jobs, then deregister."""
|
|
445
|
+
if self._stop_in_progress:
|
|
446
|
+
await self._stop_cleanup_done.wait()
|
|
447
|
+
return
|
|
448
|
+
self._stop_in_progress = True
|
|
449
|
+
self._stop_cleanup_done.clear()
|
|
450
|
+
try:
|
|
451
|
+
self._running = False
|
|
452
|
+
if JobType.THOUGHT in self._handlers:
|
|
453
|
+
self._queue.set_thought_runtime_ready(
|
|
454
|
+
False,
|
|
455
|
+
node_id=self.node_id,
|
|
456
|
+
reason="thought_worker_stopped",
|
|
457
|
+
)
|
|
458
|
+
loop_tasks = list(self._loop_tasks)
|
|
459
|
+
for task in loop_tasks:
|
|
460
|
+
task.cancel()
|
|
461
|
+
if loop_tasks:
|
|
462
|
+
await asyncio.gather(*loop_tasks, return_exceptions=True)
|
|
463
|
+
if self._running_jobs:
|
|
464
|
+
logger.info("Worker %s draining %d jobs...", self.node_id, len(self._running_jobs))
|
|
465
|
+
attempts = dict(self._job_attempt_ids)
|
|
466
|
+
task_jobs = {
|
|
467
|
+
task: job_id for job_id, task in self._running_jobs.items()
|
|
468
|
+
}
|
|
469
|
+
_done, pending = await asyncio.wait(
|
|
470
|
+
list(task_jobs),
|
|
471
|
+
timeout=drain_timeout,
|
|
472
|
+
)
|
|
473
|
+
if pending:
|
|
474
|
+
logger.warning(
|
|
475
|
+
"Worker %s cancelling %d jobs after drain timeout",
|
|
476
|
+
self.node_id,
|
|
477
|
+
len(pending),
|
|
478
|
+
)
|
|
479
|
+
for task in pending:
|
|
480
|
+
task.cancel()
|
|
481
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
482
|
+
for task in pending:
|
|
483
|
+
job_id = task_jobs[task]
|
|
484
|
+
attempt = attempts.get(job_id)
|
|
485
|
+
if not attempt:
|
|
486
|
+
continue
|
|
487
|
+
try:
|
|
488
|
+
await self._queue.fail_job(
|
|
489
|
+
job_id,
|
|
490
|
+
self.node_id,
|
|
491
|
+
"worker_shutdown_drain_timeout",
|
|
492
|
+
claim_attempt_id=attempt,
|
|
493
|
+
)
|
|
494
|
+
except Exception:
|
|
495
|
+
logger.exception(
|
|
496
|
+
"Worker %s could not close shutdown job %s",
|
|
497
|
+
self.node_id,
|
|
498
|
+
job_id,
|
|
499
|
+
)
|
|
500
|
+
if self._skill_tasks:
|
|
501
|
+
for task in list(self._skill_tasks):
|
|
502
|
+
task.cancel()
|
|
503
|
+
await asyncio.gather(*self._skill_tasks, return_exceptions=True)
|
|
504
|
+
self._skill_tasks.clear()
|
|
505
|
+
if self._registered:
|
|
506
|
+
self._registered = False
|
|
507
|
+
await self._queue.deregister_worker(self.node_id)
|
|
508
|
+
logger.info("Worker %s stopped", self.node_id)
|
|
509
|
+
finally:
|
|
510
|
+
self._stop_cleanup_done.set()
|
|
511
|
+
self._stop_in_progress = False
|
|
512
|
+
|
|
513
|
+
async def _poll_loop(self) -> None:
|
|
514
|
+
while self._running:
|
|
515
|
+
if len(self._running_jobs) < self._capabilities.max_concurrent:
|
|
516
|
+
try:
|
|
517
|
+
job = await self._queue.claim_job(
|
|
518
|
+
self.node_id, self._capabilities
|
|
519
|
+
)
|
|
520
|
+
if job is not None:
|
|
521
|
+
if not self._running:
|
|
522
|
+
attempt = str(job.claim_attempt_id or "").strip()
|
|
523
|
+
if attempt:
|
|
524
|
+
try:
|
|
525
|
+
await self._queue.release_job(
|
|
526
|
+
job.job_id,
|
|
527
|
+
self.node_id,
|
|
528
|
+
claim_attempt_id=attempt,
|
|
529
|
+
)
|
|
530
|
+
except Exception:
|
|
531
|
+
logger.exception(
|
|
532
|
+
"Worker %s could not release post-stop "
|
|
533
|
+
"claim %s",
|
|
534
|
+
self.node_id,
|
|
535
|
+
job.job_id,
|
|
536
|
+
)
|
|
537
|
+
else:
|
|
538
|
+
logger.error(
|
|
539
|
+
"Worker %s received a post-stop claim with "
|
|
540
|
+
"no exact attempt: %s",
|
|
541
|
+
self.node_id,
|
|
542
|
+
job.job_id,
|
|
543
|
+
)
|
|
544
|
+
break
|
|
545
|
+
task = asyncio.create_task(self._execute_job(job))
|
|
546
|
+
self._running_jobs[job.job_id] = task
|
|
547
|
+
if job.claim_attempt_id:
|
|
548
|
+
self._job_attempt_ids[job.job_id] = job.claim_attempt_id
|
|
549
|
+
task.add_done_callback(
|
|
550
|
+
lambda t, jid=job.job_id: (
|
|
551
|
+
self._running_jobs.pop(jid, None),
|
|
552
|
+
self._job_attempt_ids.pop(jid, None),
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
except Exception:
|
|
556
|
+
logger.exception("Worker %s: error claiming job", self.node_id)
|
|
557
|
+
await asyncio.sleep(self._poll_interval)
|
|
558
|
+
|
|
559
|
+
async def _heartbeat_loop(self) -> None:
|
|
560
|
+
while self._running:
|
|
561
|
+
await asyncio.sleep(self._heartbeat_interval)
|
|
562
|
+
attempts = dict(self._job_attempt_ids)
|
|
563
|
+
running_job_ids = list(attempts)
|
|
564
|
+
if running_job_ids:
|
|
565
|
+
try:
|
|
566
|
+
await self._queue.send_heartbeat(
|
|
567
|
+
self.node_id,
|
|
568
|
+
running_job_ids,
|
|
569
|
+
claim_attempt_ids=attempts,
|
|
570
|
+
)
|
|
571
|
+
except Exception:
|
|
572
|
+
logger.exception("Worker %s: heartbeat failed", self.node_id)
|
|
573
|
+
# Update load
|
|
574
|
+
load = len(self._running_jobs) / max(self._capabilities.max_concurrent, 1)
|
|
575
|
+
self._capabilities.load = load
|
|
576
|
+
try:
|
|
577
|
+
await self._queue.update_worker_load(self.node_id, load)
|
|
578
|
+
except Exception:
|
|
579
|
+
logger.warning("Worker %s: failed to update load metric", self.node_id, exc_info=True)
|
|
580
|
+
|
|
581
|
+
async def _work_control_loop(self) -> None:
|
|
582
|
+
"""Deliver and acknowledge controls for exact owned attempts."""
|
|
583
|
+
|
|
584
|
+
from apsimo.task_queue.work_control import work_control_mode
|
|
585
|
+
|
|
586
|
+
while self._running:
|
|
587
|
+
await asyncio.sleep(min(max(self._poll_interval / 4.0, 0.1), 1.0))
|
|
588
|
+
if work_control_mode() != "live":
|
|
589
|
+
reconcile = getattr(
|
|
590
|
+
self._queue,
|
|
591
|
+
"reconcile_work_control_delivery_state",
|
|
592
|
+
None,
|
|
593
|
+
)
|
|
594
|
+
if callable(reconcile):
|
|
595
|
+
try:
|
|
596
|
+
await reconcile()
|
|
597
|
+
except Exception:
|
|
598
|
+
logger.exception(
|
|
599
|
+
"Worker %s: inactive WorkControl reconciliation "
|
|
600
|
+
"failed",
|
|
601
|
+
self.node_id,
|
|
602
|
+
)
|
|
603
|
+
continue
|
|
604
|
+
pending_controls = getattr(
|
|
605
|
+
self._queue, "pending_work_control_operations", None,
|
|
606
|
+
)
|
|
607
|
+
if not callable(pending_controls):
|
|
608
|
+
continue
|
|
609
|
+
try:
|
|
610
|
+
controls = await pending_controls(self.node_id)
|
|
611
|
+
except Exception:
|
|
612
|
+
logger.exception(
|
|
613
|
+
"Worker %s: WorkControl poll failed", self.node_id,
|
|
614
|
+
)
|
|
615
|
+
continue
|
|
616
|
+
if not isinstance(controls, list):
|
|
617
|
+
continue
|
|
618
|
+
for control in controls:
|
|
619
|
+
try:
|
|
620
|
+
await self._apply_work_control(control)
|
|
621
|
+
except asyncio.CancelledError:
|
|
622
|
+
raise
|
|
623
|
+
except Exception:
|
|
624
|
+
logger.exception(
|
|
625
|
+
"Worker %s: WorkControl delivery failed for %s",
|
|
626
|
+
self.node_id, control.get("operation_id"),
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
async def _apply_work_control(self, control: Dict[str, Any]) -> None:
|
|
630
|
+
"""Cooperate with one durable control, acknowledging only truth."""
|
|
631
|
+
|
|
632
|
+
operation = str(control.get("operation") or "")
|
|
633
|
+
operation_id = str(control.get("operation_id") or "")
|
|
634
|
+
job_id = str(control.get("target_id") or "")
|
|
635
|
+
attempt_id = str(control.get("attempt_id") or "")
|
|
636
|
+
|
|
637
|
+
def _control_live() -> bool:
|
|
638
|
+
from apsimo.task_queue.work_control import (
|
|
639
|
+
work_control_mode,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
return work_control_mode() == "live"
|
|
643
|
+
|
|
644
|
+
async def _retire_if_inactive() -> bool:
|
|
645
|
+
if _control_live():
|
|
646
|
+
return False
|
|
647
|
+
reconcile = getattr(
|
|
648
|
+
self._queue, "reconcile_work_control_delivery_state", None,
|
|
649
|
+
)
|
|
650
|
+
if callable(reconcile):
|
|
651
|
+
await reconcile()
|
|
652
|
+
return True
|
|
653
|
+
|
|
654
|
+
def _deadline(value: Any) -> Optional[datetime]:
|
|
655
|
+
try:
|
|
656
|
+
parsed = datetime.fromisoformat(str(value))
|
|
657
|
+
except (TypeError, ValueError):
|
|
658
|
+
return None
|
|
659
|
+
if parsed.tzinfo is None:
|
|
660
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
661
|
+
return parsed.astimezone(timezone.utc)
|
|
662
|
+
|
|
663
|
+
def _lease_valid(job: Optional[Job] = None) -> bool:
|
|
664
|
+
now = datetime.now(timezone.utc)
|
|
665
|
+
lease = _deadline(control.get("ack_deadline"))
|
|
666
|
+
if lease is None or now >= lease:
|
|
667
|
+
return False
|
|
668
|
+
if operation == "steer" and job is not None:
|
|
669
|
+
deadline = job.deadline
|
|
670
|
+
if deadline is not None:
|
|
671
|
+
if deadline.tzinfo is None:
|
|
672
|
+
deadline = deadline.replace(tzinfo=timezone.utc)
|
|
673
|
+
if now >= deadline.astimezone(timezone.utc):
|
|
674
|
+
return False
|
|
675
|
+
return True
|
|
676
|
+
|
|
677
|
+
def _exact_active(job: Optional[Job]) -> bool:
|
|
678
|
+
return bool(
|
|
679
|
+
job is not None
|
|
680
|
+
and job.claimed_by == self.node_id
|
|
681
|
+
and job.claim_attempt_id == attempt_id
|
|
682
|
+
and job.status in {JobStatus.CLAIMED, JobStatus.RUNNING}
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
async def _ack(outcome: str, details: Dict[str, Any]) -> Any:
|
|
686
|
+
if await _retire_if_inactive():
|
|
687
|
+
return None
|
|
688
|
+
return await self._queue.acknowledge_work_control_operation(
|
|
689
|
+
worker_id=self.node_id,
|
|
690
|
+
operation_id=operation_id,
|
|
691
|
+
attempt_id=attempt_id,
|
|
692
|
+
outcome=outcome,
|
|
693
|
+
details=details,
|
|
694
|
+
ack_authority={
|
|
695
|
+
"authority_kind": "in_process_worker",
|
|
696
|
+
"worker_id": self.node_id,
|
|
697
|
+
},
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
if await _retire_if_inactive():
|
|
701
|
+
return
|
|
702
|
+
|
|
703
|
+
if not _lease_valid():
|
|
704
|
+
# The queue acknowledgement path independently enforces the hard
|
|
705
|
+
# deadline and records expiry; this call only accelerates closure.
|
|
706
|
+
await _ack("rejected", {"reason": "control_lease_elapsed"})
|
|
707
|
+
return
|
|
708
|
+
|
|
709
|
+
job = await self._queue.get_job(job_id)
|
|
710
|
+
|
|
711
|
+
if operation == "steer":
|
|
712
|
+
handler = self._handlers.get(job.job_type) if job else None
|
|
713
|
+
stored_outcome = getattr(
|
|
714
|
+
self._queue, "get_work_control_worker_outcome", None,
|
|
715
|
+
)
|
|
716
|
+
record_outcome = getattr(
|
|
717
|
+
self._queue, "record_work_control_worker_outcome", None,
|
|
718
|
+
)
|
|
719
|
+
if not callable(stored_outcome) or not callable(record_outcome):
|
|
720
|
+
await _ack("rejected", {
|
|
721
|
+
"reason": "durable_steer_outcome_ledger_unavailable",
|
|
722
|
+
})
|
|
723
|
+
return
|
|
724
|
+
persisted = await stored_outcome(
|
|
725
|
+
worker_id=self.node_id,
|
|
726
|
+
operation_id=operation_id,
|
|
727
|
+
attempt_id=attempt_id,
|
|
728
|
+
)
|
|
729
|
+
if persisted is not None:
|
|
730
|
+
# Response loss after durable outcome recording must retry the
|
|
731
|
+
# acknowledgement before the handler can be invoked again.
|
|
732
|
+
await _ack(
|
|
733
|
+
str(persisted["outcome"]),
|
|
734
|
+
dict(persisted.get("details") or {}),
|
|
735
|
+
)
|
|
736
|
+
return
|
|
737
|
+
task = self._running_jobs.get(job_id)
|
|
738
|
+
exact_running = bool(
|
|
739
|
+
_exact_active(job)
|
|
740
|
+
and _lease_valid(job)
|
|
741
|
+
and task is not None
|
|
742
|
+
and not task.done()
|
|
743
|
+
and self._job_attempt_ids.get(job_id) == attempt_id
|
|
744
|
+
)
|
|
745
|
+
if (
|
|
746
|
+
not exact_running
|
|
747
|
+
or handler is None
|
|
748
|
+
or not self._handler_supports_steer(handler)
|
|
749
|
+
):
|
|
750
|
+
await _ack("rejected", {
|
|
751
|
+
"reason": "attempt_or_steer_handler_unavailable",
|
|
752
|
+
})
|
|
753
|
+
return
|
|
754
|
+
parameters = control.get("parameters") or {}
|
|
755
|
+
# Re-read after every preceding await and immediately before the
|
|
756
|
+
# local apply. A delayed attempt-A delivery cannot steer B.
|
|
757
|
+
current = await self._queue.get_job(job_id)
|
|
758
|
+
current_task = self._running_jobs.get(job_id)
|
|
759
|
+
if await _retire_if_inactive():
|
|
760
|
+
return
|
|
761
|
+
if not (
|
|
762
|
+
_exact_active(current)
|
|
763
|
+
and _lease_valid(current)
|
|
764
|
+
and current_task is task
|
|
765
|
+
and current_task is not None
|
|
766
|
+
and not current_task.done()
|
|
767
|
+
and self._job_attempt_ids.get(job_id) == attempt_id
|
|
768
|
+
):
|
|
769
|
+
await _ack("rejected", {
|
|
770
|
+
"reason": "attempt_changed_before_steer_apply",
|
|
771
|
+
})
|
|
772
|
+
return
|
|
773
|
+
try:
|
|
774
|
+
report = await asyncio.wait_for(
|
|
775
|
+
handler.apply_steer(
|
|
776
|
+
job,
|
|
777
|
+
directive=str(parameters.get("directive") or ""),
|
|
778
|
+
context_refs=[
|
|
779
|
+
str(item)
|
|
780
|
+
for item in parameters.get("context_refs", [])
|
|
781
|
+
],
|
|
782
|
+
operation_id=operation_id,
|
|
783
|
+
authority_digest=str(
|
|
784
|
+
control.get("authority_digest") or ""
|
|
785
|
+
),
|
|
786
|
+
),
|
|
787
|
+
timeout=5.0,
|
|
788
|
+
)
|
|
789
|
+
except (NotImplementedError, asyncio.TimeoutError) as exc:
|
|
790
|
+
rejected = {"reason": type(exc).__name__}
|
|
791
|
+
await record_outcome(
|
|
792
|
+
worker_id=self.node_id,
|
|
793
|
+
operation_id=operation_id,
|
|
794
|
+
attempt_id=attempt_id,
|
|
795
|
+
outcome="rejected",
|
|
796
|
+
details=rejected,
|
|
797
|
+
)
|
|
798
|
+
await _ack("rejected", rejected)
|
|
799
|
+
return
|
|
800
|
+
if not isinstance(report, dict):
|
|
801
|
+
report = {"handler_report": str(report)[:500]}
|
|
802
|
+
if (
|
|
803
|
+
report.get("authority_digest")
|
|
804
|
+
!= control.get("authority_digest")
|
|
805
|
+
or report.get("authority_expanded") is not False
|
|
806
|
+
):
|
|
807
|
+
rejected = {
|
|
808
|
+
"reason": "steer_authority_expansion_or_unattested",
|
|
809
|
+
}
|
|
810
|
+
await record_outcome(
|
|
811
|
+
worker_id=self.node_id,
|
|
812
|
+
operation_id=operation_id,
|
|
813
|
+
attempt_id=attempt_id,
|
|
814
|
+
outcome="rejected",
|
|
815
|
+
details=rejected,
|
|
816
|
+
)
|
|
817
|
+
await _ack("rejected", rejected)
|
|
818
|
+
return
|
|
819
|
+
await record_outcome(
|
|
820
|
+
worker_id=self.node_id,
|
|
821
|
+
operation_id=operation_id,
|
|
822
|
+
attempt_id=attempt_id,
|
|
823
|
+
outcome="applied",
|
|
824
|
+
details=report,
|
|
825
|
+
)
|
|
826
|
+
# The handler outcome is durable now. Re-read immediately before
|
|
827
|
+
# acknowledging; a winning lifecycle transition owns the receipt.
|
|
828
|
+
current = await self._queue.get_job(job_id)
|
|
829
|
+
if await _retire_if_inactive():
|
|
830
|
+
return
|
|
831
|
+
if not (_exact_active(current) and _lease_valid(current)):
|
|
832
|
+
await _ack("rejected", {
|
|
833
|
+
"reason": "attempt_changed_before_steer_ack",
|
|
834
|
+
})
|
|
835
|
+
return
|
|
836
|
+
await _ack("applied", report)
|
|
837
|
+
return
|
|
838
|
+
|
|
839
|
+
if operation not in {"interrupt", "cancel"}:
|
|
840
|
+
await _ack("rejected", {"reason": "unsupported_worker_operation"})
|
|
841
|
+
return
|
|
842
|
+
|
|
843
|
+
handler = self._handlers.get(job.job_type) if job else None
|
|
844
|
+
task = self._running_jobs.get(job_id)
|
|
845
|
+
if not (
|
|
846
|
+
_exact_active(job)
|
|
847
|
+
and _lease_valid(job)
|
|
848
|
+
and handler is not None
|
|
849
|
+
and getattr(
|
|
850
|
+
handler, "work_control_interrupt_safe", False,
|
|
851
|
+
) is True
|
|
852
|
+
and task is not None
|
|
853
|
+
and not task.done()
|
|
854
|
+
and self._job_attempt_ids.get(job_id) == attempt_id
|
|
855
|
+
):
|
|
856
|
+
await _ack("rejected", {
|
|
857
|
+
"reason": "attempt_or_interrupt_handler_unavailable",
|
|
858
|
+
})
|
|
859
|
+
return
|
|
860
|
+
|
|
861
|
+
# Re-read immediately before cancel and bind the exact local Task
|
|
862
|
+
# object as well as the durable attempt. There is no await between the
|
|
863
|
+
# final checks and task.cancel(), so a newer task cannot be selected.
|
|
864
|
+
current = await self._queue.get_job(job_id)
|
|
865
|
+
current_task = self._running_jobs.get(job_id)
|
|
866
|
+
if await _retire_if_inactive():
|
|
867
|
+
return
|
|
868
|
+
if not (
|
|
869
|
+
_exact_active(current)
|
|
870
|
+
and _lease_valid(current)
|
|
871
|
+
and current_task is task
|
|
872
|
+
and current_task is not None
|
|
873
|
+
and not current_task.done()
|
|
874
|
+
and self._job_attempt_ids.get(job_id) == attempt_id
|
|
875
|
+
):
|
|
876
|
+
await _ack("rejected", {
|
|
877
|
+
"reason": "attempt_changed_before_interrupt",
|
|
878
|
+
})
|
|
879
|
+
return
|
|
880
|
+
task.cancel()
|
|
881
|
+
_done, pending = await asyncio.wait({task}, timeout=5.0)
|
|
882
|
+
if pending:
|
|
883
|
+
await _ack("rejected", {"reason": "handler_did_not_stop"})
|
|
884
|
+
return
|
|
885
|
+
|
|
886
|
+
current = await self._queue.get_job(job_id)
|
|
887
|
+
if await _retire_if_inactive():
|
|
888
|
+
return
|
|
889
|
+
if not (_exact_active(current) and _lease_valid(current)):
|
|
890
|
+
await _ack("rejected", {
|
|
891
|
+
"reason": "attempt_changed_before_stop_ack",
|
|
892
|
+
})
|
|
893
|
+
return
|
|
894
|
+
await _ack("stopped", {"cooperative_stop": True})
|
|
895
|
+
|
|
896
|
+
async def _execute_job(self, job: Job) -> None:
|
|
897
|
+
handler = self._handlers.get(job.job_type)
|
|
898
|
+
started_at = datetime.now(timezone.utc)
|
|
899
|
+
self._job_start_times[job.job_id] = started_at
|
|
900
|
+
|
|
901
|
+
if handler is None:
|
|
902
|
+
await self._queue.fail_job(
|
|
903
|
+
job.job_id, self.node_id,
|
|
904
|
+
error=f"No handler registered for job_type={job.job_type}",
|
|
905
|
+
started_at=started_at,
|
|
906
|
+
claim_attempt_id=job.claim_attempt_id,
|
|
907
|
+
)
|
|
908
|
+
self._job_start_times.pop(job.job_id, None)
|
|
909
|
+
return
|
|
910
|
+
|
|
911
|
+
if not await self._queue.start_job(
|
|
912
|
+
job.job_id,
|
|
913
|
+
self.node_id,
|
|
914
|
+
claim_attempt_id=job.claim_attempt_id,
|
|
915
|
+
):
|
|
916
|
+
# The claim may have expired, been released, or moved to another
|
|
917
|
+
# worker between polling and execution. Never run a handler after
|
|
918
|
+
# the central queue rejects the claimant/state transition.
|
|
919
|
+
logger.warning(
|
|
920
|
+
"Worker %s: start authority rejected for job %s",
|
|
921
|
+
self.node_id,
|
|
922
|
+
job.job_id,
|
|
923
|
+
)
|
|
924
|
+
self._job_start_times.pop(job.job_id, None)
|
|
925
|
+
return
|
|
926
|
+
try:
|
|
927
|
+
output = await asyncio.wait_for(
|
|
928
|
+
handler.execute(job),
|
|
929
|
+
timeout=job.timeout_secs,
|
|
930
|
+
)
|
|
931
|
+
output, server_attested = _prepare_embedded_report(job, output)
|
|
932
|
+
completion = await self._queue.complete_job(
|
|
933
|
+
job.job_id,
|
|
934
|
+
self.node_id,
|
|
935
|
+
output or {},
|
|
936
|
+
started_at=started_at,
|
|
937
|
+
claim_attempt_id=job.claim_attempt_id,
|
|
938
|
+
server_attested=server_attested,
|
|
939
|
+
)
|
|
940
|
+
# ThoughtJobV1 is strictly read-only. The generic post-task
|
|
941
|
+
# learner writes skill state, so it must never run for this type;
|
|
942
|
+
# validated MemoryWriteProposal remains only a proposal.
|
|
943
|
+
if (completion.get("transitioned") is True
|
|
944
|
+
and completion.get("job_status") == JobStatus.COMPLETED.value
|
|
945
|
+
and completion.get("governor_outcome") == "success"
|
|
946
|
+
and self._skill_learning is not None
|
|
947
|
+
and job.job_type != JobType.THOUGHT):
|
|
948
|
+
_hook_task = asyncio.create_task(self._fire_skill_hook(job, output or {}))
|
|
949
|
+
self._skill_tasks.add(_hook_task)
|
|
950
|
+
_hook_task.add_done_callback(
|
|
951
|
+
self._skill_tasks.discard
|
|
952
|
+
)
|
|
953
|
+
except asyncio.TimeoutError:
|
|
954
|
+
await self._queue.fail_job(
|
|
955
|
+
job.job_id, self.node_id,
|
|
956
|
+
error=f"Timed out after {job.timeout_secs}s",
|
|
957
|
+
started_at=started_at,
|
|
958
|
+
claim_attempt_id=job.claim_attempt_id,
|
|
959
|
+
)
|
|
960
|
+
except Exception as exc:
|
|
961
|
+
await self._queue.fail_job(
|
|
962
|
+
job.job_id, self.node_id,
|
|
963
|
+
error=str(exc),
|
|
964
|
+
started_at=started_at,
|
|
965
|
+
claim_attempt_id=job.claim_attempt_id,
|
|
966
|
+
)
|
|
967
|
+
finally:
|
|
968
|
+
self._job_start_times.pop(job.job_id, None)
|
|
969
|
+
|
|
970
|
+
async def _fire_skill_hook(self, job: Job, output: Dict[str, Any]) -> None:
|
|
971
|
+
"""Asynchronously fire the skill learning post-task hook.
|
|
972
|
+
|
|
973
|
+
Builds a minimal TaskSolution from the completed job and delegates
|
|
974
|
+
to SkillLearningService.post_task_hook. Errors are logged and
|
|
975
|
+
swallowed so they never surface to the caller.
|
|
976
|
+
"""
|
|
977
|
+
try:
|
|
978
|
+
from apsimo.skills.models import TaskSolution
|
|
979
|
+
solution = TaskSolution(
|
|
980
|
+
task_id=job.job_id,
|
|
981
|
+
task_description=str(job.payload.get("description", job.job_type.value)),
|
|
982
|
+
inputs=dict(job.payload),
|
|
983
|
+
output=output,
|
|
984
|
+
trace=[],
|
|
985
|
+
dependencies=list(job.payload.get("dependencies", [])),
|
|
986
|
+
embedding=None,
|
|
987
|
+
step_fingerprint=[str(k) for k in output.keys()],
|
|
988
|
+
duration_secs=0.0,
|
|
989
|
+
completed_at=datetime.now(timezone.utc),
|
|
990
|
+
)
|
|
991
|
+
await self._skill_learning.post_task_hook(solution)
|
|
992
|
+
except Exception:
|
|
993
|
+
logger.debug("Skill learning hook failed for job %s", job.job_id, exc_info=True)
|