steerable-agent-runtime 0.6.56__tar.gz → 0.6.58__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/PKG-INFO +1 -1
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/pyproject.toml +1 -1
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/calibration.py +75 -16
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/errors.py +5 -1
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/storage/__init__.py +2 -1
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/storage/sqlite_store.py +74 -13
- steerable_agent_runtime-0.6.58/src/steerable_agent_runtime/storage/write_lease.py +173 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime.egg-info/PKG-INFO +1 -1
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_calibration.py +40 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_write_lease.py +77 -2
- steerable_agent_runtime-0.6.56/src/steerable_agent_runtime/storage/write_lease.py +0 -134
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/README.md +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/setup.cfg +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/__init__.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/antihallucination.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/approval.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/approval_policy.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/ask_user.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/branch.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/cache_control.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/compaction.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/compaction_policy.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/config.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/default.harness.json +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/default.harness.yaml +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/delegation_gate.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/gateway_catalog.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/handoff.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/harness.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/harness_spec.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/history.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/hooks.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/llm/__init__.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/llm/anthropic_native.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/llm/compat.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/llm/errors.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/llm/google_genai.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/llm/openai_compat.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/llm/openai_responses.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/llm/parts.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/llm/presets.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/llm/system_proxy.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/loop.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/maintenance.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/mcp.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/mcp_server.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/model_catalog.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/model_info.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/model_resolve.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/native_bridge.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/observation_aging.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/orchestration.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/otel.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/plugins.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/pool.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/pricing.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/pseudo.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/recording.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/reminders.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/replay.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/resume.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/retry.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/sandboxed.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/skills.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/spill.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/storage/in_memory.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/storage/sqlalchemy_store.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/subagent.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/todo.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/tokens.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/tool_presentation.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/tool_schema.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/tool_search.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/tools.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/tracing.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/transport/__init__.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/transport/fastapi_sse.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/transport/stdio_jsonrpc.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime/world_state.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime.egg-info/SOURCES.txt +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime.egg-info/dependency_links.txt +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime.egg-info/requires.txt +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/src/steerable_agent_runtime.egg-info/top_level.txt +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_antihallucination.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_approval.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_approval_policy.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_ask_user.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_branch.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_cache_control.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_cache_instrumentation.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_compaction.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_compaction_policy.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_compaction_strategy.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_config.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_content_parts.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_delegation_gate.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_error_taxonomy.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_fragment_bounds.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_gateway_catalog.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_golden.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_handoff.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_harness.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_harness_spec.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_history.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_history_persistence.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_hooks.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_in_memory_storage.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_llm_gemini_wire.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_llm_responses_wire.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_llm_stream_e2e.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_llm_wire_helpers.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_long_session.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_loop.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_loop_cancellation.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_loop_replay.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_loop_sandbox_event.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_loop_terminal.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_maintenance.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_mcp.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_mcp_server.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_model_catalog.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_model_equivalence.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_model_info.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_model_resolve.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_native_default.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_observation_aging.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_orchestration.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_otel.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_parallel_tools.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_plugins.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_provider_compat.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_provider_presets.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_pseudo.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_pyo3_api_surface.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_recording.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_reminders.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_replay_crosslang.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_resume.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_retry_hooks.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_safety_gate.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_sandboxed.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_skills.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_soft_timeout.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_spill.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_sqlite_storage.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_steer.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_storage_contract.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_stream_strip.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_subagent.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_system_proxy.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_todo.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_todo_gate.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_tokens.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_tool_exposure.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_tool_hygiene.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_tool_presentation.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_tool_router.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_tool_schema.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_tool_timeout.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_trace_recorder.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_transport_jsonrpc.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_transport_sse.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_usage_attribution.py +0 -0
- {steerable_agent_runtime-0.6.56 → steerable_agent_runtime-0.6.58}/tests/test_world_state.py +0 -0
|
@@ -25,11 +25,14 @@ from __future__ import annotations
|
|
|
25
25
|
import json
|
|
26
26
|
import os
|
|
27
27
|
import tempfile
|
|
28
|
+
import time
|
|
28
29
|
from collections.abc import AsyncIterator, Iterable, Sequence
|
|
29
|
-
from dataclasses import asdict, dataclass
|
|
30
|
+
from dataclasses import asdict, dataclass, replace
|
|
30
31
|
from typing import Any
|
|
31
32
|
|
|
33
|
+
from .errors import StoreAlreadyOwnedError
|
|
32
34
|
from .llm import LLMMessage, LLMProvider, LLMStreamChunk, LLMUsage
|
|
35
|
+
from .storage.write_lease import WriteLease, acquire_write_lease
|
|
33
36
|
from .tokens import estimate_text_tokens, estimate_tokens, register_model_factor
|
|
34
37
|
|
|
35
38
|
#: Requests needed before a derived factor is trusted and auto-registered.
|
|
@@ -70,6 +73,7 @@ class UsageCalibration:
|
|
|
70
73
|
self.min_samples = min_samples
|
|
71
74
|
self.auto_register = auto_register
|
|
72
75
|
self.models: dict[str, ModelCalibration] = {}
|
|
76
|
+
self._persisted: dict[str, ModelCalibration] = {}
|
|
73
77
|
|
|
74
78
|
def record(
|
|
75
79
|
self,
|
|
@@ -142,29 +146,84 @@ class UsageCalibration:
|
|
|
142
146
|
return cal
|
|
143
147
|
|
|
144
148
|
def save(self, path: str) -> None:
|
|
145
|
-
"""
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
"""Merge this process's new samples into ``path`` and write atomically.
|
|
150
|
+
|
|
151
|
+
The exclusive lease covers the reload and the rename, so two sidecars
|
|
152
|
+
recording different requests both keep their samples. Samples already
|
|
153
|
+
written by this object are not added again.
|
|
154
|
+
"""
|
|
155
|
+
lease = _wait_for_file_lease(path)
|
|
149
156
|
try:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
raise
|
|
157
|
+
disk = UsageCalibration.load(
|
|
158
|
+
path, min_samples=self.min_samples, auto_register=False
|
|
159
|
+
)
|
|
160
|
+
self.models = _merge_models(disk.models, self.models, self._persisted)
|
|
161
|
+
self._persisted = _snapshot(self.models)
|
|
162
|
+
_atomic_write(path, self.to_dict())
|
|
163
|
+
finally:
|
|
164
|
+
lease.release()
|
|
159
165
|
|
|
160
166
|
@classmethod
|
|
161
167
|
def load(cls, path: str, **kwargs: Any) -> UsageCalibration:
|
|
162
168
|
"""Load aggregates from ``path``; missing/corrupt file → empty."""
|
|
163
169
|
try:
|
|
164
170
|
with open(path, encoding="utf-8") as fh:
|
|
165
|
-
|
|
171
|
+
cal = cls.from_dict(json.load(fh), **kwargs)
|
|
166
172
|
except (OSError, ValueError):
|
|
167
|
-
|
|
173
|
+
cal = cls(**kwargs)
|
|
174
|
+
cal._persisted = _snapshot(cal.models)
|
|
175
|
+
return cal
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _snapshot(models: dict[str, ModelCalibration]) -> dict[str, ModelCalibration]:
|
|
179
|
+
return {name: replace(entry) for name, entry in models.items()}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _merge_models(
|
|
183
|
+
disk: dict[str, ModelCalibration],
|
|
184
|
+
local: dict[str, ModelCalibration],
|
|
185
|
+
persisted: dict[str, ModelCalibration],
|
|
186
|
+
) -> dict[str, ModelCalibration]:
|
|
187
|
+
merged: dict[str, ModelCalibration] = {}
|
|
188
|
+
for name in set(disk) | set(local):
|
|
189
|
+
on_disk = disk.get(name, ModelCalibration())
|
|
190
|
+
current = local.get(name, ModelCalibration())
|
|
191
|
+
base = persisted.get(name, ModelCalibration())
|
|
192
|
+
merged[name] = ModelCalibration(
|
|
193
|
+
requests=on_disk.requests + (current.requests - base.requests),
|
|
194
|
+
est_prompt=on_disk.est_prompt + (current.est_prompt - base.est_prompt),
|
|
195
|
+
obs_prompt=on_disk.obs_prompt + (current.obs_prompt - base.obs_prompt),
|
|
196
|
+
est_completion=on_disk.est_completion + (current.est_completion - base.est_completion),
|
|
197
|
+
obs_completion=on_disk.obs_completion + (current.obs_completion - base.obs_completion),
|
|
198
|
+
)
|
|
199
|
+
return merged
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _wait_for_file_lease(path: str, timeout_s: float = 5.0) -> WriteLease:
|
|
203
|
+
deadline = time.monotonic() + timeout_s
|
|
204
|
+
while True:
|
|
205
|
+
try:
|
|
206
|
+
return acquire_write_lease(path)
|
|
207
|
+
except StoreAlreadyOwnedError:
|
|
208
|
+
if time.monotonic() >= deadline:
|
|
209
|
+
raise
|
|
210
|
+
time.sleep(0.01)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _atomic_write(path: str, payload: dict[str, Any]) -> None:
|
|
214
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
215
|
+
os.makedirs(directory, exist_ok=True)
|
|
216
|
+
fd, tmp = tempfile.mkstemp(dir=directory, suffix=".tmp")
|
|
217
|
+
try:
|
|
218
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
219
|
+
json.dump(payload, fh)
|
|
220
|
+
os.replace(tmp, path)
|
|
221
|
+
except BaseException:
|
|
222
|
+
try:
|
|
223
|
+
os.unlink(tmp)
|
|
224
|
+
except OSError:
|
|
225
|
+
pass
|
|
226
|
+
raise
|
|
168
227
|
|
|
169
228
|
|
|
170
229
|
@dataclass
|
|
@@ -18,8 +18,12 @@ class StorageError(RuntimeError):
|
|
|
18
18
|
"""Persistence layer failure."""
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
class StorageUpgradeBlockedError(StorageError):
|
|
22
|
+
"""The database is newer than this process, or a migration cannot take the exclusive lock."""
|
|
23
|
+
|
|
24
|
+
|
|
21
25
|
class StoreAlreadyOwnedError(StorageError):
|
|
22
|
-
"""Another process already holds the
|
|
26
|
+
"""Another process already holds the exclusive lease for this database."""
|
|
23
27
|
|
|
24
28
|
def __init__(self, path: str) -> None:
|
|
25
29
|
super().__init__(
|
|
@@ -117,7 +117,7 @@ class StorageAdapter(Protocol):
|
|
|
117
117
|
|
|
118
118
|
from .in_memory import InMemoryStorage # noqa: E402
|
|
119
119
|
from .sqlite_store import SqliteStorage # noqa: E402
|
|
120
|
-
from .write_lease import acquire_write_lease, lock_path_for_db # noqa: E402
|
|
120
|
+
from .write_lease import acquire_shared_lease, acquire_write_lease, lock_path_for_db # noqa: E402
|
|
121
121
|
from ..errors import StoreAlreadyOwnedError # noqa: E402
|
|
122
122
|
|
|
123
123
|
try:
|
|
@@ -132,6 +132,7 @@ __all__ = [
|
|
|
132
132
|
"SqliteStorage",
|
|
133
133
|
"SqlAlchemyStorage",
|
|
134
134
|
"StoreAlreadyOwnedError",
|
|
135
|
+
"acquire_shared_lease",
|
|
135
136
|
"acquire_write_lease",
|
|
136
137
|
"lock_path_for_db",
|
|
137
138
|
]
|
|
@@ -14,13 +14,13 @@ duplicated as real columns with indexes, so session enumeration and the
|
|
|
14
14
|
resume tail-scan are indexed lookups, never a table scan over JSON.
|
|
15
15
|
|
|
16
16
|
Concurrency: like ``InMemoryStorage``, all mutations run under one asyncio
|
|
17
|
-
lock (safe under concurrent ``await`` in a single loop).
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
lock (safe under concurrent ``await`` in a single loop). Across processes
|
|
18
|
+
the database uses WAL, ``busy_timeout``, and a shared lease on the sibling
|
|
19
|
+
``*.lock`` file, so two sidecars can write different sessions. Schema
|
|
20
|
+
migration takes that file exclusively and fails when another process has
|
|
21
|
+
the database open. A ``user_version`` newer than this process refuses to
|
|
22
|
+
open. Process death releases the kernel lock; the lock file is never
|
|
23
|
+
deleted.
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
@@ -40,8 +40,11 @@ from steerable_agent_protocol.generated import (
|
|
|
40
40
|
TraceSpan,
|
|
41
41
|
)
|
|
42
42
|
|
|
43
|
-
from ..errors import StorageError
|
|
44
|
-
from .write_lease import WriteLease, acquire_write_lease
|
|
43
|
+
from ..errors import StorageError, StorageUpgradeBlockedError, StoreAlreadyOwnedError
|
|
44
|
+
from .write_lease import WriteLease, acquire_shared_lease, acquire_write_lease
|
|
45
|
+
|
|
46
|
+
SCHEMA_VERSION = 1
|
|
47
|
+
_BUSY_TIMEOUT_MS = 5000
|
|
45
48
|
|
|
46
49
|
_SCHEMA = """
|
|
47
50
|
PRAGMA journal_mode = WAL;
|
|
@@ -107,17 +110,75 @@ def _dump(model: Any) -> str:
|
|
|
107
110
|
return json.dumps(model.model_dump(mode="json"), ensure_ascii=False)
|
|
108
111
|
|
|
109
112
|
|
|
113
|
+
def _is_memory(path: str) -> bool:
|
|
114
|
+
return path == ":memory:" or path.startswith("file::memory:")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _connect(path: str) -> sqlite3.Connection:
|
|
118
|
+
db = sqlite3.connect(path, timeout=_BUSY_TIMEOUT_MS / 1000, check_same_thread=False)
|
|
119
|
+
db.row_factory = sqlite3.Row
|
|
120
|
+
db.execute(f"PRAGMA busy_timeout = {_BUSY_TIMEOUT_MS}")
|
|
121
|
+
db.execute("PRAGMA journal_mode = WAL")
|
|
122
|
+
db.execute("PRAGMA synchronous = NORMAL")
|
|
123
|
+
return db
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _read_user_version(path: str) -> int:
|
|
127
|
+
db = sqlite3.connect(path, timeout=_BUSY_TIMEOUT_MS / 1000)
|
|
128
|
+
try:
|
|
129
|
+
row = db.execute("PRAGMA user_version").fetchone()
|
|
130
|
+
return int(row[0]) if row else 0
|
|
131
|
+
finally:
|
|
132
|
+
db.close()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _apply_schema(db: sqlite3.Connection) -> None:
|
|
136
|
+
db.executescript(_SCHEMA)
|
|
137
|
+
db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
138
|
+
db.commit()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _open_lease(path: str) -> WriteLease:
|
|
142
|
+
version = _read_user_version(path)
|
|
143
|
+
if version > SCHEMA_VERSION:
|
|
144
|
+
raise StorageUpgradeBlockedError(
|
|
145
|
+
f"sidecar database version {version} is newer than this program ({SCHEMA_VERSION})"
|
|
146
|
+
)
|
|
147
|
+
if version < SCHEMA_VERSION:
|
|
148
|
+
try:
|
|
149
|
+
exclusive = acquire_write_lease(path)
|
|
150
|
+
except StoreAlreadyOwnedError as exc:
|
|
151
|
+
version = _read_user_version(path)
|
|
152
|
+
if version < SCHEMA_VERSION:
|
|
153
|
+
raise StorageUpgradeBlockedError(
|
|
154
|
+
"sidecar database needs an upgrade but another process has it open"
|
|
155
|
+
) from exc
|
|
156
|
+
else:
|
|
157
|
+
try:
|
|
158
|
+
db = _connect(path)
|
|
159
|
+
try:
|
|
160
|
+
_apply_schema(db)
|
|
161
|
+
finally:
|
|
162
|
+
db.close()
|
|
163
|
+
finally:
|
|
164
|
+
exclusive.release()
|
|
165
|
+
return acquire_shared_lease(path)
|
|
166
|
+
|
|
167
|
+
|
|
110
168
|
class SqliteStorage:
|
|
111
169
|
"""Stdlib-sqlite3 StorageAdapter. See module docstring for the contract."""
|
|
112
170
|
|
|
113
171
|
def __init__(self, path: str) -> None:
|
|
114
172
|
self._path = path
|
|
115
173
|
self._lock = asyncio.Lock()
|
|
116
|
-
self._lease
|
|
174
|
+
self._lease = WriteLease._unheld()
|
|
175
|
+
if _is_memory(path):
|
|
176
|
+
self._db = _connect(path)
|
|
177
|
+
_apply_schema(self._db)
|
|
178
|
+
return
|
|
179
|
+
self._lease = _open_lease(path)
|
|
117
180
|
try:
|
|
118
|
-
self._db =
|
|
119
|
-
self._db.row_factory = sqlite3.Row
|
|
120
|
-
self._db.executescript(_SCHEMA)
|
|
181
|
+
self._db = _connect(path)
|
|
121
182
|
except BaseException:
|
|
122
183
|
self._lease.release()
|
|
123
184
|
raise
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Cross-process leases for one sqlite database file.
|
|
2
|
+
|
|
3
|
+
Several processes may hold a shared lease on the sibling ``*.lock`` file
|
|
4
|
+
(``sessions.db`` → ``sessions.lock``) while they have the database open.
|
|
5
|
+
Schema migration takes an exclusive lease and fails immediately when any
|
|
6
|
+
other process holds the file. Process death releases the kernel lock.
|
|
7
|
+
There is no TTL that could steal from a live process. The lock file is
|
|
8
|
+
never deleted: it keeps a stable inode for later lockers.
|
|
9
|
+
|
|
10
|
+
POSIX uses non-blocking ``fcntl.flock`` (``LOCK_SH`` to register an open
|
|
11
|
+
database, ``LOCK_EX`` to migrate). Windows uses the lock file's share mode,
|
|
12
|
+
the same rule as the Rust sidecar: shared openers allow read and write
|
|
13
|
+
sharing, and an exclusive opener allows none. SQLite byte-range locks do
|
|
14
|
+
not see this file, so only this flock/share-mode pair coordinates Python
|
|
15
|
+
and Rust.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import errno
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from ..errors import StoreAlreadyOwnedError
|
|
27
|
+
|
|
28
|
+
_LOCK_ATTEMPTS = 3
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def lock_path_for_db(db_path: str | os.PathLike[str]) -> Path:
|
|
32
|
+
"""Sibling lock file: ``sessions.db`` → ``sessions.lock``."""
|
|
33
|
+
return Path(db_path).expanduser().resolve().with_suffix(".lock")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_memory(db_path: str) -> bool:
|
|
37
|
+
return db_path == ":memory:" or db_path.startswith("file::memory:")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def acquire_shared_lease(db_path: str | os.PathLike[str]) -> "WriteLease":
|
|
41
|
+
"""Register that this process has ``db_path`` open.
|
|
42
|
+
|
|
43
|
+
Other shared holders succeed. An exclusive holder (a migration, or an
|
|
44
|
+
older sidecar that still locks the whole database) fails loud.
|
|
45
|
+
``:memory:`` databases have no file and take no lease.
|
|
46
|
+
"""
|
|
47
|
+
return _acquire(db_path, shared=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def acquire_write_lease(db_path: str | os.PathLike[str]) -> "WriteLease":
|
|
51
|
+
"""Acquire the exclusive lease for ``db_path``.
|
|
52
|
+
|
|
53
|
+
Used for schema migration. A shared or exclusive holder fails loud.
|
|
54
|
+
``:memory:`` databases have no file and take no lease.
|
|
55
|
+
"""
|
|
56
|
+
return _acquire(db_path, shared=False)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _acquire(db_path: str | os.PathLike[str], *, shared: bool) -> "WriteLease":
|
|
60
|
+
raw = os.fspath(db_path)
|
|
61
|
+
if _is_memory(raw):
|
|
62
|
+
return WriteLease._unheld()
|
|
63
|
+
lock_path = lock_path_for_db(raw)
|
|
64
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
if sys.platform == "win32":
|
|
66
|
+
return WriteLease._acquire_win32(lock_path, shared=shared)
|
|
67
|
+
return WriteLease._acquire_posix(lock_path, shared=shared)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class WriteLease:
|
|
71
|
+
"""Held kernel lock. ``release`` closes the descriptor/handle; the file stays."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, *, _fd: int | None, _handle: Any, _lock_path: Path | None) -> None:
|
|
74
|
+
self._fd = _fd
|
|
75
|
+
self._handle = _handle
|
|
76
|
+
self._lock_path = _lock_path
|
|
77
|
+
self._released = False
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def _unheld(cls) -> "WriteLease":
|
|
81
|
+
return cls(_fd=None, _handle=None, _lock_path=None)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def _acquire_posix(cls, lock_path: Path, *, shared: bool) -> "WriteLease":
|
|
85
|
+
import fcntl
|
|
86
|
+
|
|
87
|
+
mode = fcntl.LOCK_SH if shared else fcntl.LOCK_EX
|
|
88
|
+
for _ in range(_LOCK_ATTEMPTS):
|
|
89
|
+
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
90
|
+
try:
|
|
91
|
+
fcntl.flock(fd, mode | fcntl.LOCK_NB)
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
os.close(fd)
|
|
94
|
+
if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK, errno.EACCES):
|
|
95
|
+
raise StoreAlreadyOwnedError(str(lock_path)) from exc
|
|
96
|
+
raise
|
|
97
|
+
held = os.fstat(fd)
|
|
98
|
+
try:
|
|
99
|
+
current = os.stat(lock_path)
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
os.close(fd)
|
|
102
|
+
continue
|
|
103
|
+
if (held.st_ino, held.st_dev) == (current.st_ino, current.st_dev):
|
|
104
|
+
return cls(_fd=fd, _handle=None, _lock_path=lock_path)
|
|
105
|
+
os.close(fd)
|
|
106
|
+
raise StoreAlreadyOwnedError(str(lock_path))
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def _acquire_win32(cls, lock_path: Path, *, shared: bool) -> "WriteLease":
|
|
110
|
+
import ctypes
|
|
111
|
+
from ctypes import wintypes
|
|
112
|
+
|
|
113
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
114
|
+
create_file = kernel32.CreateFileW
|
|
115
|
+
create_file.argtypes = [
|
|
116
|
+
wintypes.LPCWSTR,
|
|
117
|
+
wintypes.DWORD,
|
|
118
|
+
wintypes.DWORD,
|
|
119
|
+
wintypes.LPVOID,
|
|
120
|
+
wintypes.DWORD,
|
|
121
|
+
wintypes.DWORD,
|
|
122
|
+
wintypes.HANDLE,
|
|
123
|
+
]
|
|
124
|
+
create_file.restype = wintypes.HANDLE
|
|
125
|
+
generic_read = 0x80000000
|
|
126
|
+
generic_write = 0x40000000
|
|
127
|
+
file_share_read = 0x00000001
|
|
128
|
+
file_share_write = 0x00000002
|
|
129
|
+
open_always = 4
|
|
130
|
+
file_attribute_normal = 0x80
|
|
131
|
+
error_sharing = 32
|
|
132
|
+
error_lock = 33
|
|
133
|
+
invalid = wintypes.HANDLE(-1).value
|
|
134
|
+
share = file_share_read | file_share_write if shared else 0
|
|
135
|
+
handle = create_file(
|
|
136
|
+
str(lock_path),
|
|
137
|
+
generic_read | generic_write,
|
|
138
|
+
share,
|
|
139
|
+
None,
|
|
140
|
+
open_always,
|
|
141
|
+
file_attribute_normal,
|
|
142
|
+
None,
|
|
143
|
+
)
|
|
144
|
+
if handle is None or int(handle) == int(invalid):
|
|
145
|
+
err = ctypes.get_last_error()
|
|
146
|
+
if err in (error_sharing, error_lock):
|
|
147
|
+
raise StoreAlreadyOwnedError(str(lock_path))
|
|
148
|
+
raise ctypes.WinError(err)
|
|
149
|
+
return cls(_fd=None, _handle=handle, _lock_path=lock_path)
|
|
150
|
+
|
|
151
|
+
def release(self) -> None:
|
|
152
|
+
if self._released:
|
|
153
|
+
return
|
|
154
|
+
self._released = True
|
|
155
|
+
if self._fd is not None:
|
|
156
|
+
os.close(self._fd)
|
|
157
|
+
self._fd = None
|
|
158
|
+
if self._handle is not None:
|
|
159
|
+
import ctypes
|
|
160
|
+
from ctypes import wintypes
|
|
161
|
+
|
|
162
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
163
|
+
close_handle = kernel32.CloseHandle
|
|
164
|
+
close_handle.argtypes = [wintypes.HANDLE]
|
|
165
|
+
close_handle.restype = wintypes.BOOL
|
|
166
|
+
close_handle(self._handle)
|
|
167
|
+
self._handle = None
|
|
168
|
+
|
|
169
|
+
def __enter__(self) -> "WriteLease":
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def __exit__(self, *exc: object) -> None:
|
|
173
|
+
self.release()
|
|
@@ -88,6 +88,46 @@ class TestUsageCalibration:
|
|
|
88
88
|
assert loaded.factor("m") == pytest.approx(320 / 400)
|
|
89
89
|
assert loaded.models["m"].obs_completion == 40
|
|
90
90
|
|
|
91
|
+
def test_two_processes_keep_both_models(self, tmp_path) -> None:
|
|
92
|
+
import subprocess
|
|
93
|
+
import sys
|
|
94
|
+
|
|
95
|
+
path = tmp_path / "token-calibration.json"
|
|
96
|
+
script = r"""
|
|
97
|
+
import sys
|
|
98
|
+
import time
|
|
99
|
+
from pathlib import Path
|
|
100
|
+
|
|
101
|
+
from steerable_agent_runtime import UsageCalibration
|
|
102
|
+
|
|
103
|
+
path, model, ready, peer = sys.argv[1:5]
|
|
104
|
+
cal = UsageCalibration.load(path, min_samples=1, auto_register=False)
|
|
105
|
+
Path(ready).write_text("ready", encoding="utf-8")
|
|
106
|
+
deadline = time.monotonic() + 5
|
|
107
|
+
while time.monotonic() < deadline and not Path(peer).exists():
|
|
108
|
+
time.sleep(0.02)
|
|
109
|
+
cal.record(model, est_prompt=100, obs_prompt=80)
|
|
110
|
+
cal.save(path)
|
|
111
|
+
"""
|
|
112
|
+
ready_a = tmp_path / "a.ready"
|
|
113
|
+
ready_b = tmp_path / "b.ready"
|
|
114
|
+
proc_a = subprocess.Popen(
|
|
115
|
+
[sys.executable, "-c", script, str(path), "model-a", str(ready_a), str(ready_b)]
|
|
116
|
+
)
|
|
117
|
+
proc_b = subprocess.Popen(
|
|
118
|
+
[sys.executable, "-c", script, str(path), "model-b", str(ready_b), str(ready_a)]
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
assert proc_a.wait(timeout=15) == 0
|
|
122
|
+
assert proc_b.wait(timeout=15) == 0
|
|
123
|
+
finally:
|
|
124
|
+
proc_a.kill()
|
|
125
|
+
proc_b.kill()
|
|
126
|
+
loaded = UsageCalibration.load(str(path), min_samples=1, auto_register=False)
|
|
127
|
+
assert set(loaded.models) == {"model-a", "model-b"}
|
|
128
|
+
assert loaded.models["model-a"].requests == 1
|
|
129
|
+
assert loaded.models["model-b"].requests == 1
|
|
130
|
+
|
|
91
131
|
def test_load_missing_or_corrupt_returns_empty(self, tmp_path) -> None:
|
|
92
132
|
assert UsageCalibration.load(str(tmp_path / "nope.json")).models == {}
|
|
93
133
|
bad = tmp_path / "bad.json"
|
|
@@ -7,8 +7,10 @@ import sys
|
|
|
7
7
|
import time
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
+
import sqlite3
|
|
11
|
+
|
|
10
12
|
import pytest
|
|
11
|
-
from steerable_agent_runtime.errors import StoreAlreadyOwnedError
|
|
13
|
+
from steerable_agent_runtime.errors import StorageUpgradeBlockedError, StoreAlreadyOwnedError
|
|
12
14
|
from steerable_agent_runtime.storage import SqliteStorage
|
|
13
15
|
from steerable_agent_runtime.storage.write_lease import (
|
|
14
16
|
acquire_write_lease,
|
|
@@ -62,7 +64,7 @@ def test_second_process_fails_loud(tmp_path: Path) -> None:
|
|
|
62
64
|
_wait_held(holder, flag)
|
|
63
65
|
with pytest.raises(StoreAlreadyOwnedError, match="already owned"):
|
|
64
66
|
acquire_write_lease(db)
|
|
65
|
-
with pytest.raises(
|
|
67
|
+
with pytest.raises(StorageUpgradeBlockedError, match="another process"):
|
|
66
68
|
SqliteStorage(str(db))
|
|
67
69
|
assert lock_path_for_db(db).is_file()
|
|
68
70
|
finally:
|
|
@@ -87,6 +89,79 @@ def test_successor_opens_after_holder_is_killed(tmp_path: Path) -> None:
|
|
|
87
89
|
assert lock_path_for_db(db).is_file()
|
|
88
90
|
|
|
89
91
|
|
|
92
|
+
_WRITER = r"""
|
|
93
|
+
import asyncio
|
|
94
|
+
import sys
|
|
95
|
+
|
|
96
|
+
from steerable_agent_protocol.generated import AgentSession
|
|
97
|
+
from steerable_agent_runtime.storage import SqliteStorage
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def main() -> None:
|
|
101
|
+
store = SqliteStorage(sys.argv[1])
|
|
102
|
+
await store.upsert_session(
|
|
103
|
+
AgentSession(
|
|
104
|
+
sessionId=sys.argv[2],
|
|
105
|
+
userId="u1",
|
|
106
|
+
chatId=sys.argv[2],
|
|
107
|
+
currentStage="chat",
|
|
108
|
+
isActive=True,
|
|
109
|
+
createdAt="2026-08-30T00:00:00Z",
|
|
110
|
+
updatedAt="2026-08-30T00:00:00Z",
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
store.close()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
asyncio.run(main())
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_two_processes_write_different_sessions(tmp_path: Path) -> None:
|
|
121
|
+
"""Two sidecars can write different sessions in one database."""
|
|
122
|
+
import asyncio
|
|
123
|
+
|
|
124
|
+
from steerable_agent_protocol.generated import AgentSession
|
|
125
|
+
|
|
126
|
+
db = tmp_path / "sessions.db"
|
|
127
|
+
store = SqliteStorage(str(db))
|
|
128
|
+
try:
|
|
129
|
+
asyncio.run(
|
|
130
|
+
store.upsert_session(
|
|
131
|
+
AgentSession(
|
|
132
|
+
sessionId="s1",
|
|
133
|
+
userId="u1",
|
|
134
|
+
chatId="c1",
|
|
135
|
+
currentStage="chat",
|
|
136
|
+
isActive=True,
|
|
137
|
+
createdAt="2026-08-30T00:00:00Z",
|
|
138
|
+
updatedAt="2026-08-30T00:00:00Z",
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
child = subprocess.run(
|
|
143
|
+
[sys.executable, "-c", _WRITER, str(db), "s2"],
|
|
144
|
+
capture_output=True,
|
|
145
|
+
text=True,
|
|
146
|
+
timeout=20,
|
|
147
|
+
)
|
|
148
|
+
assert child.returncode == 0, child.stderr
|
|
149
|
+
loaded = asyncio.run(store.get_session("s2"))
|
|
150
|
+
assert loaded is not None and loaded.sessionId == "s2"
|
|
151
|
+
finally:
|
|
152
|
+
store.close()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_refuses_a_database_newer_than_this_program(tmp_path: Path) -> None:
|
|
156
|
+
db = tmp_path / "sessions.db"
|
|
157
|
+
conn = sqlite3.connect(db)
|
|
158
|
+
conn.execute("PRAGMA user_version = 99")
|
|
159
|
+
conn.commit()
|
|
160
|
+
conn.close()
|
|
161
|
+
with pytest.raises(StorageUpgradeBlockedError, match="newer than this program"):
|
|
162
|
+
SqliteStorage(str(db))
|
|
163
|
+
|
|
164
|
+
|
|
90
165
|
def test_release_does_not_delete_lock_file(tmp_path: Path) -> None:
|
|
91
166
|
db = tmp_path / "sessions.db"
|
|
92
167
|
lease = acquire_write_lease(db)
|