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,1822 @@
|
|
|
1
|
+
"""Durable periodic scheduler for Colony autonomy subsystems.
|
|
2
|
+
|
|
3
|
+
The schedules table remains compatible with the original scheduler. Additive
|
|
4
|
+
lease, attempt, and receipt state makes one due claim atomic across processes
|
|
5
|
+
and makes every terminal transition auditable without treating a callback
|
|
6
|
+
failure as a successful run.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
import hashlib
|
|
16
|
+
import inspect
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import math
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import re
|
|
22
|
+
import sqlite3
|
|
23
|
+
import types
|
|
24
|
+
import uuid
|
|
25
|
+
from typing import Any, Callable, Coroutine, Dict, Iterator, List, Mapping, Optional, Union
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
#: Consecutive failures at which a schedule counts against the scheduler's
|
|
30
|
+
#: `healthy` verdict (failure_count resets to 0 on any real success).
|
|
31
|
+
SUSTAINED_FAILURE_THRESHOLD = 3
|
|
32
|
+
|
|
33
|
+
_TASK_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
|
34
|
+
_METADATA_KEY = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
|
|
35
|
+
_MAX_INTERVAL_SECONDS = 31_536_000
|
|
36
|
+
_MAX_METADATA_FIELDS = 32
|
|
37
|
+
_MAX_METADATA_BYTES = 8192
|
|
38
|
+
_MAX_RESULT_NODES = 256
|
|
39
|
+
_MAX_RESULT_ITEMS = 64
|
|
40
|
+
_MAX_RESULT_DEPTH = 8
|
|
41
|
+
_MAX_RESULT_TEXT_CHARS = 65_536
|
|
42
|
+
_MAX_RESULT_INTEGER_BITS = 4096
|
|
43
|
+
_MAX_RESULT_SUMMARY_CHARS = 2000
|
|
44
|
+
_MAX_RESULT_DESCRIPTOR_CHARS = 4096
|
|
45
|
+
_SCHEDULE_NAMESPACE = uuid.UUID("9fa9f892-fc4b-5ef4-aa25-b45d740d157c")
|
|
46
|
+
_TYPE_NAME_DESCRIPTOR = type.__dict__["__name__"]
|
|
47
|
+
_TYPE_MRO_DESCRIPTOR = type.__dict__["__mro__"]
|
|
48
|
+
_TYPE_DICT_DESCRIPTOR = type.__dict__["__dict__"]
|
|
49
|
+
_MAPPING_PROXY_TYPE = type(type.__dict__)
|
|
50
|
+
_MAPPING_PROXY_ITEMS = _MAPPING_PROXY_TYPE.__dict__["items"]
|
|
51
|
+
_GENERATOR_CODE_DESCRIPTOR = types.GeneratorType.__dict__["gi_code"]
|
|
52
|
+
_CODE_FLAGS_DESCRIPTOR = types.CodeType.__dict__["co_flags"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _utc(value: datetime) -> datetime:
|
|
56
|
+
if not isinstance(value, datetime) or value.tzinfo is None:
|
|
57
|
+
raise ValueError("scheduler clock must return a timezone-aware datetime")
|
|
58
|
+
return value.astimezone(timezone.utc)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _iso(value: datetime) -> str:
|
|
62
|
+
return _utc(value).isoformat()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_time(value: Optional[str]) -> Optional[datetime]:
|
|
66
|
+
if not value:
|
|
67
|
+
return None
|
|
68
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
69
|
+
if parsed.tzinfo is None:
|
|
70
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
71
|
+
return parsed.astimezone(timezone.utc)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _validate_name(name: Any) -> str:
|
|
75
|
+
if not isinstance(name, str) or not _TASK_NAME.fullmatch(name):
|
|
76
|
+
raise ValueError("task name must be 1-128 safe characters")
|
|
77
|
+
return name
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _validate_interval(value: Any) -> int:
|
|
81
|
+
if (
|
|
82
|
+
isinstance(value, bool)
|
|
83
|
+
or not isinstance(value, int)
|
|
84
|
+
or not 1 <= value <= _MAX_INTERVAL_SECONDS
|
|
85
|
+
):
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"interval_seconds must be an integer from 1-{_MAX_INTERVAL_SECONDS}"
|
|
88
|
+
)
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _metadata_scalar(value: Any, *, field: str) -> Any:
|
|
93
|
+
if value is None or isinstance(value, bool):
|
|
94
|
+
return value
|
|
95
|
+
if isinstance(value, int):
|
|
96
|
+
if abs(value) > 9_007_199_254_740_991:
|
|
97
|
+
raise ValueError(f"metadata.{field} integer is outside bounds")
|
|
98
|
+
return value
|
|
99
|
+
if isinstance(value, float):
|
|
100
|
+
if not math.isfinite(value):
|
|
101
|
+
raise ValueError(f"metadata.{field} must be finite")
|
|
102
|
+
return value
|
|
103
|
+
if isinstance(value, str):
|
|
104
|
+
normalized = " ".join(value.split()).strip()
|
|
105
|
+
if len(normalized) > 500:
|
|
106
|
+
raise ValueError(f"metadata.{field} exceeds 500 characters")
|
|
107
|
+
return normalized
|
|
108
|
+
raise ValueError(f"metadata.{field} must be a scalar or scalar array")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _canonical_metadata(value: Optional[Mapping[str, Any]]) -> tuple[Dict[str, Any], str]:
|
|
112
|
+
if value is None:
|
|
113
|
+
value = {}
|
|
114
|
+
if not isinstance(value, Mapping):
|
|
115
|
+
raise ValueError("metadata must be one flat object")
|
|
116
|
+
if len(value) > _MAX_METADATA_FIELDS:
|
|
117
|
+
raise ValueError(f"metadata exceeds {_MAX_METADATA_FIELDS} fields")
|
|
118
|
+
normalized: Dict[str, Any] = {}
|
|
119
|
+
for raw_key, raw_value in value.items():
|
|
120
|
+
if not isinstance(raw_key, str) or not _METADATA_KEY.fullmatch(raw_key):
|
|
121
|
+
raise ValueError("metadata contains an invalid key")
|
|
122
|
+
if isinstance(raw_value, list):
|
|
123
|
+
if len(raw_value) > 16:
|
|
124
|
+
raise ValueError(f"metadata.{raw_key} exceeds 16 list items")
|
|
125
|
+
normalized[raw_key] = [
|
|
126
|
+
_metadata_scalar(item, field=raw_key) for item in raw_value
|
|
127
|
+
]
|
|
128
|
+
else:
|
|
129
|
+
normalized[raw_key] = _metadata_scalar(raw_value, field=raw_key)
|
|
130
|
+
encoded = json.dumps(
|
|
131
|
+
normalized,
|
|
132
|
+
ensure_ascii=False,
|
|
133
|
+
separators=(",", ":"),
|
|
134
|
+
sort_keys=True,
|
|
135
|
+
allow_nan=False,
|
|
136
|
+
)
|
|
137
|
+
if len(encoded.encode("utf-8")) > _MAX_METADATA_BYTES:
|
|
138
|
+
raise ValueError(f"metadata exceeds {_MAX_METADATA_BYTES} bytes")
|
|
139
|
+
return normalized, encoded
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _safe_text(value: Any, maximum: int) -> str:
|
|
143
|
+
"""Bound exact primitive text without dispatching to user conversions."""
|
|
144
|
+
value_type = type(value)
|
|
145
|
+
if value_type is str:
|
|
146
|
+
text = value
|
|
147
|
+
elif value is None:
|
|
148
|
+
text = "null"
|
|
149
|
+
elif value_type is bool:
|
|
150
|
+
text = "true" if value else "false"
|
|
151
|
+
elif value_type is int and int.bit_length(value) <= _MAX_RESULT_INTEGER_BITS:
|
|
152
|
+
text = json.dumps(value)
|
|
153
|
+
elif value_type is float and math.isfinite(value):
|
|
154
|
+
text = json.dumps(value, allow_nan=False)
|
|
155
|
+
else:
|
|
156
|
+
text = f"<{_safe_type_name(value)}>"
|
|
157
|
+
bounded = text[:maximum]
|
|
158
|
+
return bounded.encode("utf-8", "replace").decode("utf-8")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _safe_type_name(value: Any) -> str:
|
|
162
|
+
"""Return a bounded type label without invoking a custom metaclass hook."""
|
|
163
|
+
try:
|
|
164
|
+
# Cache and call the built-in getset descriptor itself. Going through
|
|
165
|
+
# either ``type(value).__name__`` or ``type.__getattribute__`` still
|
|
166
|
+
# honors a hostile metaclass data descriptor named ``__name__``.
|
|
167
|
+
name = _TYPE_NAME_DESCRIPTOR.__get__(type(value), type)
|
|
168
|
+
except BaseException:
|
|
169
|
+
return "unknown"
|
|
170
|
+
if type(name) is not str:
|
|
171
|
+
return "unknown"
|
|
172
|
+
bounded = name[:128]
|
|
173
|
+
cleaned = "".join(
|
|
174
|
+
character
|
|
175
|
+
if character.isascii() and (character.isalnum() or character in "._-")
|
|
176
|
+
else "_"
|
|
177
|
+
for character in bounded
|
|
178
|
+
)
|
|
179
|
+
return cleaned or "unknown"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _is_hook_free_awaitable(value: Any) -> bool:
|
|
183
|
+
"""Classify real awaitables without generic ABC/metaclass dispatch."""
|
|
184
|
+
value_type = type(value)
|
|
185
|
+
try:
|
|
186
|
+
if value_type is types.GeneratorType:
|
|
187
|
+
code = _GENERATOR_CODE_DESCRIPTOR.__get__(
|
|
188
|
+
value, types.GeneratorType,
|
|
189
|
+
)
|
|
190
|
+
flags = _CODE_FLAGS_DESCRIPTOR.__get__(code, types.CodeType)
|
|
191
|
+
if (
|
|
192
|
+
type(flags) is int
|
|
193
|
+
and flags & inspect.CO_ITERABLE_COROUTINE
|
|
194
|
+
):
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
# Python's special-method lookup reads the actual MRO dictionaries;
|
|
198
|
+
# it does not consult metaclass attributes. Reproduce that lookup via
|
|
199
|
+
# cached built-in descriptors so hostile ``__mro__``/``__dict__``
|
|
200
|
+
# properties and ABC ``__subclasshook__`` code never execute.
|
|
201
|
+
hierarchy = _TYPE_MRO_DESCRIPTOR.__get__(value_type, type)
|
|
202
|
+
if type(hierarchy) is not tuple:
|
|
203
|
+
return False
|
|
204
|
+
for base_index in range(tuple.__len__(hierarchy)):
|
|
205
|
+
base = tuple.__getitem__(hierarchy, base_index)
|
|
206
|
+
namespace = _TYPE_DICT_DESCRIPTOR.__get__(base, type)
|
|
207
|
+
if type(namespace) is not _MAPPING_PROXY_TYPE:
|
|
208
|
+
return False
|
|
209
|
+
await_method = None
|
|
210
|
+
found_await_method = False
|
|
211
|
+
for key, method in _MAPPING_PROXY_ITEMS(namespace):
|
|
212
|
+
# A metaclass can inject a ``str`` subclass as a namespace
|
|
213
|
+
# key. It may compare equal to ``__await__`` regardless of its
|
|
214
|
+
# visible contents, and continuing into a base would let the
|
|
215
|
+
# eventual ``await`` execute that equality hook. Iterating the
|
|
216
|
+
# built-in mapping proxy exposes key/value pairs without any
|
|
217
|
+
# lookup or descriptor dispatch, so reject the whole MRO as
|
|
218
|
+
# soon as any non-exact namespace key is observed.
|
|
219
|
+
if type(key) is not str:
|
|
220
|
+
return False
|
|
221
|
+
if key == "__await__":
|
|
222
|
+
found_await_method = True
|
|
223
|
+
await_method = method
|
|
224
|
+
if found_await_method:
|
|
225
|
+
# Match ``inspect.isawaitable`` compatibility: an exact first
|
|
226
|
+
# MRO definition of ``None`` explicitly disables awaiting.
|
|
227
|
+
return await_method is not None
|
|
228
|
+
except BaseException:
|
|
229
|
+
return False
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class _BoundedText:
|
|
234
|
+
"""A small append-only text sink that never exceeds its character budget."""
|
|
235
|
+
|
|
236
|
+
__slots__ = ("_maximum", "_parts", "_size", "truncated")
|
|
237
|
+
|
|
238
|
+
def __init__(self, maximum: int) -> None:
|
|
239
|
+
self._maximum = maximum
|
|
240
|
+
self._parts: List[str] = []
|
|
241
|
+
self._size = 0
|
|
242
|
+
self.truncated = False
|
|
243
|
+
|
|
244
|
+
def add(self, value: str) -> None:
|
|
245
|
+
remaining = self._maximum - self._size
|
|
246
|
+
if remaining <= 0:
|
|
247
|
+
self.truncated = True
|
|
248
|
+
return
|
|
249
|
+
if len(value) > remaining:
|
|
250
|
+
self._parts.append(value[:remaining])
|
|
251
|
+
self._size = self._maximum
|
|
252
|
+
self.truncated = True
|
|
253
|
+
return
|
|
254
|
+
self._parts.append(value)
|
|
255
|
+
self._size += len(value)
|
|
256
|
+
|
|
257
|
+
def render(self) -> str:
|
|
258
|
+
rendered = "".join(self._parts)
|
|
259
|
+
if not self.truncated:
|
|
260
|
+
return rendered
|
|
261
|
+
if not rendered:
|
|
262
|
+
return ""
|
|
263
|
+
return rendered[:-1] + "…"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _safe_json_clone(
|
|
267
|
+
value: Any,
|
|
268
|
+
*,
|
|
269
|
+
depth: int,
|
|
270
|
+
budget: Dict[str, int],
|
|
271
|
+
active: set[int],
|
|
272
|
+
) -> tuple[bool, Any, str]:
|
|
273
|
+
"""Clone only exact built-in JSON values under fixed traversal budgets.
|
|
274
|
+
|
|
275
|
+
Exact-type checks are intentional: a list/dict/string subclass may replace
|
|
276
|
+
iteration, conversion, indexing, or length with arbitrary user code.
|
|
277
|
+
"""
|
|
278
|
+
if budget["nodes"] <= 0 or depth > _MAX_RESULT_DEPTH:
|
|
279
|
+
return False, None, "oversized"
|
|
280
|
+
budget["nodes"] -= 1
|
|
281
|
+
value_type = type(value)
|
|
282
|
+
|
|
283
|
+
if value is None or value_type is bool:
|
|
284
|
+
return True, value, ""
|
|
285
|
+
if value_type is int:
|
|
286
|
+
if int.bit_length(value) > _MAX_RESULT_INTEGER_BITS:
|
|
287
|
+
return False, None, "oversized"
|
|
288
|
+
return True, value, ""
|
|
289
|
+
if value_type is float:
|
|
290
|
+
if not math.isfinite(value):
|
|
291
|
+
return False, None, "not_json"
|
|
292
|
+
return True, value, ""
|
|
293
|
+
if value_type is str:
|
|
294
|
+
length = str.__len__(value)
|
|
295
|
+
if length > budget["text"]:
|
|
296
|
+
return False, None, "oversized"
|
|
297
|
+
budget["text"] -= length
|
|
298
|
+
return True, value, ""
|
|
299
|
+
|
|
300
|
+
if value_type not in (dict, list, tuple):
|
|
301
|
+
return False, None, "not_json"
|
|
302
|
+
identity = id(value)
|
|
303
|
+
if identity in active:
|
|
304
|
+
return False, None, "not_json"
|
|
305
|
+
active.add(identity)
|
|
306
|
+
try:
|
|
307
|
+
if value_type is dict:
|
|
308
|
+
length = dict.__len__(value)
|
|
309
|
+
if length > _MAX_RESULT_ITEMS or length > budget["nodes"]:
|
|
310
|
+
return False, None, "oversized"
|
|
311
|
+
entries = list(dict.items(value))
|
|
312
|
+
if any(type(key) is not str for key, _item in entries):
|
|
313
|
+
return False, None, "not_json"
|
|
314
|
+
entries.sort(key=lambda entry: entry[0])
|
|
315
|
+
cloned: Dict[str, Any] = {}
|
|
316
|
+
for key, item in entries:
|
|
317
|
+
key_length = str.__len__(key)
|
|
318
|
+
if key_length > budget["text"]:
|
|
319
|
+
return False, None, "oversized"
|
|
320
|
+
budget["text"] -= key_length
|
|
321
|
+
ok, projected, reason = _safe_json_clone(
|
|
322
|
+
item, depth=depth + 1, budget=budget, active=active,
|
|
323
|
+
)
|
|
324
|
+
if not ok:
|
|
325
|
+
return False, None, reason
|
|
326
|
+
cloned[key] = projected
|
|
327
|
+
return True, cloned, ""
|
|
328
|
+
|
|
329
|
+
length = (
|
|
330
|
+
list.__len__(value) if value_type is list else tuple.__len__(value)
|
|
331
|
+
)
|
|
332
|
+
if length > _MAX_RESULT_ITEMS or length > budget["nodes"]:
|
|
333
|
+
return False, None, "oversized"
|
|
334
|
+
projected_items: List[Any] = []
|
|
335
|
+
getter = list.__getitem__ if value_type is list else tuple.__getitem__
|
|
336
|
+
for index in range(length):
|
|
337
|
+
ok, projected, reason = _safe_json_clone(
|
|
338
|
+
getter(value, index),
|
|
339
|
+
depth=depth + 1,
|
|
340
|
+
budget=budget,
|
|
341
|
+
active=active,
|
|
342
|
+
)
|
|
343
|
+
if not ok:
|
|
344
|
+
return False, None, reason
|
|
345
|
+
projected_items.append(projected)
|
|
346
|
+
return True, projected_items, ""
|
|
347
|
+
finally:
|
|
348
|
+
active.remove(identity)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _summary_token(
|
|
352
|
+
value: Any,
|
|
353
|
+
*,
|
|
354
|
+
maximum: int,
|
|
355
|
+
depth: int = 0,
|
|
356
|
+
active: Optional[set[int]] = None,
|
|
357
|
+
) -> str:
|
|
358
|
+
writer = _BoundedText(maximum)
|
|
359
|
+
_write_summary(value, writer=writer, depth=depth, active=active or set())
|
|
360
|
+
return writer.render()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
_BASE_EXCEPTION_ARGS = BaseException.__dict__["args"]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _safe_exception_projection(error: BaseException) -> tuple[str, str]:
|
|
367
|
+
"""Project exception identity/args without calling exception user hooks."""
|
|
368
|
+
error_type = _safe_type_name(error)
|
|
369
|
+
try:
|
|
370
|
+
arguments = _BASE_EXCEPTION_ARGS.__get__(error, type(error))
|
|
371
|
+
except BaseException:
|
|
372
|
+
arguments = ()
|
|
373
|
+
if type(arguments) is not tuple:
|
|
374
|
+
return error_type, f"<{error_type} raised>"
|
|
375
|
+
if tuple.__len__(arguments) == 1:
|
|
376
|
+
first = tuple.__getitem__(arguments, 0)
|
|
377
|
+
if type(first) is str:
|
|
378
|
+
return error_type, _safe_text(first, 1000)
|
|
379
|
+
return error_type, _summary_token(first, maximum=1000)
|
|
380
|
+
if not arguments:
|
|
381
|
+
return error_type, f"<{error_type} raised>"
|
|
382
|
+
return error_type, _summary_token(arguments, maximum=1000)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _shallow_summary_token(value: Any, *, maximum: int) -> str:
|
|
386
|
+
writer = _BoundedText(maximum)
|
|
387
|
+
value_type = type(value)
|
|
388
|
+
type_name = _safe_type_name(value)
|
|
389
|
+
if value is None:
|
|
390
|
+
writer.add("null")
|
|
391
|
+
elif value_type is bool:
|
|
392
|
+
writer.add("true" if value else "false")
|
|
393
|
+
elif value_type is int:
|
|
394
|
+
bits = int.bit_length(value)
|
|
395
|
+
if bits <= _MAX_RESULT_INTEGER_BITS:
|
|
396
|
+
writer.add(json.dumps(value, allow_nan=False))
|
|
397
|
+
else:
|
|
398
|
+
sign = "negative" if value < 0 else "positive"
|
|
399
|
+
writer.add(f"<int bits={bits} sign={sign}>")
|
|
400
|
+
elif value_type is float:
|
|
401
|
+
if math.isfinite(value):
|
|
402
|
+
writer.add(json.dumps(value, allow_nan=False))
|
|
403
|
+
elif math.isnan(value):
|
|
404
|
+
writer.add("<float nan>")
|
|
405
|
+
elif value < 0:
|
|
406
|
+
writer.add("<float -infinity>")
|
|
407
|
+
else:
|
|
408
|
+
writer.add("<float infinity>")
|
|
409
|
+
elif value_type is str:
|
|
410
|
+
length = str.__len__(value)
|
|
411
|
+
prefix = str.__getitem__(value, slice(0, 512))
|
|
412
|
+
writer.add(json.dumps(prefix, ensure_ascii=False))
|
|
413
|
+
if length > 512:
|
|
414
|
+
writer.add(f"<chars={length}>")
|
|
415
|
+
elif value_type is bytes:
|
|
416
|
+
length = bytes.__len__(value)
|
|
417
|
+
writer.add("bytes:")
|
|
418
|
+
writer.add(bytes.hex(bytes.__getitem__(value, slice(0, 128))))
|
|
419
|
+
if length > 128:
|
|
420
|
+
writer.add(f"<bytes={length}>")
|
|
421
|
+
elif value_type is bytearray:
|
|
422
|
+
length = bytearray.__len__(value)
|
|
423
|
+
prefix = bytearray.__getitem__(value, slice(0, 128))
|
|
424
|
+
writer.add("bytearray:")
|
|
425
|
+
writer.add(bytearray.hex(prefix))
|
|
426
|
+
if length > 128:
|
|
427
|
+
writer.add(f"<bytes={length}>")
|
|
428
|
+
elif value_type is dict:
|
|
429
|
+
writer.add(f"<dict items={dict.__len__(value)}>")
|
|
430
|
+
elif value_type is list:
|
|
431
|
+
writer.add(f"<list items={list.__len__(value)}>")
|
|
432
|
+
elif value_type is tuple:
|
|
433
|
+
writer.add(f"<tuple items={tuple.__len__(value)}>")
|
|
434
|
+
elif value_type is set:
|
|
435
|
+
writer.add(f"<set items={set.__len__(value)}>")
|
|
436
|
+
elif value_type is frozenset:
|
|
437
|
+
writer.add(f"<frozenset items={frozenset.__len__(value)}>")
|
|
438
|
+
else:
|
|
439
|
+
writer.add(f"<{type_name} opaque>")
|
|
440
|
+
return writer.render()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _write_summary(
|
|
444
|
+
value: Any,
|
|
445
|
+
*,
|
|
446
|
+
writer: _BoundedText,
|
|
447
|
+
depth: int,
|
|
448
|
+
active: set[int],
|
|
449
|
+
) -> None:
|
|
450
|
+
"""Write a deterministic diagnostic shape without calling user methods."""
|
|
451
|
+
value_type = type(value)
|
|
452
|
+
type_name = _safe_type_name(value)
|
|
453
|
+
if depth > _MAX_RESULT_DEPTH:
|
|
454
|
+
writer.add(f"<max-depth:{type_name}>")
|
|
455
|
+
return
|
|
456
|
+
if value_type not in (dict, list, tuple, set, frozenset):
|
|
457
|
+
writer.add(_shallow_summary_token(value, maximum=2000))
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
identity = id(value)
|
|
461
|
+
if identity in active:
|
|
462
|
+
writer.add(f"<cycle:{type_name}>")
|
|
463
|
+
return
|
|
464
|
+
active.add(identity)
|
|
465
|
+
try:
|
|
466
|
+
if value_type is dict:
|
|
467
|
+
length = dict.__len__(value)
|
|
468
|
+
if length > _MAX_RESULT_ITEMS:
|
|
469
|
+
writer.add(f"<dict items={length}>")
|
|
470
|
+
return
|
|
471
|
+
pairs = []
|
|
472
|
+
for key, item in dict.items(value):
|
|
473
|
+
key_token = _shallow_summary_token(key, maximum=256)
|
|
474
|
+
item_token = _shallow_summary_token(item, maximum=512)
|
|
475
|
+
pairs.append((key_token, item_token))
|
|
476
|
+
pairs.sort()
|
|
477
|
+
writer.add("{")
|
|
478
|
+
for index, (key_token, item_token) in enumerate(pairs):
|
|
479
|
+
if index:
|
|
480
|
+
writer.add(",")
|
|
481
|
+
writer.add(key_token)
|
|
482
|
+
writer.add(":")
|
|
483
|
+
writer.add(item_token)
|
|
484
|
+
writer.add("}")
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
if value_type in (list, tuple):
|
|
488
|
+
length = (
|
|
489
|
+
list.__len__(value)
|
|
490
|
+
if value_type is list
|
|
491
|
+
else tuple.__len__(value)
|
|
492
|
+
)
|
|
493
|
+
getter = list.__getitem__ if value_type is list else tuple.__getitem__
|
|
494
|
+
visible = min(length, _MAX_RESULT_ITEMS)
|
|
495
|
+
writer.add("[" if value_type is list else "tuple(")
|
|
496
|
+
for index in range(visible):
|
|
497
|
+
if index:
|
|
498
|
+
writer.add(",")
|
|
499
|
+
writer.add(_shallow_summary_token(
|
|
500
|
+
getter(value, index), maximum=512,
|
|
501
|
+
))
|
|
502
|
+
if visible < length:
|
|
503
|
+
if visible:
|
|
504
|
+
writer.add(",")
|
|
505
|
+
writer.add(f"…<items={length}>")
|
|
506
|
+
writer.add("]" if value_type is list else ")")
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
length = (
|
|
510
|
+
set.__len__(value)
|
|
511
|
+
if value_type is set
|
|
512
|
+
else frozenset.__len__(value)
|
|
513
|
+
)
|
|
514
|
+
if length > _MAX_RESULT_ITEMS:
|
|
515
|
+
writer.add(f"<{type_name} items={length}>")
|
|
516
|
+
return
|
|
517
|
+
iterator = (
|
|
518
|
+
set.__iter__(value)
|
|
519
|
+
if value_type is set
|
|
520
|
+
else frozenset.__iter__(value)
|
|
521
|
+
)
|
|
522
|
+
tokens = sorted(
|
|
523
|
+
_shallow_summary_token(item, maximum=512)
|
|
524
|
+
for item in iterator
|
|
525
|
+
)
|
|
526
|
+
writer.add("set{" if value_type is set else "frozenset{")
|
|
527
|
+
for index, token in enumerate(tokens):
|
|
528
|
+
if index:
|
|
529
|
+
writer.add(",")
|
|
530
|
+
writer.add(token)
|
|
531
|
+
writer.add("}")
|
|
532
|
+
finally:
|
|
533
|
+
active.remove(identity)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _render_receipt_projection(
|
|
537
|
+
*,
|
|
538
|
+
value_type: str,
|
|
539
|
+
reason: str,
|
|
540
|
+
summary: str,
|
|
541
|
+
descriptor: str,
|
|
542
|
+
) -> str:
|
|
543
|
+
digest_payload = json.dumps(
|
|
544
|
+
[value_type, reason, descriptor],
|
|
545
|
+
ensure_ascii=True,
|
|
546
|
+
separators=(",", ":"),
|
|
547
|
+
)
|
|
548
|
+
envelope = {
|
|
549
|
+
"digest": hashlib.sha256(digest_payload.encode("ascii")).hexdigest(),
|
|
550
|
+
"digest_scope": "bounded-shape-v1",
|
|
551
|
+
"projection": "bounded",
|
|
552
|
+
"reason": reason,
|
|
553
|
+
"summary": "",
|
|
554
|
+
"type": value_type,
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
def render(summary_prefix: str) -> str:
|
|
558
|
+
envelope["summary"] = summary_prefix
|
|
559
|
+
return json.dumps(
|
|
560
|
+
envelope,
|
|
561
|
+
ensure_ascii=True,
|
|
562
|
+
separators=(",", ":"),
|
|
563
|
+
sort_keys=True,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
encoded = render(summary)
|
|
567
|
+
if len(encoded) <= _MAX_METADATA_BYTES:
|
|
568
|
+
return encoded
|
|
569
|
+
|
|
570
|
+
# ASCII JSON makes character length the exact persisted byte length.
|
|
571
|
+
# Binary search retains the largest exact prefix; whitespace is never
|
|
572
|
+
# normalized or collapsed.
|
|
573
|
+
low, high = 0, len(summary)
|
|
574
|
+
bounded = render("")
|
|
575
|
+
while low <= high:
|
|
576
|
+
middle = (low + high) // 2
|
|
577
|
+
candidate = summary[:middle]
|
|
578
|
+
if middle < len(summary):
|
|
579
|
+
candidate += "…"
|
|
580
|
+
projected = render(candidate)
|
|
581
|
+
if len(projected) <= _MAX_METADATA_BYTES:
|
|
582
|
+
bounded = projected
|
|
583
|
+
low = middle + 1
|
|
584
|
+
else:
|
|
585
|
+
high = middle - 1
|
|
586
|
+
return bounded
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _bounded_receipt_json(value: Any, *, value_type: str) -> str:
|
|
590
|
+
budget = {
|
|
591
|
+
"nodes": _MAX_RESULT_NODES,
|
|
592
|
+
"text": _MAX_RESULT_TEXT_CHARS,
|
|
593
|
+
}
|
|
594
|
+
ok, safe_value, reason = _safe_json_clone(
|
|
595
|
+
value, depth=0, budget=budget, active=set(),
|
|
596
|
+
)
|
|
597
|
+
canonical: Optional[str] = None
|
|
598
|
+
if ok:
|
|
599
|
+
canonical = json.dumps(
|
|
600
|
+
safe_value,
|
|
601
|
+
ensure_ascii=True,
|
|
602
|
+
separators=(",", ":"),
|
|
603
|
+
sort_keys=True,
|
|
604
|
+
allow_nan=False,
|
|
605
|
+
)
|
|
606
|
+
if len(canonical) <= _MAX_METADATA_BYTES:
|
|
607
|
+
return canonical
|
|
608
|
+
reason = "oversized"
|
|
609
|
+
|
|
610
|
+
if canonical is not None:
|
|
611
|
+
descriptor = canonical
|
|
612
|
+
summary = canonical[:_MAX_RESULT_SUMMARY_CHARS]
|
|
613
|
+
else:
|
|
614
|
+
descriptor = _summary_token(
|
|
615
|
+
value, maximum=_MAX_RESULT_DESCRIPTOR_CHARS,
|
|
616
|
+
)
|
|
617
|
+
summary = descriptor[:_MAX_RESULT_SUMMARY_CHARS]
|
|
618
|
+
return _render_receipt_projection(
|
|
619
|
+
value_type=value_type,
|
|
620
|
+
reason=reason or "not_json",
|
|
621
|
+
summary=summary,
|
|
622
|
+
descriptor=descriptor,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _receipt_json(value: Any) -> str:
|
|
627
|
+
"""Project an arbitrary callback result into deterministic bounded JSON.
|
|
628
|
+
|
|
629
|
+
No operation in this function dispatches through a user-defined iterator,
|
|
630
|
+
string/repr conversion, container override, or metaclass attribute hook.
|
|
631
|
+
Concurrent mutation of an exact built-in container also degrades to an
|
|
632
|
+
opaque receipt instead of escaping and stranding the claimed attempt.
|
|
633
|
+
"""
|
|
634
|
+
value_type = _safe_type_name(value)
|
|
635
|
+
try:
|
|
636
|
+
return _bounded_receipt_json(value, value_type=value_type)
|
|
637
|
+
except Exception:
|
|
638
|
+
descriptor = f"<{value_type} projection-unavailable>"
|
|
639
|
+
return _render_receipt_projection(
|
|
640
|
+
value_type=value_type,
|
|
641
|
+
reason="not_json",
|
|
642
|
+
summary=descriptor,
|
|
643
|
+
descriptor=descriptor,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
class TaskSchedule:
|
|
648
|
+
"""A persisted periodic task and its current scheduling state."""
|
|
649
|
+
|
|
650
|
+
def __init__(
|
|
651
|
+
self,
|
|
652
|
+
id: str,
|
|
653
|
+
name: str,
|
|
654
|
+
interval_seconds: int,
|
|
655
|
+
callback_name: str,
|
|
656
|
+
last_run: Optional[datetime] = None,
|
|
657
|
+
next_run: Optional[datetime] = None,
|
|
658
|
+
enabled: bool = True,
|
|
659
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
660
|
+
failure_count: int = 0,
|
|
661
|
+
lease_token: Optional[str] = None,
|
|
662
|
+
lease_expires_at: Optional[datetime] = None,
|
|
663
|
+
degraded_reason: Optional[str] = None,
|
|
664
|
+
created_at: Optional[datetime] = None,
|
|
665
|
+
updated_at: Optional[datetime] = None,
|
|
666
|
+
) -> None:
|
|
667
|
+
self.id = id
|
|
668
|
+
self.name = name
|
|
669
|
+
self.interval_seconds = interval_seconds
|
|
670
|
+
self.callback_name = callback_name
|
|
671
|
+
self.last_run = last_run
|
|
672
|
+
self.next_run = next_run or datetime.now(timezone.utc)
|
|
673
|
+
self.enabled = enabled
|
|
674
|
+
self.metadata = metadata or {}
|
|
675
|
+
self.failure_count = max(0, int(failure_count))
|
|
676
|
+
self.lease_token = lease_token
|
|
677
|
+
self.lease_expires_at = lease_expires_at
|
|
678
|
+
self.degraded_reason = degraded_reason
|
|
679
|
+
self.created_at = created_at
|
|
680
|
+
self.updated_at = updated_at
|
|
681
|
+
|
|
682
|
+
def to_dict(self) -> dict:
|
|
683
|
+
return {
|
|
684
|
+
"id": self.id,
|
|
685
|
+
"name": self.name,
|
|
686
|
+
"interval_seconds": self.interval_seconds,
|
|
687
|
+
"callback_name": self.callback_name,
|
|
688
|
+
"last_run": self.last_run.isoformat() if self.last_run else None,
|
|
689
|
+
"next_run": self.next_run.isoformat() if self.next_run else None,
|
|
690
|
+
"enabled": self.enabled,
|
|
691
|
+
"metadata": self.metadata,
|
|
692
|
+
"failure_count": self.failure_count,
|
|
693
|
+
"leased": bool(self.lease_token),
|
|
694
|
+
"lease_expires_at": (
|
|
695
|
+
self.lease_expires_at.isoformat()
|
|
696
|
+
if self.lease_expires_at else None
|
|
697
|
+
),
|
|
698
|
+
"degraded_reason": self.degraded_reason,
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
@dataclass(frozen=True)
|
|
703
|
+
class RunClaim:
|
|
704
|
+
schedule: TaskSchedule
|
|
705
|
+
attempt_id: str
|
|
706
|
+
lease_token: str
|
|
707
|
+
claimed_at: datetime
|
|
708
|
+
lease_expires_at: datetime
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
class ScheduleStore:
|
|
712
|
+
"""SQLite schedule store with atomic cross-process due claims."""
|
|
713
|
+
|
|
714
|
+
def __init__(self, db_path: str, *, clock: Optional[Callable[[], datetime]] = None):
|
|
715
|
+
self._db_path = str(db_path)
|
|
716
|
+
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
|
717
|
+
self._init_db()
|
|
718
|
+
|
|
719
|
+
def _now(self) -> datetime:
|
|
720
|
+
return _utc(self._clock())
|
|
721
|
+
|
|
722
|
+
@contextmanager
|
|
723
|
+
def _connect(self) -> Iterator[sqlite3.Connection]:
|
|
724
|
+
conn = sqlite3.connect(
|
|
725
|
+
self._db_path,
|
|
726
|
+
timeout=30.0,
|
|
727
|
+
isolation_level=None,
|
|
728
|
+
)
|
|
729
|
+
try:
|
|
730
|
+
conn.row_factory = sqlite3.Row
|
|
731
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
732
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
733
|
+
with conn:
|
|
734
|
+
yield conn
|
|
735
|
+
finally:
|
|
736
|
+
conn.close()
|
|
737
|
+
|
|
738
|
+
def _init_db(self) -> None:
|
|
739
|
+
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
740
|
+
now = _iso(self._now())
|
|
741
|
+
with self._connect() as conn:
|
|
742
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
743
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
744
|
+
try:
|
|
745
|
+
conn.execute("""
|
|
746
|
+
CREATE TABLE IF NOT EXISTS schedules (
|
|
747
|
+
id TEXT PRIMARY KEY,
|
|
748
|
+
name TEXT NOT NULL,
|
|
749
|
+
interval_seconds INTEGER NOT NULL,
|
|
750
|
+
callback_name TEXT NOT NULL,
|
|
751
|
+
last_run TEXT,
|
|
752
|
+
next_run TEXT,
|
|
753
|
+
enabled INTEGER DEFAULT 1,
|
|
754
|
+
metadata TEXT DEFAULT '{}'
|
|
755
|
+
)
|
|
756
|
+
""")
|
|
757
|
+
columns = {
|
|
758
|
+
row[1] for row in conn.execute("PRAGMA table_info(schedules)")
|
|
759
|
+
}
|
|
760
|
+
additions = {
|
|
761
|
+
"failure_count": "INTEGER NOT NULL DEFAULT 0",
|
|
762
|
+
"lease_token": "TEXT",
|
|
763
|
+
"lease_expires_at": "TEXT",
|
|
764
|
+
"degraded_reason": "TEXT",
|
|
765
|
+
"created_at": "TEXT",
|
|
766
|
+
"updated_at": "TEXT",
|
|
767
|
+
}
|
|
768
|
+
for column, declaration in additions.items():
|
|
769
|
+
if column not in columns:
|
|
770
|
+
conn.execute(
|
|
771
|
+
f"ALTER TABLE schedules ADD COLUMN {column} {declaration}"
|
|
772
|
+
)
|
|
773
|
+
conn.execute(
|
|
774
|
+
"UPDATE schedules SET created_at=COALESCE(created_at, ?), "
|
|
775
|
+
"updated_at=COALESCE(updated_at, ?), failure_count="
|
|
776
|
+
"COALESCE(failure_count, 0), next_run=COALESCE(next_run, ?)",
|
|
777
|
+
(now, now, now),
|
|
778
|
+
)
|
|
779
|
+
conn.execute(
|
|
780
|
+
"UPDATE schedules SET lease_token=NULL, lease_expires_at=NULL "
|
|
781
|
+
"WHERE (lease_token IS NULL) != (lease_expires_at IS NULL)"
|
|
782
|
+
)
|
|
783
|
+
self._migrate_duplicate_names(conn)
|
|
784
|
+
self._migrate_legacy_rows(conn)
|
|
785
|
+
conn.execute(
|
|
786
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS schedules_name_unique "
|
|
787
|
+
"ON schedules(name)"
|
|
788
|
+
)
|
|
789
|
+
conn.execute(
|
|
790
|
+
"CREATE INDEX IF NOT EXISTS schedules_due_idx ON schedules "
|
|
791
|
+
"(enabled, next_run, lease_expires_at)"
|
|
792
|
+
)
|
|
793
|
+
conn.execute("""
|
|
794
|
+
CREATE TABLE IF NOT EXISTS schedule_run_attempts (
|
|
795
|
+
attempt_id TEXT PRIMARY KEY,
|
|
796
|
+
schedule_id TEXT NOT NULL,
|
|
797
|
+
task_name TEXT NOT NULL,
|
|
798
|
+
callback_name TEXT NOT NULL,
|
|
799
|
+
lease_token TEXT NOT NULL UNIQUE,
|
|
800
|
+
claimed_at TEXT NOT NULL,
|
|
801
|
+
lease_expires_at TEXT NOT NULL,
|
|
802
|
+
scheduled_for TEXT NOT NULL,
|
|
803
|
+
failure_count_before INTEGER NOT NULL,
|
|
804
|
+
metadata_digest TEXT NOT NULL
|
|
805
|
+
)
|
|
806
|
+
""")
|
|
807
|
+
conn.execute("""
|
|
808
|
+
CREATE TABLE IF NOT EXISTS schedule_run_receipts (
|
|
809
|
+
receipt_id TEXT PRIMARY KEY,
|
|
810
|
+
attempt_id TEXT NOT NULL UNIQUE,
|
|
811
|
+
schedule_id TEXT NOT NULL,
|
|
812
|
+
task_name TEXT NOT NULL,
|
|
813
|
+
lease_token TEXT NOT NULL,
|
|
814
|
+
status TEXT NOT NULL,
|
|
815
|
+
finished_at TEXT NOT NULL,
|
|
816
|
+
result_json TEXT,
|
|
817
|
+
error_type TEXT,
|
|
818
|
+
error_message TEXT,
|
|
819
|
+
failure_count_after INTEGER NOT NULL,
|
|
820
|
+
next_run TEXT
|
|
821
|
+
)
|
|
822
|
+
""")
|
|
823
|
+
conn.execute(
|
|
824
|
+
"CREATE INDEX IF NOT EXISTS schedule_attempt_schedule_idx "
|
|
825
|
+
"ON schedule_run_attempts(schedule_id, claimed_at)"
|
|
826
|
+
)
|
|
827
|
+
conn.execute(
|
|
828
|
+
"CREATE INDEX IF NOT EXISTS schedule_receipt_schedule_idx "
|
|
829
|
+
"ON schedule_run_receipts(schedule_id, finished_at)"
|
|
830
|
+
)
|
|
831
|
+
for table in ("schedule_run_attempts", "schedule_run_receipts"):
|
|
832
|
+
conn.execute(f"""
|
|
833
|
+
CREATE TRIGGER IF NOT EXISTS {table}_no_update
|
|
834
|
+
BEFORE UPDATE ON {table}
|
|
835
|
+
BEGIN
|
|
836
|
+
SELECT RAISE(ABORT, '{table} append-only');
|
|
837
|
+
END
|
|
838
|
+
""")
|
|
839
|
+
conn.execute(f"""
|
|
840
|
+
CREATE TRIGGER IF NOT EXISTS {table}_no_delete
|
|
841
|
+
BEFORE DELETE ON {table}
|
|
842
|
+
BEGIN
|
|
843
|
+
SELECT RAISE(ABORT, '{table} append-only');
|
|
844
|
+
END
|
|
845
|
+
""")
|
|
846
|
+
conn.commit()
|
|
847
|
+
except Exception:
|
|
848
|
+
conn.rollback()
|
|
849
|
+
raise
|
|
850
|
+
|
|
851
|
+
@staticmethod
|
|
852
|
+
def _migrate_duplicate_names(conn: sqlite3.Connection) -> None:
|
|
853
|
+
duplicates = conn.execute(
|
|
854
|
+
"SELECT name FROM schedules GROUP BY name HAVING COUNT(*) > 1"
|
|
855
|
+
).fetchall()
|
|
856
|
+
for duplicate in duplicates:
|
|
857
|
+
name = str(duplicate["name"])
|
|
858
|
+
rows = conn.execute(
|
|
859
|
+
"SELECT rowid,id FROM schedules WHERE name=? ORDER BY rowid,id",
|
|
860
|
+
(name,),
|
|
861
|
+
).fetchall()
|
|
862
|
+
canonical_id = str(rows[0]["id"])
|
|
863
|
+
for index, row in enumerate(rows[1:], start=1):
|
|
864
|
+
digest = hashlib.sha256(str(row["id"]).encode()).hexdigest()[:12]
|
|
865
|
+
renamed = f"{name[:96]}#legacy-duplicate-{digest}-{index}"
|
|
866
|
+
conn.execute(
|
|
867
|
+
"UPDATE schedules SET name=?,enabled=0,degraded_reason=? "
|
|
868
|
+
"WHERE rowid=?",
|
|
869
|
+
(
|
|
870
|
+
renamed,
|
|
871
|
+
f"migration_duplicate_name:{canonical_id}"[:500],
|
|
872
|
+
row["rowid"],
|
|
873
|
+
),
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
@staticmethod
|
|
877
|
+
def _migrate_legacy_rows(conn: sqlite3.Connection) -> None:
|
|
878
|
+
for row in conn.execute(
|
|
879
|
+
"SELECT id,name,interval_seconds,callback_name,metadata "
|
|
880
|
+
"FROM schedules"
|
|
881
|
+
).fetchall():
|
|
882
|
+
reason: Optional[str] = None
|
|
883
|
+
if not _TASK_NAME.fullmatch(str(row["name"] or "")):
|
|
884
|
+
reason = "migration_invalid_task_name"
|
|
885
|
+
try:
|
|
886
|
+
_validate_interval(row["interval_seconds"])
|
|
887
|
+
except ValueError:
|
|
888
|
+
reason = reason or "migration_invalid_interval"
|
|
889
|
+
if not str(row["callback_name"] or "").strip():
|
|
890
|
+
reason = reason or "migration_invalid_callback_name"
|
|
891
|
+
try:
|
|
892
|
+
decoded = json.loads(str(row["metadata"] or "{}"))
|
|
893
|
+
_, encoded = _canonical_metadata(decoded)
|
|
894
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
895
|
+
reason = reason or "migration_invalid_metadata"
|
|
896
|
+
encoded = None
|
|
897
|
+
if encoded is not None:
|
|
898
|
+
conn.execute(
|
|
899
|
+
"UPDATE schedules SET metadata=? WHERE id=?",
|
|
900
|
+
(encoded, row["id"]),
|
|
901
|
+
)
|
|
902
|
+
if reason:
|
|
903
|
+
conn.execute("""
|
|
904
|
+
UPDATE schedules SET enabled=0,
|
|
905
|
+
degraded_reason=COALESCE(degraded_reason, ?)
|
|
906
|
+
WHERE id=?
|
|
907
|
+
""", (reason, row["id"]))
|
|
908
|
+
|
|
909
|
+
def register_schedule(
|
|
910
|
+
self,
|
|
911
|
+
*,
|
|
912
|
+
name: str,
|
|
913
|
+
interval_seconds: int,
|
|
914
|
+
callback_name: str,
|
|
915
|
+
metadata_json: str,
|
|
916
|
+
now: datetime,
|
|
917
|
+
) -> TaskSchedule:
|
|
918
|
+
schedule_id = str(uuid.uuid5(
|
|
919
|
+
_SCHEDULE_NAMESPACE, f"colony-autonomy-schedule:{name}",
|
|
920
|
+
))
|
|
921
|
+
stamp = _iso(now)
|
|
922
|
+
with self._connect() as conn:
|
|
923
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
924
|
+
try:
|
|
925
|
+
existing = conn.execute(
|
|
926
|
+
"SELECT * FROM schedules WHERE name=?", (name,),
|
|
927
|
+
).fetchone()
|
|
928
|
+
if existing is None:
|
|
929
|
+
conn.execute("""
|
|
930
|
+
INSERT INTO schedules
|
|
931
|
+
(id,name,interval_seconds,callback_name,last_run,next_run,
|
|
932
|
+
enabled,metadata,failure_count,lease_token,
|
|
933
|
+
lease_expires_at,degraded_reason,created_at,updated_at)
|
|
934
|
+
VALUES (?,?,?,?,NULL,?,1,?,0,NULL,NULL,NULL,?,?)
|
|
935
|
+
""", (
|
|
936
|
+
schedule_id, name, interval_seconds, callback_name,
|
|
937
|
+
stamp, metadata_json, stamp, stamp,
|
|
938
|
+
))
|
|
939
|
+
else:
|
|
940
|
+
degraded = str(existing["degraded_reason"] or "")
|
|
941
|
+
recover_callback = (
|
|
942
|
+
degraded.startswith("callback_unregistered:")
|
|
943
|
+
or degraded in {
|
|
944
|
+
"migration_invalid_metadata",
|
|
945
|
+
"migration_invalid_interval",
|
|
946
|
+
"migration_invalid_callback_name",
|
|
947
|
+
}
|
|
948
|
+
)
|
|
949
|
+
conn.execute("""
|
|
950
|
+
UPDATE schedules SET interval_seconds=?,callback_name=?,
|
|
951
|
+
metadata=?,enabled=CASE WHEN ? THEN 1 ELSE enabled END,
|
|
952
|
+
degraded_reason=CASE WHEN ? THEN NULL
|
|
953
|
+
ELSE degraded_reason END,
|
|
954
|
+
updated_at=?
|
|
955
|
+
WHERE name=?
|
|
956
|
+
""", (
|
|
957
|
+
interval_seconds, callback_name, metadata_json,
|
|
958
|
+
1 if recover_callback else 0,
|
|
959
|
+
1 if recover_callback else 0,
|
|
960
|
+
stamp, name,
|
|
961
|
+
))
|
|
962
|
+
row = conn.execute(
|
|
963
|
+
"SELECT * FROM schedules WHERE name=?", (name,),
|
|
964
|
+
).fetchone()
|
|
965
|
+
conn.commit()
|
|
966
|
+
except Exception:
|
|
967
|
+
conn.rollback()
|
|
968
|
+
raise
|
|
969
|
+
return self._row_to_schedule(row)
|
|
970
|
+
|
|
971
|
+
def upsert(self, schedule: TaskSchedule) -> None:
|
|
972
|
+
"""Persist compatibility configuration without rewriting run state.
|
|
973
|
+
|
|
974
|
+
Existing embedded callers may retain a ``TaskSchedule`` object while a
|
|
975
|
+
different scheduler process claims or completes it. On conflict only
|
|
976
|
+
caller-owned definition/configuration fields are updated. The store is
|
|
977
|
+
the sole owner of cadence, terminal state, failures, degradation, and
|
|
978
|
+
exact-token leases after initial insertion.
|
|
979
|
+
"""
|
|
980
|
+
_, metadata_json = _canonical_metadata(schedule.metadata)
|
|
981
|
+
stamp = _iso(self._now())
|
|
982
|
+
with self._connect() as conn:
|
|
983
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
984
|
+
try:
|
|
985
|
+
conn.execute("""
|
|
986
|
+
INSERT INTO schedules
|
|
987
|
+
(id,name,interval_seconds,callback_name,last_run,next_run,
|
|
988
|
+
enabled,metadata,failure_count,lease_token,lease_expires_at,
|
|
989
|
+
degraded_reason,created_at,updated_at)
|
|
990
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
991
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
992
|
+
name=excluded.name,
|
|
993
|
+
interval_seconds=excluded.interval_seconds,
|
|
994
|
+
callback_name=excluded.callback_name,
|
|
995
|
+
enabled=excluded.enabled,
|
|
996
|
+
metadata=excluded.metadata,
|
|
997
|
+
updated_at=excluded.updated_at
|
|
998
|
+
""", (
|
|
999
|
+
schedule.id, schedule.name, schedule.interval_seconds,
|
|
1000
|
+
schedule.callback_name,
|
|
1001
|
+
_iso(schedule.last_run) if schedule.last_run else None,
|
|
1002
|
+
_iso(schedule.next_run), 1 if schedule.enabled else 0,
|
|
1003
|
+
metadata_json, schedule.failure_count, schedule.lease_token,
|
|
1004
|
+
_iso(schedule.lease_expires_at)
|
|
1005
|
+
if schedule.lease_expires_at else None,
|
|
1006
|
+
schedule.degraded_reason,
|
|
1007
|
+
_iso(schedule.created_at) if schedule.created_at else stamp,
|
|
1008
|
+
stamp,
|
|
1009
|
+
))
|
|
1010
|
+
conn.commit()
|
|
1011
|
+
except Exception:
|
|
1012
|
+
conn.rollback()
|
|
1013
|
+
raise
|
|
1014
|
+
|
|
1015
|
+
def get_due(self, now: Optional[datetime] = None) -> List[TaskSchedule]:
|
|
1016
|
+
stamp = _iso(now or self._now())
|
|
1017
|
+
with self._connect() as conn:
|
|
1018
|
+
rows = conn.execute("""
|
|
1019
|
+
SELECT * FROM schedules
|
|
1020
|
+
WHERE enabled=1 AND next_run <= ?
|
|
1021
|
+
AND (lease_token IS NULL OR lease_expires_at <= ?)
|
|
1022
|
+
ORDER BY next_run,name
|
|
1023
|
+
""", (stamp, stamp)).fetchall()
|
|
1024
|
+
return [self._row_to_schedule(row) for row in rows]
|
|
1025
|
+
|
|
1026
|
+
def claim_one_due(
|
|
1027
|
+
self,
|
|
1028
|
+
now: datetime,
|
|
1029
|
+
*,
|
|
1030
|
+
lease_seconds: int,
|
|
1031
|
+
exclude_schedule_ids: Optional[set[str]] = None,
|
|
1032
|
+
) -> Optional[RunClaim]:
|
|
1033
|
+
observed = _utc(now)
|
|
1034
|
+
stamp = _iso(observed)
|
|
1035
|
+
excluded = sorted(exclude_schedule_ids or set())
|
|
1036
|
+
exclusion_sql = ""
|
|
1037
|
+
parameters: List[Any] = [stamp, stamp]
|
|
1038
|
+
if excluded:
|
|
1039
|
+
exclusion_sql = " AND id NOT IN (" + ",".join("?" for _ in excluded) + ")"
|
|
1040
|
+
parameters.extend(excluded)
|
|
1041
|
+
with self._connect() as conn:
|
|
1042
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1043
|
+
try:
|
|
1044
|
+
row = conn.execute(
|
|
1045
|
+
"SELECT * FROM schedules WHERE enabled=1 "
|
|
1046
|
+
"AND next_run <= ? AND (lease_token IS NULL OR "
|
|
1047
|
+
"lease_expires_at <= ?)" + exclusion_sql
|
|
1048
|
+
+ " ORDER BY next_run,name LIMIT 1",
|
|
1049
|
+
parameters,
|
|
1050
|
+
).fetchone()
|
|
1051
|
+
if row is None:
|
|
1052
|
+
conn.commit()
|
|
1053
|
+
return None
|
|
1054
|
+
|
|
1055
|
+
failure_count = int(row["failure_count"] or 0)
|
|
1056
|
+
old_token = str(row["lease_token"] or "")
|
|
1057
|
+
if old_token:
|
|
1058
|
+
expired_attempt = conn.execute(
|
|
1059
|
+
"SELECT * FROM schedule_run_attempts WHERE lease_token=?",
|
|
1060
|
+
(old_token,),
|
|
1061
|
+
).fetchone()
|
|
1062
|
+
if expired_attempt is not None:
|
|
1063
|
+
inserted = self._insert_receipt(
|
|
1064
|
+
conn,
|
|
1065
|
+
attempt=expired_attempt,
|
|
1066
|
+
status="lease_expired",
|
|
1067
|
+
now=observed,
|
|
1068
|
+
result_json=None,
|
|
1069
|
+
error_type="LeaseExpired",
|
|
1070
|
+
error_message="lease expired before terminal receipt",
|
|
1071
|
+
failure_count_after=failure_count + 1,
|
|
1072
|
+
next_run=_parse_time(row["next_run"]),
|
|
1073
|
+
)
|
|
1074
|
+
if inserted:
|
|
1075
|
+
failure_count += 1
|
|
1076
|
+
|
|
1077
|
+
lease_token = uuid.uuid4().hex
|
|
1078
|
+
attempt_id = str(uuid.uuid4())
|
|
1079
|
+
expires = observed + timedelta(seconds=lease_seconds)
|
|
1080
|
+
updated = conn.execute("""
|
|
1081
|
+
UPDATE schedules
|
|
1082
|
+
SET lease_token=?,lease_expires_at=?,failure_count=?,updated_at=?
|
|
1083
|
+
WHERE id=? AND enabled=1 AND next_run <= ?
|
|
1084
|
+
AND (lease_token IS NULL OR lease_expires_at <= ?)
|
|
1085
|
+
""", (
|
|
1086
|
+
lease_token, _iso(expires), failure_count, stamp,
|
|
1087
|
+
row["id"], stamp, stamp,
|
|
1088
|
+
))
|
|
1089
|
+
if updated.rowcount != 1:
|
|
1090
|
+
conn.rollback()
|
|
1091
|
+
return None
|
|
1092
|
+
metadata_text = str(row["metadata"] or "{}")
|
|
1093
|
+
conn.execute("""
|
|
1094
|
+
INSERT INTO schedule_run_attempts
|
|
1095
|
+
(attempt_id,schedule_id,task_name,callback_name,lease_token,
|
|
1096
|
+
claimed_at,lease_expires_at,scheduled_for,
|
|
1097
|
+
failure_count_before,metadata_digest)
|
|
1098
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
1099
|
+
""", (
|
|
1100
|
+
attempt_id, row["id"], row["name"], row["callback_name"],
|
|
1101
|
+
lease_token, stamp, _iso(expires), row["next_run"],
|
|
1102
|
+
failure_count,
|
|
1103
|
+
hashlib.sha256(metadata_text.encode("utf-8")).hexdigest(),
|
|
1104
|
+
))
|
|
1105
|
+
claimed_row = conn.execute(
|
|
1106
|
+
"SELECT * FROM schedules WHERE id=?", (row["id"],),
|
|
1107
|
+
).fetchone()
|
|
1108
|
+
conn.commit()
|
|
1109
|
+
except Exception:
|
|
1110
|
+
conn.rollback()
|
|
1111
|
+
raise
|
|
1112
|
+
return RunClaim(
|
|
1113
|
+
schedule=self._row_to_schedule(claimed_row),
|
|
1114
|
+
attempt_id=attempt_id,
|
|
1115
|
+
lease_token=lease_token,
|
|
1116
|
+
claimed_at=observed,
|
|
1117
|
+
lease_expires_at=expires,
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
@staticmethod
|
|
1121
|
+
def _insert_receipt(
|
|
1122
|
+
conn: sqlite3.Connection,
|
|
1123
|
+
*,
|
|
1124
|
+
attempt: sqlite3.Row,
|
|
1125
|
+
status: str,
|
|
1126
|
+
now: datetime,
|
|
1127
|
+
result_json: Optional[str],
|
|
1128
|
+
error_type: Optional[str],
|
|
1129
|
+
error_message: Optional[str],
|
|
1130
|
+
failure_count_after: int,
|
|
1131
|
+
next_run: Optional[datetime],
|
|
1132
|
+
) -> bool:
|
|
1133
|
+
cursor = conn.execute("""
|
|
1134
|
+
INSERT OR IGNORE INTO schedule_run_receipts
|
|
1135
|
+
(receipt_id,attempt_id,schedule_id,task_name,lease_token,status,
|
|
1136
|
+
finished_at,result_json,error_type,error_message,
|
|
1137
|
+
failure_count_after,next_run)
|
|
1138
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
|
1139
|
+
""", (
|
|
1140
|
+
str(uuid.uuid4()), attempt["attempt_id"], attempt["schedule_id"],
|
|
1141
|
+
attempt["task_name"], attempt["lease_token"], status[:64],
|
|
1142
|
+
_iso(now), result_json,
|
|
1143
|
+
_safe_text(error_type, 128) if error_type else None,
|
|
1144
|
+
_safe_text(error_message, 1000) if error_message else None,
|
|
1145
|
+
max(0, int(failure_count_after)),
|
|
1146
|
+
_iso(next_run) if next_run else None,
|
|
1147
|
+
))
|
|
1148
|
+
return cursor.rowcount == 1
|
|
1149
|
+
|
|
1150
|
+
def _completion_rows(
|
|
1151
|
+
self,
|
|
1152
|
+
conn: sqlite3.Connection,
|
|
1153
|
+
claim: RunClaim,
|
|
1154
|
+
now: datetime,
|
|
1155
|
+
) -> tuple[Optional[sqlite3.Row], Optional[sqlite3.Row], bool]:
|
|
1156
|
+
schedule = conn.execute(
|
|
1157
|
+
"SELECT * FROM schedules WHERE id=?", (claim.schedule.id,),
|
|
1158
|
+
).fetchone()
|
|
1159
|
+
attempt = conn.execute(
|
|
1160
|
+
"SELECT * FROM schedule_run_attempts WHERE attempt_id=?",
|
|
1161
|
+
(claim.attempt_id,),
|
|
1162
|
+
).fetchone()
|
|
1163
|
+
owns = bool(
|
|
1164
|
+
schedule is not None
|
|
1165
|
+
and attempt is not None
|
|
1166
|
+
and schedule["lease_token"] == claim.lease_token
|
|
1167
|
+
and attempt["schedule_id"] == claim.schedule.id
|
|
1168
|
+
and attempt["lease_token"] == claim.lease_token
|
|
1169
|
+
and str(schedule["lease_expires_at"] or "") > _iso(now)
|
|
1170
|
+
)
|
|
1171
|
+
return schedule, attempt, owns
|
|
1172
|
+
|
|
1173
|
+
def _record_lost_or_expired(
|
|
1174
|
+
self,
|
|
1175
|
+
conn: sqlite3.Connection,
|
|
1176
|
+
claim: RunClaim,
|
|
1177
|
+
schedule: Optional[sqlite3.Row],
|
|
1178
|
+
attempt: Optional[sqlite3.Row],
|
|
1179
|
+
now: datetime,
|
|
1180
|
+
) -> None:
|
|
1181
|
+
if (
|
|
1182
|
+
attempt is None
|
|
1183
|
+
or attempt["schedule_id"] != claim.schedule.id
|
|
1184
|
+
or attempt["lease_token"] != claim.lease_token
|
|
1185
|
+
):
|
|
1186
|
+
return
|
|
1187
|
+
expired_while_owned = bool(
|
|
1188
|
+
schedule is not None
|
|
1189
|
+
and schedule["lease_token"] == claim.lease_token
|
|
1190
|
+
)
|
|
1191
|
+
failure_count = int(schedule["failure_count"] or 0) if schedule else 0
|
|
1192
|
+
status = "lease_expired" if expired_while_owned else "lease_lost"
|
|
1193
|
+
inserted = self._insert_receipt(
|
|
1194
|
+
conn,
|
|
1195
|
+
attempt=attempt,
|
|
1196
|
+
status=status,
|
|
1197
|
+
now=now,
|
|
1198
|
+
result_json=None,
|
|
1199
|
+
error_type="LeaseExpired" if expired_while_owned else "LeaseLost",
|
|
1200
|
+
error_message=(
|
|
1201
|
+
"lease expired before terminal receipt"
|
|
1202
|
+
if expired_while_owned
|
|
1203
|
+
else "exact lease token no longer owns schedule"
|
|
1204
|
+
),
|
|
1205
|
+
failure_count_after=failure_count + (1 if expired_while_owned else 0),
|
|
1206
|
+
next_run=_parse_time(schedule["next_run"]) if schedule else None,
|
|
1207
|
+
)
|
|
1208
|
+
if expired_while_owned and inserted:
|
|
1209
|
+
conn.execute("""
|
|
1210
|
+
UPDATE schedules SET lease_token=NULL,lease_expires_at=NULL,
|
|
1211
|
+
failure_count=?,updated_at=?
|
|
1212
|
+
WHERE id=? AND lease_token=?
|
|
1213
|
+
""", (
|
|
1214
|
+
failure_count + 1, _iso(now), claim.schedule.id,
|
|
1215
|
+
claim.lease_token,
|
|
1216
|
+
))
|
|
1217
|
+
|
|
1218
|
+
def _complete_success_with_projection(
|
|
1219
|
+
self,
|
|
1220
|
+
claim: RunClaim,
|
|
1221
|
+
result: Any,
|
|
1222
|
+
*,
|
|
1223
|
+
now: datetime,
|
|
1224
|
+
outcome: str = "success",
|
|
1225
|
+
) -> tuple[bool, Any]:
|
|
1226
|
+
"""Complete a claim whose callback returned normally.
|
|
1227
|
+
|
|
1228
|
+
``outcome="skipped"`` records that the callback declined to do work:
|
|
1229
|
+
the schedule still advances and the lease is released, but the run is
|
|
1230
|
+
receipted as ``skipped`` and neither resets ``failure_count`` nor
|
|
1231
|
+
clears ``degraded_reason`` — a skip is not a success.
|
|
1232
|
+
"""
|
|
1233
|
+
if outcome not in ("success", "skipped"):
|
|
1234
|
+
raise ValueError("outcome must be 'success' or 'skipped'")
|
|
1235
|
+
observed = _utc(now)
|
|
1236
|
+
# Projection is deliberately outside the SQLite writer transaction.
|
|
1237
|
+
# Even an unexpectedly expensive diagnostic value cannot hold the
|
|
1238
|
+
# scheduler's global write lock while it is rendered.
|
|
1239
|
+
result_json = _receipt_json(result) if result is not None else None
|
|
1240
|
+
projected_result = (
|
|
1241
|
+
json.loads(result_json) if result_json is not None else None
|
|
1242
|
+
)
|
|
1243
|
+
with self._connect() as conn:
|
|
1244
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1245
|
+
try:
|
|
1246
|
+
schedule, attempt, owns = self._completion_rows(
|
|
1247
|
+
conn, claim, observed,
|
|
1248
|
+
)
|
|
1249
|
+
if not owns:
|
|
1250
|
+
self._record_lost_or_expired(
|
|
1251
|
+
conn, claim, schedule, attempt, observed,
|
|
1252
|
+
)
|
|
1253
|
+
conn.commit()
|
|
1254
|
+
return False, projected_result
|
|
1255
|
+
interval = int(schedule["interval_seconds"])
|
|
1256
|
+
next_run = observed + timedelta(seconds=interval)
|
|
1257
|
+
failure_count = int(schedule["failure_count"] or 0)
|
|
1258
|
+
failure_count_after = failure_count if outcome == "skipped" else 0
|
|
1259
|
+
self._insert_receipt(
|
|
1260
|
+
conn,
|
|
1261
|
+
attempt=attempt,
|
|
1262
|
+
status=outcome,
|
|
1263
|
+
now=observed,
|
|
1264
|
+
result_json=result_json,
|
|
1265
|
+
error_type=None,
|
|
1266
|
+
error_message=None,
|
|
1267
|
+
failure_count_after=failure_count_after,
|
|
1268
|
+
next_run=next_run,
|
|
1269
|
+
)
|
|
1270
|
+
if outcome == "skipped":
|
|
1271
|
+
updated = conn.execute("""
|
|
1272
|
+
UPDATE schedules SET last_run=?,next_run=?,
|
|
1273
|
+
lease_token=NULL,lease_expires_at=NULL,updated_at=?
|
|
1274
|
+
WHERE id=? AND lease_token=? AND lease_expires_at > ?
|
|
1275
|
+
""", (
|
|
1276
|
+
_iso(observed), _iso(next_run), _iso(observed),
|
|
1277
|
+
claim.schedule.id, claim.lease_token, _iso(observed),
|
|
1278
|
+
))
|
|
1279
|
+
else:
|
|
1280
|
+
updated = conn.execute("""
|
|
1281
|
+
UPDATE schedules SET last_run=?,next_run=?,failure_count=0,
|
|
1282
|
+
lease_token=NULL,lease_expires_at=NULL,
|
|
1283
|
+
degraded_reason=NULL,updated_at=?
|
|
1284
|
+
WHERE id=? AND lease_token=? AND lease_expires_at > ?
|
|
1285
|
+
""", (
|
|
1286
|
+
_iso(observed), _iso(next_run), _iso(observed),
|
|
1287
|
+
claim.schedule.id, claim.lease_token, _iso(observed),
|
|
1288
|
+
))
|
|
1289
|
+
if updated.rowcount != 1:
|
|
1290
|
+
raise RuntimeError("lease ownership changed during success commit")
|
|
1291
|
+
conn.commit()
|
|
1292
|
+
return True, projected_result
|
|
1293
|
+
except Exception:
|
|
1294
|
+
conn.rollback()
|
|
1295
|
+
raise
|
|
1296
|
+
|
|
1297
|
+
def complete_success(self, claim: RunClaim, result: Any, *, now: datetime) -> bool:
|
|
1298
|
+
accepted, _projection = self._complete_success_with_projection(
|
|
1299
|
+
claim, result, now=now,
|
|
1300
|
+
)
|
|
1301
|
+
return accepted
|
|
1302
|
+
|
|
1303
|
+
def complete_failure(
|
|
1304
|
+
self,
|
|
1305
|
+
claim: RunClaim,
|
|
1306
|
+
error: BaseException,
|
|
1307
|
+
*,
|
|
1308
|
+
now: datetime,
|
|
1309
|
+
base_backoff_seconds: int,
|
|
1310
|
+
max_backoff_seconds: int,
|
|
1311
|
+
) -> bool:
|
|
1312
|
+
observed = _utc(now)
|
|
1313
|
+
error_type, error_message = _safe_exception_projection(error)
|
|
1314
|
+
with self._connect() as conn:
|
|
1315
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1316
|
+
try:
|
|
1317
|
+
schedule, attempt, owns = self._completion_rows(
|
|
1318
|
+
conn, claim, observed,
|
|
1319
|
+
)
|
|
1320
|
+
if not owns:
|
|
1321
|
+
self._record_lost_or_expired(
|
|
1322
|
+
conn, claim, schedule, attempt, observed,
|
|
1323
|
+
)
|
|
1324
|
+
conn.commit()
|
|
1325
|
+
return False
|
|
1326
|
+
failures = int(schedule["failure_count"] or 0) + 1
|
|
1327
|
+
exponent = min(30, failures - 1)
|
|
1328
|
+
backoff = min(
|
|
1329
|
+
max_backoff_seconds,
|
|
1330
|
+
base_backoff_seconds * (2 ** exponent),
|
|
1331
|
+
)
|
|
1332
|
+
next_run = observed + timedelta(seconds=backoff)
|
|
1333
|
+
self._insert_receipt(
|
|
1334
|
+
conn,
|
|
1335
|
+
attempt=attempt,
|
|
1336
|
+
status="error",
|
|
1337
|
+
now=observed,
|
|
1338
|
+
result_json=None,
|
|
1339
|
+
error_type=error_type,
|
|
1340
|
+
error_message=error_message,
|
|
1341
|
+
failure_count_after=failures,
|
|
1342
|
+
next_run=next_run,
|
|
1343
|
+
)
|
|
1344
|
+
updated = conn.execute("""
|
|
1345
|
+
UPDATE schedules SET next_run=?,failure_count=?,
|
|
1346
|
+
lease_token=NULL,lease_expires_at=NULL,updated_at=?
|
|
1347
|
+
WHERE id=? AND lease_token=? AND lease_expires_at > ?
|
|
1348
|
+
""", (
|
|
1349
|
+
_iso(next_run), failures, _iso(observed),
|
|
1350
|
+
claim.schedule.id, claim.lease_token, _iso(observed),
|
|
1351
|
+
))
|
|
1352
|
+
if updated.rowcount != 1:
|
|
1353
|
+
raise RuntimeError("lease ownership changed during failure commit")
|
|
1354
|
+
conn.commit()
|
|
1355
|
+
return True
|
|
1356
|
+
except Exception:
|
|
1357
|
+
conn.rollback()
|
|
1358
|
+
raise
|
|
1359
|
+
|
|
1360
|
+
def degrade_unknown_callback(
|
|
1361
|
+
self,
|
|
1362
|
+
claim: RunClaim,
|
|
1363
|
+
*,
|
|
1364
|
+
now: datetime,
|
|
1365
|
+
) -> bool:
|
|
1366
|
+
observed = _utc(now)
|
|
1367
|
+
reason = f"callback_unregistered:{claim.schedule.callback_name}"[:500]
|
|
1368
|
+
with self._connect() as conn:
|
|
1369
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1370
|
+
try:
|
|
1371
|
+
schedule, attempt, owns = self._completion_rows(
|
|
1372
|
+
conn, claim, observed,
|
|
1373
|
+
)
|
|
1374
|
+
if not owns:
|
|
1375
|
+
self._record_lost_or_expired(
|
|
1376
|
+
conn, claim, schedule, attempt, observed,
|
|
1377
|
+
)
|
|
1378
|
+
conn.commit()
|
|
1379
|
+
return False
|
|
1380
|
+
failures = int(schedule["failure_count"] or 0) + 1
|
|
1381
|
+
self._insert_receipt(
|
|
1382
|
+
conn,
|
|
1383
|
+
attempt=attempt,
|
|
1384
|
+
status="degraded",
|
|
1385
|
+
now=observed,
|
|
1386
|
+
result_json=None,
|
|
1387
|
+
error_type="CallbackUnregistered",
|
|
1388
|
+
error_message=reason,
|
|
1389
|
+
failure_count_after=failures,
|
|
1390
|
+
next_run=_parse_time(schedule["next_run"]),
|
|
1391
|
+
)
|
|
1392
|
+
updated = conn.execute("""
|
|
1393
|
+
UPDATE schedules SET enabled=0,degraded_reason=?,
|
|
1394
|
+
failure_count=?,lease_token=NULL,lease_expires_at=NULL,
|
|
1395
|
+
updated_at=?
|
|
1396
|
+
WHERE id=? AND lease_token=? AND lease_expires_at > ?
|
|
1397
|
+
""", (
|
|
1398
|
+
reason, failures, _iso(observed), claim.schedule.id,
|
|
1399
|
+
claim.lease_token, _iso(observed),
|
|
1400
|
+
))
|
|
1401
|
+
if updated.rowcount != 1:
|
|
1402
|
+
raise RuntimeError("lease ownership changed during degradation")
|
|
1403
|
+
conn.commit()
|
|
1404
|
+
return True
|
|
1405
|
+
except Exception:
|
|
1406
|
+
conn.rollback()
|
|
1407
|
+
raise
|
|
1408
|
+
|
|
1409
|
+
def list_all(self) -> List[TaskSchedule]:
|
|
1410
|
+
with self._connect() as conn:
|
|
1411
|
+
rows = conn.execute(
|
|
1412
|
+
"SELECT * FROM schedules ORDER BY next_run,name"
|
|
1413
|
+
).fetchall()
|
|
1414
|
+
return [self._row_to_schedule(row) for row in rows]
|
|
1415
|
+
|
|
1416
|
+
def set_enabled(self, schedule_id: str, enabled: bool) -> bool:
|
|
1417
|
+
stamp = _iso(self._now())
|
|
1418
|
+
with self._connect() as conn:
|
|
1419
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1420
|
+
try:
|
|
1421
|
+
row = conn.execute(
|
|
1422
|
+
"SELECT * FROM schedules WHERE id=?", (schedule_id,),
|
|
1423
|
+
).fetchone()
|
|
1424
|
+
if row is None:
|
|
1425
|
+
conn.commit()
|
|
1426
|
+
return False
|
|
1427
|
+
# Enabling is an idempotent configuration operation regardless
|
|
1428
|
+
# of the row's current enabled flag. A compatibility writer may
|
|
1429
|
+
# have disabled a row after another process claimed it; the
|
|
1430
|
+
# exact-token lease still belongs to that original attempt and
|
|
1431
|
+
# must survive the disabled-to-enabled transition.
|
|
1432
|
+
if enabled:
|
|
1433
|
+
conn.execute("""
|
|
1434
|
+
UPDATE schedules SET enabled=1,degraded_reason=NULL,
|
|
1435
|
+
updated_at=? WHERE id=?
|
|
1436
|
+
""", (stamp, schedule_id))
|
|
1437
|
+
conn.commit()
|
|
1438
|
+
return True
|
|
1439
|
+
if not enabled and row["lease_token"]:
|
|
1440
|
+
attempt = conn.execute(
|
|
1441
|
+
"SELECT * FROM schedule_run_attempts WHERE lease_token=?",
|
|
1442
|
+
(row["lease_token"],),
|
|
1443
|
+
).fetchone()
|
|
1444
|
+
if attempt is not None:
|
|
1445
|
+
self._insert_receipt(
|
|
1446
|
+
conn,
|
|
1447
|
+
attempt=attempt,
|
|
1448
|
+
status="disabled",
|
|
1449
|
+
now=self._now(),
|
|
1450
|
+
result_json=None,
|
|
1451
|
+
error_type="ScheduleDisabled",
|
|
1452
|
+
error_message="schedule disabled while leased",
|
|
1453
|
+
failure_count_after=int(row["failure_count"] or 0),
|
|
1454
|
+
next_run=_parse_time(row["next_run"]),
|
|
1455
|
+
)
|
|
1456
|
+
conn.execute("""
|
|
1457
|
+
UPDATE schedules SET enabled=0,lease_token=NULL,
|
|
1458
|
+
lease_expires_at=NULL,updated_at=? WHERE id=?
|
|
1459
|
+
""", (stamp, schedule_id))
|
|
1460
|
+
conn.commit()
|
|
1461
|
+
return True
|
|
1462
|
+
except Exception:
|
|
1463
|
+
conn.rollback()
|
|
1464
|
+
raise
|
|
1465
|
+
|
|
1466
|
+
def delete(self, schedule_id: str) -> bool:
|
|
1467
|
+
"""Delete an idle schedule, refusing to orphan an open run attempt.
|
|
1468
|
+
|
|
1469
|
+
A live (or otherwise unreceipted) claim must first reach a terminal
|
|
1470
|
+
receipt. ``set_enabled(..., False)`` can explicitly cancel a leased
|
|
1471
|
+
schedule with a ``disabled`` receipt before a subsequent delete.
|
|
1472
|
+
"""
|
|
1473
|
+
with self._connect() as conn:
|
|
1474
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1475
|
+
try:
|
|
1476
|
+
row = conn.execute(
|
|
1477
|
+
"SELECT lease_token FROM schedules WHERE id=?",
|
|
1478
|
+
(schedule_id,),
|
|
1479
|
+
).fetchone()
|
|
1480
|
+
if row is None:
|
|
1481
|
+
conn.commit()
|
|
1482
|
+
return False
|
|
1483
|
+
open_attempt = conn.execute("""
|
|
1484
|
+
SELECT 1
|
|
1485
|
+
FROM schedule_run_attempts AS attempt
|
|
1486
|
+
LEFT JOIN schedule_run_receipts AS receipt
|
|
1487
|
+
ON receipt.attempt_id=attempt.attempt_id
|
|
1488
|
+
WHERE attempt.schedule_id=? AND receipt.attempt_id IS NULL
|
|
1489
|
+
LIMIT 1
|
|
1490
|
+
""", (schedule_id,)).fetchone()
|
|
1491
|
+
if row["lease_token"] is not None or open_attempt is not None:
|
|
1492
|
+
conn.commit()
|
|
1493
|
+
return False
|
|
1494
|
+
cursor = conn.execute(
|
|
1495
|
+
"DELETE FROM schedules WHERE id=?", (schedule_id,),
|
|
1496
|
+
)
|
|
1497
|
+
conn.commit()
|
|
1498
|
+
return cursor.rowcount > 0
|
|
1499
|
+
except Exception:
|
|
1500
|
+
conn.rollback()
|
|
1501
|
+
raise
|
|
1502
|
+
|
|
1503
|
+
def list_attempts(
|
|
1504
|
+
self, schedule_id: Optional[str] = None, *, limit: int = 100,
|
|
1505
|
+
) -> List[dict]:
|
|
1506
|
+
bounded = max(1, min(1000, int(limit)))
|
|
1507
|
+
query = "SELECT * FROM schedule_run_attempts"
|
|
1508
|
+
params: List[Any] = []
|
|
1509
|
+
if schedule_id:
|
|
1510
|
+
query += " WHERE schedule_id=?"
|
|
1511
|
+
params.append(schedule_id)
|
|
1512
|
+
query += " ORDER BY claimed_at,rowid LIMIT ?"
|
|
1513
|
+
params.append(bounded)
|
|
1514
|
+
with self._connect() as conn:
|
|
1515
|
+
return [dict(row) for row in conn.execute(query, params).fetchall()]
|
|
1516
|
+
|
|
1517
|
+
def list_receipts(
|
|
1518
|
+
self, schedule_id: Optional[str] = None, *, limit: int = 100,
|
|
1519
|
+
) -> List[dict]:
|
|
1520
|
+
bounded = max(1, min(1000, int(limit)))
|
|
1521
|
+
query = "SELECT * FROM schedule_run_receipts"
|
|
1522
|
+
params: List[Any] = []
|
|
1523
|
+
if schedule_id:
|
|
1524
|
+
query += " WHERE schedule_id=?"
|
|
1525
|
+
params.append(schedule_id)
|
|
1526
|
+
query += " ORDER BY finished_at,rowid LIMIT ?"
|
|
1527
|
+
params.append(bounded)
|
|
1528
|
+
with self._connect() as conn:
|
|
1529
|
+
rows = conn.execute(query, params).fetchall()
|
|
1530
|
+
result = []
|
|
1531
|
+
for row in rows:
|
|
1532
|
+
item = dict(row)
|
|
1533
|
+
if item.get("result_json") is not None:
|
|
1534
|
+
item["result"] = json.loads(item["result_json"])
|
|
1535
|
+
result.append(item)
|
|
1536
|
+
return result
|
|
1537
|
+
|
|
1538
|
+
def health_snapshot(self, now: datetime) -> dict:
|
|
1539
|
+
stamp = _iso(now)
|
|
1540
|
+
with self._connect() as conn:
|
|
1541
|
+
counts = conn.execute("""
|
|
1542
|
+
SELECT COUNT(*) AS total,
|
|
1543
|
+
SUM(CASE WHEN enabled=1 THEN 1 ELSE 0 END) AS enabled,
|
|
1544
|
+
SUM(CASE WHEN degraded_reason IS NOT NULL THEN 1 ELSE 0 END)
|
|
1545
|
+
AS degraded,
|
|
1546
|
+
SUM(CASE WHEN enabled=1 AND failure_count >= ? THEN 1
|
|
1547
|
+
ELSE 0 END)
|
|
1548
|
+
AS failing,
|
|
1549
|
+
SUM(CASE WHEN lease_token IS NOT NULL
|
|
1550
|
+
AND lease_expires_at > ? THEN 1 ELSE 0 END)
|
|
1551
|
+
AS leased,
|
|
1552
|
+
SUM(CASE WHEN lease_token IS NOT NULL
|
|
1553
|
+
AND lease_expires_at <= ? THEN 1 ELSE 0 END)
|
|
1554
|
+
AS expired,
|
|
1555
|
+
SUM(CASE WHEN enabled=1 AND next_run <= ?
|
|
1556
|
+
AND (lease_token IS NULL
|
|
1557
|
+
OR lease_expires_at <= ?)
|
|
1558
|
+
THEN 1 ELSE 0 END)
|
|
1559
|
+
AS due
|
|
1560
|
+
FROM schedules
|
|
1561
|
+
""", (SUSTAINED_FAILURE_THRESHOLD, stamp, stamp, stamp, stamp)).fetchone()
|
|
1562
|
+
attempts = conn.execute(
|
|
1563
|
+
"SELECT COUNT(*) FROM schedule_run_attempts"
|
|
1564
|
+
).fetchone()[0]
|
|
1565
|
+
receipts = conn.execute(
|
|
1566
|
+
"SELECT COUNT(*) FROM schedule_run_receipts"
|
|
1567
|
+
).fetchone()[0]
|
|
1568
|
+
degraded_rows = conn.execute("""
|
|
1569
|
+
SELECT name,degraded_reason FROM schedules
|
|
1570
|
+
WHERE degraded_reason IS NOT NULL ORDER BY name LIMIT 20
|
|
1571
|
+
""").fetchall()
|
|
1572
|
+
return {
|
|
1573
|
+
"total_schedules": int(counts["total"] or 0),
|
|
1574
|
+
"enabled_schedules": int(counts["enabled"] or 0),
|
|
1575
|
+
"degraded_schedules": int(counts["degraded"] or 0),
|
|
1576
|
+
"failing_schedules": int(counts["failing"] or 0),
|
|
1577
|
+
"active_leases": int(counts["leased"] or 0),
|
|
1578
|
+
"expired_leases": int(counts["expired"] or 0),
|
|
1579
|
+
"due_schedules": int(counts["due"] or 0),
|
|
1580
|
+
"run_attempts": int(attempts),
|
|
1581
|
+
"terminal_receipts": int(receipts),
|
|
1582
|
+
"open_attempts": max(0, int(attempts) - int(receipts)),
|
|
1583
|
+
"degraded": [dict(row) for row in degraded_rows],
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
@staticmethod
|
|
1587
|
+
def _row_to_schedule(row: sqlite3.Row) -> TaskSchedule:
|
|
1588
|
+
try:
|
|
1589
|
+
metadata = json.loads(row["metadata"] or "{}")
|
|
1590
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
1591
|
+
metadata = {"legacy_metadata_invalid": True}
|
|
1592
|
+
if not isinstance(metadata, dict):
|
|
1593
|
+
metadata = {"legacy_metadata_invalid": True}
|
|
1594
|
+
return TaskSchedule(
|
|
1595
|
+
id=str(row["id"]),
|
|
1596
|
+
name=str(row["name"]),
|
|
1597
|
+
interval_seconds=int(row["interval_seconds"]),
|
|
1598
|
+
callback_name=str(row["callback_name"]),
|
|
1599
|
+
last_run=_parse_time(row["last_run"]),
|
|
1600
|
+
next_run=_parse_time(row["next_run"]),
|
|
1601
|
+
enabled=bool(row["enabled"]),
|
|
1602
|
+
metadata=metadata,
|
|
1603
|
+
failure_count=int(row["failure_count"] or 0),
|
|
1604
|
+
lease_token=str(row["lease_token"]) if row["lease_token"] else None,
|
|
1605
|
+
lease_expires_at=_parse_time(row["lease_expires_at"]),
|
|
1606
|
+
degraded_reason=(
|
|
1607
|
+
str(row["degraded_reason"])
|
|
1608
|
+
if row["degraded_reason"] else None
|
|
1609
|
+
),
|
|
1610
|
+
created_at=_parse_time(row["created_at"]),
|
|
1611
|
+
updated_at=_parse_time(row["updated_at"]),
|
|
1612
|
+
)
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
class AutonomyScheduler:
|
|
1616
|
+
"""Periodic scheduler with durable exact-token leases and receipts."""
|
|
1617
|
+
|
|
1618
|
+
def __init__(
|
|
1619
|
+
self,
|
|
1620
|
+
db_path: str,
|
|
1621
|
+
*,
|
|
1622
|
+
clock: Optional[Callable[[], datetime]] = None,
|
|
1623
|
+
lease_seconds: int = 300,
|
|
1624
|
+
base_backoff_seconds: int = 5,
|
|
1625
|
+
max_backoff_seconds: int = 3600,
|
|
1626
|
+
) -> None:
|
|
1627
|
+
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
|
1628
|
+
self._lease_seconds = self._bounded_positive(
|
|
1629
|
+
lease_seconds, "lease_seconds", maximum=86_400,
|
|
1630
|
+
)
|
|
1631
|
+
self._base_backoff_seconds = self._bounded_positive(
|
|
1632
|
+
base_backoff_seconds, "base_backoff_seconds", maximum=86_400,
|
|
1633
|
+
)
|
|
1634
|
+
self._max_backoff_seconds = self._bounded_positive(
|
|
1635
|
+
max_backoff_seconds, "max_backoff_seconds", maximum=604_800,
|
|
1636
|
+
)
|
|
1637
|
+
if self._max_backoff_seconds < self._base_backoff_seconds:
|
|
1638
|
+
raise ValueError(
|
|
1639
|
+
"max_backoff_seconds must be >= base_backoff_seconds"
|
|
1640
|
+
)
|
|
1641
|
+
self._store = ScheduleStore(db_path, clock=self._clock)
|
|
1642
|
+
self._callbacks: Dict[str, Callable] = {}
|
|
1643
|
+
self._last_tick_at: Optional[str] = None
|
|
1644
|
+
self._last_error: Optional[str] = None
|
|
1645
|
+
self._tick_count = 0
|
|
1646
|
+
|
|
1647
|
+
@staticmethod
|
|
1648
|
+
def _bounded_positive(value: Any, field: str, *, maximum: int) -> int:
|
|
1649
|
+
if (
|
|
1650
|
+
isinstance(value, bool)
|
|
1651
|
+
or not isinstance(value, int)
|
|
1652
|
+
or not 1 <= value <= maximum
|
|
1653
|
+
):
|
|
1654
|
+
raise ValueError(f"{field} must be an integer from 1-{maximum}")
|
|
1655
|
+
return value
|
|
1656
|
+
|
|
1657
|
+
def _now(self) -> datetime:
|
|
1658
|
+
return _utc(self._clock())
|
|
1659
|
+
|
|
1660
|
+
def register(
|
|
1661
|
+
self,
|
|
1662
|
+
name: str,
|
|
1663
|
+
callback: Union[Callable, Callable[..., Coroutine]],
|
|
1664
|
+
interval_seconds: int,
|
|
1665
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1666
|
+
) -> str:
|
|
1667
|
+
"""Register or update one stable-name periodic task.
|
|
1668
|
+
|
|
1669
|
+
The public call signature is unchanged. Existing last/next run state
|
|
1670
|
+
and operator-disabled state survive re-registration. A schedule that
|
|
1671
|
+
was degraded only because its callback was absent is recovered.
|
|
1672
|
+
"""
|
|
1673
|
+
task_name = _validate_name(name)
|
|
1674
|
+
interval = _validate_interval(interval_seconds)
|
|
1675
|
+
if not callable(callback):
|
|
1676
|
+
raise ValueError("callback must be callable")
|
|
1677
|
+
_, metadata_json = _canonical_metadata(metadata)
|
|
1678
|
+
schedule = self._store.register_schedule(
|
|
1679
|
+
name=task_name,
|
|
1680
|
+
interval_seconds=interval,
|
|
1681
|
+
callback_name=task_name,
|
|
1682
|
+
metadata_json=metadata_json,
|
|
1683
|
+
now=self._now(),
|
|
1684
|
+
)
|
|
1685
|
+
self._callbacks[task_name] = callback
|
|
1686
|
+
logger.debug("Registered schedule: %s (every %ds)", task_name, interval)
|
|
1687
|
+
return schedule.id
|
|
1688
|
+
|
|
1689
|
+
async def tick(self) -> List[dict]:
|
|
1690
|
+
"""Atomically claim and execute due tasks once for this tick."""
|
|
1691
|
+
results: List[dict] = []
|
|
1692
|
+
claimed_ids: set[str] = set()
|
|
1693
|
+
try:
|
|
1694
|
+
for _ in range(100):
|
|
1695
|
+
claim = self._store.claim_one_due(
|
|
1696
|
+
self._now(),
|
|
1697
|
+
lease_seconds=self._lease_seconds,
|
|
1698
|
+
exclude_schedule_ids=claimed_ids,
|
|
1699
|
+
)
|
|
1700
|
+
if claim is None:
|
|
1701
|
+
break
|
|
1702
|
+
task = claim.schedule
|
|
1703
|
+
claimed_ids.add(task.id)
|
|
1704
|
+
callback = self._callbacks.get(task.callback_name)
|
|
1705
|
+
if callback is None:
|
|
1706
|
+
reason = f"callback_unregistered:{task.callback_name}"
|
|
1707
|
+
accepted = self._store.degrade_unknown_callback(
|
|
1708
|
+
claim, now=self._now(),
|
|
1709
|
+
)
|
|
1710
|
+
results.append({
|
|
1711
|
+
"task": task.name,
|
|
1712
|
+
"status": "degraded" if accepted else "ambiguous",
|
|
1713
|
+
"error": reason if accepted else "lease_lost",
|
|
1714
|
+
})
|
|
1715
|
+
logger.warning(
|
|
1716
|
+
"Disabled degraded schedule '%s': %s", task.name, reason,
|
|
1717
|
+
)
|
|
1718
|
+
continue
|
|
1719
|
+
try:
|
|
1720
|
+
result = callback()
|
|
1721
|
+
if _is_hook_free_awaitable(result):
|
|
1722
|
+
result = await result
|
|
1723
|
+
except Exception as exc:
|
|
1724
|
+
error_type, error_message = _safe_exception_projection(exc)
|
|
1725
|
+
accepted = self._store.complete_failure(
|
|
1726
|
+
claim,
|
|
1727
|
+
exc,
|
|
1728
|
+
now=self._now(),
|
|
1729
|
+
base_backoff_seconds=self._base_backoff_seconds,
|
|
1730
|
+
max_backoff_seconds=self._max_backoff_seconds,
|
|
1731
|
+
)
|
|
1732
|
+
results.append({
|
|
1733
|
+
"task": task.name,
|
|
1734
|
+
"status": "error" if accepted else "ambiguous",
|
|
1735
|
+
"error": error_message if accepted else "lease_lost",
|
|
1736
|
+
})
|
|
1737
|
+
logger.warning(
|
|
1738
|
+
"Scheduled task failed: %s — %s: %s",
|
|
1739
|
+
task.name, error_type, error_message,
|
|
1740
|
+
)
|
|
1741
|
+
continue
|
|
1742
|
+
# A callback that declined to run ({"status": "skipped"}) is
|
|
1743
|
+
# receipted as a skip, never counted as a success.
|
|
1744
|
+
skipped = (
|
|
1745
|
+
isinstance(result, dict)
|
|
1746
|
+
and result.get("status") == "skipped"
|
|
1747
|
+
)
|
|
1748
|
+
accepted, projected_result = (
|
|
1749
|
+
self._store._complete_success_with_projection(
|
|
1750
|
+
claim, result, now=self._now(),
|
|
1751
|
+
outcome="skipped" if skipped else "success",
|
|
1752
|
+
)
|
|
1753
|
+
)
|
|
1754
|
+
if not accepted:
|
|
1755
|
+
status = "ambiguous"
|
|
1756
|
+
else:
|
|
1757
|
+
status = "skipped" if skipped else "ok"
|
|
1758
|
+
results.append({
|
|
1759
|
+
"task": task.name,
|
|
1760
|
+
"status": status,
|
|
1761
|
+
**(
|
|
1762
|
+
{"result": projected_result}
|
|
1763
|
+
if accepted
|
|
1764
|
+
else {"error": "lease_lost"}
|
|
1765
|
+
),
|
|
1766
|
+
})
|
|
1767
|
+
if accepted:
|
|
1768
|
+
logger.debug(
|
|
1769
|
+
"Scheduled task %s: %s",
|
|
1770
|
+
"skipped" if skipped else "completed", task.name,
|
|
1771
|
+
)
|
|
1772
|
+
self._last_tick_at = _iso(self._now())
|
|
1773
|
+
self._last_error = None
|
|
1774
|
+
self._tick_count += 1
|
|
1775
|
+
return results
|
|
1776
|
+
except Exception as exc:
|
|
1777
|
+
self._last_tick_at = _iso(self._now())
|
|
1778
|
+
error_type, error_message = _safe_exception_projection(exc)
|
|
1779
|
+
self._last_error = f"{error_type}:{error_message}"[:1000]
|
|
1780
|
+
self._tick_count += 1
|
|
1781
|
+
raise
|
|
1782
|
+
|
|
1783
|
+
def list_schedules(self) -> List[TaskSchedule]:
|
|
1784
|
+
return self._store.list_all()
|
|
1785
|
+
|
|
1786
|
+
def list_run_attempts(
|
|
1787
|
+
self, schedule_id: Optional[str] = None, *, limit: int = 100,
|
|
1788
|
+
) -> List[dict]:
|
|
1789
|
+
return self._store.list_attempts(schedule_id, limit=limit)
|
|
1790
|
+
|
|
1791
|
+
def list_run_receipts(
|
|
1792
|
+
self, schedule_id: Optional[str] = None, *, limit: int = 100,
|
|
1793
|
+
) -> List[dict]:
|
|
1794
|
+
return self._store.list_receipts(schedule_id, limit=limit)
|
|
1795
|
+
|
|
1796
|
+
@property
|
|
1797
|
+
def health(self) -> dict:
|
|
1798
|
+
snapshot = self._store.health_snapshot(self._now())
|
|
1799
|
+
snapshot.update({
|
|
1800
|
+
"healthy": (
|
|
1801
|
+
self._last_error is None
|
|
1802
|
+
and snapshot["degraded_schedules"] == 0
|
|
1803
|
+
and snapshot["expired_leases"] == 0
|
|
1804
|
+
# A schedule failing every run forever must not read healthy.
|
|
1805
|
+
and snapshot["failing_schedules"] == 0
|
|
1806
|
+
),
|
|
1807
|
+
"last_tick_at": self._last_tick_at,
|
|
1808
|
+
"last_error": self._last_error,
|
|
1809
|
+
"tick_count": self._tick_count,
|
|
1810
|
+
})
|
|
1811
|
+
return snapshot
|
|
1812
|
+
|
|
1813
|
+
def enable(self, schedule_id: str) -> bool:
|
|
1814
|
+
return self._store.set_enabled(schedule_id, True)
|
|
1815
|
+
|
|
1816
|
+
def disable(self, schedule_id: str) -> bool:
|
|
1817
|
+
return self._store.set_enabled(schedule_id, False)
|
|
1818
|
+
|
|
1819
|
+
|
|
1820
|
+
__all__ = [
|
|
1821
|
+
"AutonomyScheduler", "RunClaim", "ScheduleStore", "TaskSchedule",
|
|
1822
|
+
]
|