activegraph 1.6.0__tar.gz → 1.7.0__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.
- {activegraph-1.6.0/activegraph.egg-info → activegraph-1.7.0}/PKG-INFO +1 -1
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/__init__.py +1 -1
- activegraph-1.7.0/activegraph/sandbox/__init__.py +440 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/sandbox/_child.py +24 -5
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/retention.py +55 -3
- {activegraph-1.6.0 → activegraph-1.7.0/activegraph.egg-info}/PKG-INFO +1 -1
- {activegraph-1.6.0 → activegraph-1.7.0}/pyproject.toml +1 -1
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_compaction.py +35 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_sandbox_trial.py +233 -0
- activegraph-1.6.0/activegraph/sandbox/__init__.py +0 -257
- {activegraph-1.6.0 → activegraph-1.7.0}/LICENSE +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/NOTICE +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/README.md +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/__main__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/_signature.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/behaviors/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/behaviors/base.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/behaviors/decorators.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/cli/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/cli/main.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/cli/quickstart.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/cli/renderers.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/clock.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/event.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/graph.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/graph_store.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/ids.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/patch.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/view.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/frame.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/anthropic.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/cache.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/embedding.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/native.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/openai.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/parsing.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/prompt.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/provider.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/recorded.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/types.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/wire.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/logging.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/metrics.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/migration.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/otel.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/prometheus.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/status.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/behaviors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/fixtures/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/fixtures/companies.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/object_types.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/prompts/document_researcher.md +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/prompts/memo_synthesizer.md +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/prompts/question_generator.md +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/prompts/risk_identifier.md +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/settings.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/tools.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/loader.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/manifest.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/scaffold.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/policy.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/_live.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/behavior_graph.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/budget.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/config_errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/diff.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/exec_errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/patterns.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/promote.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/queue.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/registration_errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/registry.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/runtime.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/scheduler.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/view_builder.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/base.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/conformance.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/falkordb.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/graph_conformance.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/memory.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/postgres.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/serde.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/sqlite.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/url.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/base.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/cache.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/context.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/decorators.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/graph_query.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/recorded.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/web_fetch.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/trace/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/trace/causal.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/trace/printer.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph.egg-info/SOURCES.txt +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph.egg-info/dependency_links.txt +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph.egg-info/entry_points.txt +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph.egg-info/requires.txt +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/activegraph.egg-info/top_level.txt +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/setup.cfg +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_activate_after.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_causal_cross_tool.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_cli.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_cli_docs_flags.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_clock.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_diff.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_diligence_pack.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_diligence_with_tools.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_disable_pack.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_doc_links.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_doc_python_snippets.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_doc_site_reachable.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_embedding_provider.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_errors_format.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_event.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_falkordb_store.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_fork.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_graph.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_graph_store.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_ids.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_license.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_anthropic.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_behavior.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_budget.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_causal.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_claim_extraction.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_default_model.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_determinism.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_failure.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_native_structured_output.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_openai.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_prompt.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_provider_fixtures.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_replay.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_tool_loop.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_trace.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_trace_snapshot.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_types.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_wire.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llms_txt.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_manifest_warning_tier.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_migration.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_observability_logging.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_observability_metrics.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_operate_example.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_pack_manifest.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_pack_scaffold.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_packs.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_patch.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_pattern_matcher.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_pattern_parser.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_pattern_subscriptions.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_persistence.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_postgres_store.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_promote.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_quickstart.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_quickstart_snapshot.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_reason_codes_docs.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_registration_validation.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_replay.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_replay_trace_snapshot.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_requeue_unfired.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_resume_example.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_runtime.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_runtime_status.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_serde.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_store_conformance.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_store_url.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_tool_replay.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_tool_trace_snapshot.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_tools.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_trace.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_trace_accessors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_tutorial_snippets.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_v1_0_1_patches.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_v1_0_3_behavior_failed_ux.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_v1_0_3_tool_multiturn.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_version_sync.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_view.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_wheel_completeness.py +0 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Subprocess fork-trial isolation. CONTRACT v1.5 #1.
|
|
2
|
+
|
|
3
|
+
Implements ``trial-isolation-design.md``: candidate pack code trials
|
|
4
|
+
run in a **fresh interpreter**, against a fork of the parent run,
|
|
5
|
+
materialized from artifacts pinned by the bundle hash — so the parent
|
|
6
|
+
process is out of the blast radius of the T1 accident class (runaway
|
|
7
|
+
memory/CPU, corrupted in-process state) and the bytes trialed are the
|
|
8
|
+
bytes the proposal recorded.
|
|
9
|
+
|
|
10
|
+
The division of authority is the design's:
|
|
11
|
+
|
|
12
|
+
* **The parent forks.** The child receives only the fork's run id —
|
|
13
|
+
it never gets fork authority, and it appends only to that run.
|
|
14
|
+
* **The child is a fresh interpreter** (``sys.executable -m
|
|
15
|
+
activegraph.sandbox._child``), not ``os.fork()`` — no inherited
|
|
16
|
+
Python state, no shared clients. Environment is allow-list only.
|
|
17
|
+
* **Three independent nets**: ``resource.setrlimit`` (address
|
|
18
|
+
space + CPU, POSIX; degrade to the other two nets on Windows),
|
|
19
|
+
parent-side wall-clock kill, and the runtime's own event/LLM
|
|
20
|
+
budgets inside the child.
|
|
21
|
+
* **The store is the record.** The child's stdout tail is a signal;
|
|
22
|
+
the parent re-reads the fork run from the store and the store's
|
|
23
|
+
numbers win any disagreement.
|
|
24
|
+
|
|
25
|
+
TWO CHANNELS, KEPT SEPARATE (v1.7, the Replit soak fix):
|
|
26
|
+
|
|
27
|
+
* **The environment allow-list stays CLOSED.** Only ``PATH``,
|
|
28
|
+
``HOME``, ``LANG`` plus an explicit ``env_passthrough`` cross the
|
|
29
|
+
boundary. No ambient parent env — secrets, API keys, platform
|
|
30
|
+
config like ``REPLIT_*`` — leaks into a child running hostile
|
|
31
|
+
candidate code. This is a security control and does not widen.
|
|
32
|
+
* **Code location is an EXPLICIT channel, not an ambient one.** A
|
|
33
|
+
child must be able to import ``activegraph`` (and its deps) on any
|
|
34
|
+
box where the *parent* could — but on some platforms (Replit,
|
|
35
|
+
Nix) the package is discoverable only via a platform env var the
|
|
36
|
+
allow-list correctly strips, so a naive child dies at import
|
|
37
|
+
before running. The parent therefore RESOLVES the ``sys.path``
|
|
38
|
+
entries that make its own code importable and passes them to the
|
|
39
|
+
child as a computed ``PYTHONPATH`` (see :func:`_child_env`).
|
|
40
|
+
``PYTHONPATH`` is thus derived from the parent's resolved
|
|
41
|
+
``sys.path``, never forwarded from ambient env — code locations
|
|
42
|
+
are not secrets, and making the child's discovery of its own code
|
|
43
|
+
an explicit input keeps the allow-list closed while fixing the
|
|
44
|
+
restricted-env break. Call :func:`preflight` once at boot to
|
|
45
|
+
verify a child can start under this env and fail loud if not.
|
|
46
|
+
|
|
47
|
+
HONEST LIMITS (the design's §5, restated where users will read it):
|
|
48
|
+
this is crash/state isolation, **not a security sandbox**. A fresh
|
|
49
|
+
interpreter with rlimits does not stop a malicious candidate from
|
|
50
|
+
opening sockets or reading the filesystem — syscall and network
|
|
51
|
+
confinement are host territory (containers, seccomp), and this module
|
|
52
|
+
does not imitate them. The child can also, in principle, open the
|
|
53
|
+
shared SQLite file directly and touch other runs; per-run
|
|
54
|
+
authorization inside one SQLite file is not something SQLite gives us
|
|
55
|
+
honestly, so that caveat is stated rather than solved. The evolution
|
|
56
|
+
pack's static gates remain the pre-execution filter.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import json
|
|
62
|
+
import os
|
|
63
|
+
import subprocess
|
|
64
|
+
import sys
|
|
65
|
+
from dataclasses import dataclass, field
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
from typing import Optional
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
#: The closed outcome set. ``crashed`` is the implementation's one
|
|
71
|
+
#: addition over the design draft: a child that dies without a
|
|
72
|
+
#: parseable tail (segfault, os._exit, kill) is reported as what it
|
|
73
|
+
#: is, never guessed into a neater bucket.
|
|
74
|
+
TRIAL_OUTCOMES = (
|
|
75
|
+
"completed",
|
|
76
|
+
"scenario_failed",
|
|
77
|
+
"limits_exceeded",
|
|
78
|
+
"materialization_failed",
|
|
79
|
+
"crashed",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Child exit codes, mirrored in _child.py. Anything else → crashed.
|
|
83
|
+
_EXIT_TO_OUTCOME = {
|
|
84
|
+
0: "completed",
|
|
85
|
+
30: "scenario_failed",
|
|
86
|
+
40: "limits_exceeded",
|
|
87
|
+
50: "materialization_failed",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class PackSource:
|
|
93
|
+
"""Where the child materializes the candidate pack from.
|
|
94
|
+
|
|
95
|
+
``expected_bundle_hash`` is the external pin (the §4 walk WITH
|
|
96
|
+
``manifest.toml``, per the v1.4 bundle-hash amendment) — the child
|
|
97
|
+
verifies it via ``activegraph.packs.manifest.verify_bundle_hash``
|
|
98
|
+
before importing anything, so the bytes trialed are the bytes the
|
|
99
|
+
proposal recorded. ``manifest_required=True`` additionally runs
|
|
100
|
+
``load_manifest`` + ``verify_surface`` against the live ``Pack``,
|
|
101
|
+
making the trial child the first consumer of the full manifest
|
|
102
|
+
chain end-to-end.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
root_dir: str
|
|
106
|
+
expected_bundle_hash: str = ""
|
|
107
|
+
manifest_required: bool = True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class TrialLimits:
|
|
112
|
+
"""The trial's resource nets. Zero/None disables a given net.
|
|
113
|
+
|
|
114
|
+
``max_llm_calls=0`` (the default) means key-freedom is
|
|
115
|
+
STRUCTURAL: the child configures no LLM provider at all, so a
|
|
116
|
+
candidate with LLM behaviors fails loud at registration
|
|
117
|
+
(``MissingProviderError``) rather than reaching a network. A
|
|
118
|
+
positive cap is accepted and recorded for a future
|
|
119
|
+
provider-wiring seam; the v1 child never configures a live
|
|
120
|
+
provider either way. ``env_passthrough`` is the ONLY ambient parent
|
|
121
|
+
environment forwarded beyond the closed allow-list
|
|
122
|
+
(``PATH``/``HOME``/``LANG``) — the helper never forwards the
|
|
123
|
+
environment wholesale, so parent API keys don't leak into
|
|
124
|
+
candidate code by default. The child's ``PYTHONPATH`` is NOT an
|
|
125
|
+
ambient forward: it is computed from the parent's resolved
|
|
126
|
+
``sys.path`` so the child can import its own code (see the module
|
|
127
|
+
docstring's "two channels").
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
wall_clock_seconds: float = 120.0
|
|
131
|
+
max_rss_bytes: Optional[int] = None
|
|
132
|
+
max_events: Optional[int] = 2_000
|
|
133
|
+
max_llm_calls: Optional[int] = 0
|
|
134
|
+
env_passthrough: tuple[str, ...] = ()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class TrialReport:
|
|
139
|
+
"""What a trial produced. Store-derived numbers, child-signaled shape.
|
|
140
|
+
|
|
141
|
+
``events_appended`` and ``behavior_failures`` are re-read from the
|
|
142
|
+
fork's run in the store by the parent AFTER the child exits — the
|
|
143
|
+
stdout tail is a signal only, and any disagreement resolves in
|
|
144
|
+
favor of the store. Richer evidence (tracebacks, diffs) is read
|
|
145
|
+
from the fork's log directly: ``Runtime.load(store, run_id=
|
|
146
|
+
report.fork_run_id)`` then ``trace.failures()`` / ``diff()``.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
outcome: str
|
|
150
|
+
fork_run_id: str
|
|
151
|
+
events_appended: int
|
|
152
|
+
behavior_failures: int
|
|
153
|
+
detail: str
|
|
154
|
+
exit_code: Optional[int]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class SandboxStartupError(RuntimeError):
|
|
158
|
+
"""A trial child could not START under the sandbox env.
|
|
159
|
+
|
|
160
|
+
Raised by :func:`preflight` when the child fails before it can run
|
|
161
|
+
any trial — the common cause is the sandbox env being unable to
|
|
162
|
+
import ``activegraph`` on this box (the exact restricted-env break
|
|
163
|
+
the explicit package-path channel exists to prevent). The message
|
|
164
|
+
carries the child's stderr tail so the cause is never opaque.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _child_code_paths() -> list[str]:
|
|
169
|
+
"""The ``sys.path`` entries a child needs to import the parent's
|
|
170
|
+
code, resolved in the parent (v1.7 explicit package-path channel).
|
|
171
|
+
|
|
172
|
+
``activegraph``'s own root comes first, resolved from
|
|
173
|
+
``activegraph.__file__`` — this is what an editable install or a
|
|
174
|
+
platform that surfaces packages via a stripped env var (Replit's
|
|
175
|
+
``REPLIT_PYTHONPATH``) would otherwise hide from the child. The
|
|
176
|
+
parent's real ``sys.path`` directories follow so third-party deps
|
|
177
|
+
resolve too. Non-directory entries (namespace-finder hooks, the
|
|
178
|
+
empty CWD entry, zip imports) are dropped: they either don't help
|
|
179
|
+
a fresh child or would import from its CWD, which we do not want.
|
|
180
|
+
"""
|
|
181
|
+
import activegraph
|
|
182
|
+
|
|
183
|
+
ordered: list[str] = []
|
|
184
|
+
ag_root = str(Path(activegraph.__file__).resolve().parent.parent)
|
|
185
|
+
ordered.append(ag_root)
|
|
186
|
+
for entry in sys.path:
|
|
187
|
+
if entry and os.path.isdir(entry) and entry not in ordered:
|
|
188
|
+
ordered.append(entry)
|
|
189
|
+
return ordered
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _child_env(limits: TrialLimits) -> dict[str, str]:
|
|
193
|
+
"""Build the child's environment: closed allow-list + the computed
|
|
194
|
+
``PYTHONPATH`` code channel. See the module docstring's "two
|
|
195
|
+
channels".
|
|
196
|
+
|
|
197
|
+
The allow-list is ``PATH``/``HOME``/``LANG`` plus the caller's
|
|
198
|
+
explicit ``env_passthrough`` — no other ambient env crosses. Then
|
|
199
|
+
``PYTHONPATH`` is set from :func:`_child_code_paths` (the parent's
|
|
200
|
+
resolved code locations), prepended before any ``PYTHONPATH`` the
|
|
201
|
+
caller opted to pass through, so the child imports the same code
|
|
202
|
+
the parent can regardless of platform package discovery.
|
|
203
|
+
"""
|
|
204
|
+
env = {
|
|
205
|
+
key: os.environ[key]
|
|
206
|
+
for key in ("PATH", "HOME", "LANG")
|
|
207
|
+
if key in os.environ
|
|
208
|
+
}
|
|
209
|
+
for key in limits.env_passthrough:
|
|
210
|
+
if key in os.environ:
|
|
211
|
+
env[key] = os.environ[key]
|
|
212
|
+
channel = os.pathsep.join(_child_code_paths())
|
|
213
|
+
passed_through = env.get("PYTHONPATH", "")
|
|
214
|
+
env["PYTHONPATH"] = (
|
|
215
|
+
channel + os.pathsep + passed_through if passed_through else channel
|
|
216
|
+
)
|
|
217
|
+
return env
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _stderr_tail(stderr: str, *, max_lines: int = 20, max_chars: int = 1500) -> str:
|
|
221
|
+
"""The last few lines of a child's stderr, capped — the actual
|
|
222
|
+
cause of a pre-``_report`` crash the DEVNULL default used to lose.
|
|
223
|
+
"""
|
|
224
|
+
text = (stderr or "").strip()
|
|
225
|
+
if not text:
|
|
226
|
+
return ""
|
|
227
|
+
lines = text.splitlines()[-max_lines:]
|
|
228
|
+
tail = "\n".join(lines)
|
|
229
|
+
return tail[-max_chars:]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _parse_report_tail(stdout: str) -> dict[str, object]:
|
|
233
|
+
"""The child's last valid JSON report line, or ``{}``."""
|
|
234
|
+
for line in reversed((stdout or "").strip().splitlines()):
|
|
235
|
+
try:
|
|
236
|
+
parsed = json.loads(line)
|
|
237
|
+
except json.JSONDecodeError:
|
|
238
|
+
continue
|
|
239
|
+
if isinstance(parsed, dict) and (
|
|
240
|
+
"outcome" in parsed or "preflight" in parsed
|
|
241
|
+
):
|
|
242
|
+
return parsed
|
|
243
|
+
return {}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _run_child(
|
|
247
|
+
job: dict[str, object],
|
|
248
|
+
*,
|
|
249
|
+
env: dict[str, str],
|
|
250
|
+
wall_clock: float,
|
|
251
|
+
python_flags: tuple[str, ...] = (),
|
|
252
|
+
) -> tuple[Optional[int], str, str, bool]:
|
|
253
|
+
"""Spawn the trial child, feed it ``job`` on stdin, and return
|
|
254
|
+
``(exit_code, stdout, stderr, timed_out)``.
|
|
255
|
+
|
|
256
|
+
Shared by :func:`run_forked_trial` and :func:`preflight`. stderr is
|
|
257
|
+
PIPED (never DEVNULL) so a pre-``_report`` crash's cause survives.
|
|
258
|
+
``python_flags`` is an interpreter-flag seam used by tests to
|
|
259
|
+
reproduce a restricted env (``-S``); production passes none.
|
|
260
|
+
"""
|
|
261
|
+
proc = subprocess.Popen(
|
|
262
|
+
[sys.executable, *python_flags, "-m", "activegraph.sandbox._child"],
|
|
263
|
+
stdin=subprocess.PIPE,
|
|
264
|
+
stdout=subprocess.PIPE,
|
|
265
|
+
stderr=subprocess.PIPE,
|
|
266
|
+
env=env,
|
|
267
|
+
text=True,
|
|
268
|
+
)
|
|
269
|
+
try:
|
|
270
|
+
stdout, stderr = proc.communicate(
|
|
271
|
+
input=json.dumps(job), timeout=wall_clock
|
|
272
|
+
)
|
|
273
|
+
return proc.returncode, stdout, stderr, False
|
|
274
|
+
except subprocess.TimeoutExpired:
|
|
275
|
+
proc.kill()
|
|
276
|
+
stdout, stderr = proc.communicate()
|
|
277
|
+
return proc.returncode, stdout, stderr, True
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _preflight_with(
|
|
281
|
+
env: dict[str, str],
|
|
282
|
+
*,
|
|
283
|
+
python_flags: tuple[str, ...] = (),
|
|
284
|
+
timeout: float = 30.0,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""The preflight check against a specific ``env`` (the testable
|
|
287
|
+
seam under :func:`preflight`). Raises :class:`SandboxStartupError`
|
|
288
|
+
if a child cannot start and report ``preflight: ok``.
|
|
289
|
+
"""
|
|
290
|
+
exit_code, stdout, stderr, timed_out = _run_child(
|
|
291
|
+
{"preflight": True}, env=env, wall_clock=timeout, python_flags=python_flags
|
|
292
|
+
)
|
|
293
|
+
tail = _parse_report_tail(stdout)
|
|
294
|
+
if not timed_out and exit_code == 0 and tail.get("preflight") == "ok":
|
|
295
|
+
return
|
|
296
|
+
cause = _stderr_tail(stderr) or (
|
|
297
|
+
"child timed out" if timed_out else "no preflight tail on stdout"
|
|
298
|
+
)
|
|
299
|
+
raise SandboxStartupError(
|
|
300
|
+
f"trial child could not start (exit {exit_code}): {cause}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def preflight(*, limits: TrialLimits = TrialLimits(), timeout: float = 30.0) -> None:
|
|
305
|
+
"""Verify a trial child can START under the sandbox env on this box.
|
|
306
|
+
|
|
307
|
+
Spawns the child with a null job (no fork, no store, no candidate):
|
|
308
|
+
it imports ``activegraph``, echoes ``preflight: ok``, and exits 0.
|
|
309
|
+
Returns ``None`` on success; raises :class:`SandboxStartupError`
|
|
310
|
+
with the child's stderr tail otherwise. Consumers call this once at
|
|
311
|
+
boot so a broken sandbox env (e.g. a platform whose package
|
|
312
|
+
discovery the allow-list strips) fails LOUD here instead of showing
|
|
313
|
+
up as an opaque ``crashed`` on the first real trial.
|
|
314
|
+
"""
|
|
315
|
+
_preflight_with(_child_env(limits), timeout=timeout)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def run_forked_trial(
|
|
319
|
+
store_path: str,
|
|
320
|
+
*,
|
|
321
|
+
parent_run_id: str,
|
|
322
|
+
at_event: str,
|
|
323
|
+
pack_source: PackSource,
|
|
324
|
+
scenario: str = "",
|
|
325
|
+
limits: TrialLimits = TrialLimits(),
|
|
326
|
+
label: str = "trial",
|
|
327
|
+
extra_packs: tuple[PackSource, ...] = (),
|
|
328
|
+
) -> TrialReport:
|
|
329
|
+
"""Fork the parent at ``at_event`` and trial the candidate pack in
|
|
330
|
+
a fresh subprocess. CONTRACT v1.5 #1.
|
|
331
|
+
|
|
332
|
+
The fork is created HERE, in the parent process, with full
|
|
333
|
+
``fork()`` semantics (lineage recorded, promote-block cut guard);
|
|
334
|
+
the child receives only the fork's run id. ``scenario`` is
|
|
335
|
+
``"relative/path.py"`` or ``"relative/path.py::func"`` inside the
|
|
336
|
+
CANDIDATE's pack root — the function (default ``main``) is called
|
|
337
|
+
with the fork's ``Runtime`` and drives the trial; empty means
|
|
338
|
+
just ``run_until_idle()``. The scenario contract is
|
|
339
|
+
``def main(rt): ...``.
|
|
340
|
+
|
|
341
|
+
**The default is candidate-only isolation**: the child loads
|
|
342
|
+
nothing but the candidate, so the trial exercises it apart from
|
|
343
|
+
every other pack's behaviors. ``extra_packs`` is the opt-in for
|
|
344
|
+
cross-pack interaction trials (CONTRACT v1.5 #1 addendum 1b):
|
|
345
|
+
each entry is materialized in the child exactly like the
|
|
346
|
+
candidate — bundle hash verified before import, manifest schema
|
|
347
|
+
+ two-way surface check when required — and loaded, in order,
|
|
348
|
+
BEFORE the candidate. Any extra pack failing its pins is
|
|
349
|
+
``materialization_failed`` for the whole trial; the scenario
|
|
350
|
+
still resolves inside the candidate's root only.
|
|
351
|
+
|
|
352
|
+
Deterministic and key-free by default (``max_llm_calls=0``, empty
|
|
353
|
+
environment pass-through). Returns a :class:`TrialReport`; never
|
|
354
|
+
raises for in-trial failures — those are outcomes. Raises only
|
|
355
|
+
for parent-side setup problems (bad store, bad fork point), with
|
|
356
|
+
the same errors ``Runtime.load`` / ``fork()`` raise.
|
|
357
|
+
"""
|
|
358
|
+
from activegraph.runtime.runtime import Runtime
|
|
359
|
+
|
|
360
|
+
parent_rt = Runtime.load(store_path, run_id=parent_run_id, behaviors=[])
|
|
361
|
+
fork_rt = parent_rt.fork(at_event=at_event, label=label, behaviors=[])
|
|
362
|
+
fork_run_id = fork_rt.run_id
|
|
363
|
+
initial_events = len(fork_rt.graph.events)
|
|
364
|
+
del fork_rt # the child owns the fork from here
|
|
365
|
+
|
|
366
|
+
job = {
|
|
367
|
+
"store_path": store_path,
|
|
368
|
+
"fork_run_id": fork_run_id,
|
|
369
|
+
"initial_events": initial_events,
|
|
370
|
+
"pack_root": str(Path(pack_source.root_dir).resolve()),
|
|
371
|
+
"expected_bundle_hash": pack_source.expected_bundle_hash,
|
|
372
|
+
"manifest_required": pack_source.manifest_required,
|
|
373
|
+
"extra_packs": [
|
|
374
|
+
{
|
|
375
|
+
"pack_root": str(Path(p.root_dir).resolve()),
|
|
376
|
+
"expected_bundle_hash": p.expected_bundle_hash,
|
|
377
|
+
"manifest_required": p.manifest_required,
|
|
378
|
+
}
|
|
379
|
+
for p in extra_packs
|
|
380
|
+
],
|
|
381
|
+
"scenario": scenario,
|
|
382
|
+
"limits": {
|
|
383
|
+
"max_rss_bytes": limits.max_rss_bytes,
|
|
384
|
+
"max_events": limits.max_events,
|
|
385
|
+
"max_llm_calls": limits.max_llm_calls,
|
|
386
|
+
"cpu_seconds": int(limits.wall_clock_seconds) + 5,
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
exit_code, stdout, stderr, timed_out = _run_child(
|
|
390
|
+
job, env=_child_env(limits), wall_clock=limits.wall_clock_seconds
|
|
391
|
+
)
|
|
392
|
+
tail = _parse_report_tail(stdout)
|
|
393
|
+
|
|
394
|
+
if timed_out:
|
|
395
|
+
outcome = "limits_exceeded"
|
|
396
|
+
detail = (
|
|
397
|
+
f"wall clock exceeded {limits.wall_clock_seconds}s; child killed"
|
|
398
|
+
)
|
|
399
|
+
elif tail:
|
|
400
|
+
outcome = str(tail.get("outcome", "crashed"))
|
|
401
|
+
if outcome not in TRIAL_OUTCOMES:
|
|
402
|
+
outcome = "crashed"
|
|
403
|
+
detail = str(tail.get("detail", ""))
|
|
404
|
+
else:
|
|
405
|
+
outcome = _EXIT_TO_OUTCOME.get(exit_code or -1, "crashed")
|
|
406
|
+
if outcome == "completed":
|
|
407
|
+
# Exit 0 with no tail is itself suspicious; say so.
|
|
408
|
+
outcome = "crashed"
|
|
409
|
+
detail = f"child exited {exit_code} with no report tail"
|
|
410
|
+
|
|
411
|
+
# The pre-execution gate must never swallow its own failure cause:
|
|
412
|
+
# when the child crashed before it could _report (module-import
|
|
413
|
+
# failure, rlimit setup, anything pre-main), the actual exception
|
|
414
|
+
# is on the child's stderr, not in any tail. Fold it into detail so
|
|
415
|
+
# it reaches the caller's recorded gate_result (v1.7 fix 1).
|
|
416
|
+
if outcome == "crashed":
|
|
417
|
+
err_tail = _stderr_tail(stderr)
|
|
418
|
+
if err_tail:
|
|
419
|
+
detail = f"{detail}; child stderr:\n{err_tail}"
|
|
420
|
+
|
|
421
|
+
# The store is the record: re-read the fork run for the numbers.
|
|
422
|
+
events_appended = 0
|
|
423
|
+
behavior_failures = 0
|
|
424
|
+
try:
|
|
425
|
+
fork_view = Runtime.load(store_path, run_id=fork_run_id, behaviors=[])
|
|
426
|
+
events_appended = max(
|
|
427
|
+
0, len(fork_view.graph.events) - initial_events
|
|
428
|
+
)
|
|
429
|
+
behavior_failures = len(fork_view.trace.failures())
|
|
430
|
+
except Exception as e: # noqa: BLE001 — report, never mask the trial
|
|
431
|
+
detail = f"{detail} (store re-read failed: {e})".strip()
|
|
432
|
+
|
|
433
|
+
return TrialReport(
|
|
434
|
+
outcome=outcome,
|
|
435
|
+
fork_run_id=fork_run_id,
|
|
436
|
+
events_appended=events_appended,
|
|
437
|
+
behavior_failures=behavior_failures,
|
|
438
|
+
detail=detail,
|
|
439
|
+
exit_code=exit_code,
|
|
440
|
+
)
|
|
@@ -62,11 +62,15 @@ def _apply_rlimits(limits: dict[str, Any]) -> None:
|
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def _materialize_pack(job: dict[str, Any]) -> Any:
|
|
65
|
-
"""Verify pins, then import
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
"""Verify pins, then import one pack source and return its Pack.
|
|
66
|
+
|
|
67
|
+
``job`` carries ``pack_root`` / ``expected_bundle_hash`` /
|
|
68
|
+
``manifest_required`` — the top-level job spec for the candidate,
|
|
69
|
+
or one ``extra_packs`` entry (v1.7 addendum 1b), which uses the
|
|
70
|
+
identical chain. Order is the design's §3: bundle hash BEFORE any
|
|
71
|
+
import (the pin covers manifest.toml per the v1.4 amendment),
|
|
72
|
+
then manifest schema, then import, then the two-way surface check
|
|
73
|
+
against the live Pack.
|
|
70
74
|
"""
|
|
71
75
|
from activegraph.packs import Pack
|
|
72
76
|
from activegraph.packs.manifest import (
|
|
@@ -133,6 +137,16 @@ def _resolve_scenario(
|
|
|
133
137
|
|
|
134
138
|
def main() -> None:
|
|
135
139
|
job = json.loads(sys.stdin.read())
|
|
140
|
+
|
|
141
|
+
# Preflight (v1.7): a null job that only proves the child could
|
|
142
|
+
# START — i.e. that importing activegraph under the sandbox env
|
|
143
|
+
# succeeded, which happens before this function even runs. Reaching
|
|
144
|
+
# here at all means the heavy import chain worked; echo the marker
|
|
145
|
+
# and exit clean. No fork, no store, no candidate touched.
|
|
146
|
+
if job.get("preflight"):
|
|
147
|
+
print(json.dumps({"preflight": "ok"}), flush=True)
|
|
148
|
+
raise SystemExit(0)
|
|
149
|
+
|
|
136
150
|
fork_run_id = str(job["fork_run_id"])
|
|
137
151
|
initial = int(job.get("initial_events", 0))
|
|
138
152
|
_apply_rlimits(job.get("limits", {}))
|
|
@@ -144,6 +158,9 @@ def main() -> None:
|
|
|
144
158
|
)
|
|
145
159
|
|
|
146
160
|
try:
|
|
161
|
+
# Extra packs first (addendum 1b): each pinned and verified
|
|
162
|
+
# exactly like the candidate, loaded before it below.
|
|
163
|
+
extra = [_materialize_pack(p) for p in job.get("extra_packs", [])]
|
|
147
164
|
pack = _materialize_pack(job)
|
|
148
165
|
except Exception as e: # noqa: BLE001 — outcome, not crash
|
|
149
166
|
_report(
|
|
@@ -174,6 +191,8 @@ def main() -> None:
|
|
|
174
191
|
behaviors=[],
|
|
175
192
|
budget=budget or None,
|
|
176
193
|
)
|
|
194
|
+
for trusted in extra:
|
|
195
|
+
rt.load_pack(trusted)
|
|
177
196
|
rt.load_pack(pack)
|
|
178
197
|
|
|
179
198
|
try:
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
CONTRACT v1.5 #2, phase 1 of ``compaction-design.md``. Three offline
|
|
4
4
|
operations over a SQLite store (no self-compaction under a live
|
|
5
|
-
dispatcher — run these against
|
|
5
|
+
dispatcher — run these against runs no runtime is attached to):
|
|
6
6
|
|
|
7
7
|
* :func:`compact` — emit a ``runtime.snapshot`` event capturing the
|
|
8
8
|
run's projected state, store the blob in the snapshot sidecar,
|
|
@@ -26,6 +26,40 @@ horizon stop at it); there is no CLI yet; and patch records from the
|
|
|
26
26
|
archived prefix are not reconstructed after compaction (``compact``
|
|
27
27
|
refuses runs with patches still in ``proposed`` status, and applied/
|
|
28
28
|
rejected patch history remains queryable via the archive).
|
|
29
|
+
|
|
30
|
+
**What "offline" means — the attachment rule is per-RUN, not
|
|
31
|
+
per-file** (v1.6.x ruling; CONTRACT v1.5 #2 addendum 2b). These
|
|
32
|
+
operations must not run against a run that has a live runtime
|
|
33
|
+
attached; OTHER runs in the same store file may hold live runtimes
|
|
34
|
+
throughout. Why that is safe, from the implementation: every event-
|
|
35
|
+
row statement is scoped ``WHERE run_id = ?``; the store runs in WAL
|
|
36
|
+
mode, so the readers these functions open never block a live run's
|
|
37
|
+
writer and never see torn state; a live runtime appends via single-
|
|
38
|
+
statement autocommit transactions and never re-reads its own log
|
|
39
|
+
mid-dispatch; and the archive move is one short ``BEGIN IMMEDIATE``
|
|
40
|
+
transaction that a contending writer simply waits out (sqlite3's
|
|
41
|
+
default 5-second busy timeout — exhausted, it raises
|
|
42
|
+
``OperationalError``, never corrupting). What per-run attachment
|
|
43
|
+
actually protects against is the SAME-run case: a runtime attached
|
|
44
|
+
to the run being compacted mints the same event id as the snapshot
|
|
45
|
+
event (``UNIQUE(id, run_id)`` — ``IntegrityError``) and never learns
|
|
46
|
+
the horizon exists; a runtime attached to a run being retired would
|
|
47
|
+
keep appending to a log the operator believes empty.
|
|
48
|
+
|
|
49
|
+
Two caveats stay the caller's responsibility:
|
|
50
|
+
|
|
51
|
+
* ``pins`` → archive is check-then-act, not one atomic unit. Do
|
|
52
|
+
not race a pin-creating operation against retirement of the
|
|
53
|
+
same run — a promote from the fork, or a new fork of it, landing
|
|
54
|
+
between the pin check and the archive move would be archived-
|
|
55
|
+
while-pinned. Retire after decisions on that run are final. (A
|
|
56
|
+
lost race degrades audit walks — ``causal_chain`` does not read
|
|
57
|
+
the archive — but destroys no bytes: archiving is a move, and
|
|
58
|
+
the rows stay readable via ``iter_archived``.)
|
|
59
|
+
* A very large run's archive move holds the write lock for the
|
|
60
|
+
duration of its one transaction; a concurrent writer that
|
|
61
|
+
exhausts its busy timeout gets ``OperationalError``, not
|
|
62
|
+
corruption. Size housekeeping windows accordingly.
|
|
29
63
|
"""
|
|
30
64
|
|
|
31
65
|
from __future__ import annotations
|
|
@@ -148,6 +182,10 @@ def pins(path: str, run_id: str) -> list[str]:
|
|
|
148
182
|
Empty list = unpinned. The same computation the refusals in
|
|
149
183
|
:func:`compact` / :func:`retire` run — design §5's "why can't I
|
|
150
184
|
retire this run?" API.
|
|
185
|
+
|
|
186
|
+
Read-only, safe to call while any run in the file holds a live
|
|
187
|
+
runtime (WAL readers never block or tear). The answer is
|
|
188
|
+
point-in-time: a pin created after it returns is not in it.
|
|
151
189
|
"""
|
|
152
190
|
reasons: list[str] = []
|
|
153
191
|
store = SQLiteEventStore(path, run_id=run_id)
|
|
@@ -220,8 +258,12 @@ def compact(path: str, run_id: str) -> str:
|
|
|
220
258
|
design's crash-safe one: snapshot event, then blob, then the
|
|
221
259
|
archive move (idempotent). Returns the snapshot event id.
|
|
222
260
|
|
|
223
|
-
Offline operation:
|
|
224
|
-
|
|
261
|
+
Offline operation, per-RUN: no live runtime may be attached to
|
|
262
|
+
``run_id`` itself — the snapshot event would collide with the
|
|
263
|
+
ids a live runtime mints (``UNIQUE(id, run_id)``), and that
|
|
264
|
+
runtime's projection would never learn the horizon exists. Other
|
|
265
|
+
runs in the same file may hold live runtimes; see the module
|
|
266
|
+
docstring for the full reasoning.
|
|
225
267
|
"""
|
|
226
268
|
reasons = pins(path, run_id)
|
|
227
269
|
if reasons:
|
|
@@ -272,6 +314,16 @@ def retire(path: str, run_id: str) -> int:
|
|
|
272
314
|
|
|
273
315
|
The typical subject is a rejected fork trial that was never
|
|
274
316
|
promoted. Pinned runs refuse with the full reason list.
|
|
317
|
+
|
|
318
|
+
Offline operation, per-RUN: no live runtime may be attached to
|
|
319
|
+
``run_id`` itself, and no pin-creating operation (a promote from
|
|
320
|
+
this run, a fork of it) may race the retirement. Other runs in
|
|
321
|
+
the same file may hold live runtimes throughout — retiring a
|
|
322
|
+
finished fork while the parent run's runtime is live is
|
|
323
|
+
sanctioned usage (see the module docstring for the transaction
|
|
324
|
+
reasoning, and ``test_compaction.py``'s
|
|
325
|
+
``test_retire_fork_per_run_while_parent_runtime_is_live`` for
|
|
326
|
+
the pinned behavior).
|
|
275
327
|
"""
|
|
276
328
|
reasons = pins(path, run_id)
|
|
277
329
|
if reasons:
|
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "activegraph"
|
|
10
|
-
version = "1.
|
|
10
|
+
version = "1.7.0"
|
|
11
11
|
description = "An event-sourced reactive graph runtime for long-running, auditable, agentic systems."
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -181,6 +181,41 @@ def test_unpromoted_rejected_fork_retires_cleanly(tmp_path):
|
|
|
181
181
|
assert len(parent_view.graph.events) == n_parent
|
|
182
182
|
|
|
183
183
|
|
|
184
|
+
def test_retire_fork_per_run_while_parent_runtime_is_live(tmp_path):
|
|
185
|
+
# The downstream housekeeping pattern (v1.6.x ruling, CONTRACT
|
|
186
|
+
# v1.5 #2 addendum 2b): the offline rule is per-RUN, not
|
|
187
|
+
# per-file. pins()/retire() on a finished fork run while the
|
|
188
|
+
# PARENT run holds a live runtime on the same SQLite file is
|
|
189
|
+
# sanctioned — WAL + per-run row scoping + single-statement
|
|
190
|
+
# appends. What stays forbidden is a runtime attached to the run
|
|
191
|
+
# being retired/compacted itself.
|
|
192
|
+
path, parent = _seeded_runtime(tmp_path)
|
|
193
|
+
rejected = parent.fork(at_event=parent.trace.events()[-1].id)
|
|
194
|
+
rejected.graph.add_object("note", {"text": "never adopted"})
|
|
195
|
+
fork_run = rejected.run_id
|
|
196
|
+
n_fork = len(rejected.graph.events)
|
|
197
|
+
del rejected # trial over: nothing attached to the FORK run
|
|
198
|
+
|
|
199
|
+
# Parent stays LIVE — attached store connection, no teardown.
|
|
200
|
+
assert pins(path, fork_run) == []
|
|
201
|
+
moved = retire(path, fork_run)
|
|
202
|
+
assert moved == n_fork
|
|
203
|
+
|
|
204
|
+
# The live parent keeps working through its open attachment:
|
|
205
|
+
# appends land, save succeeds, nothing about its run was touched.
|
|
206
|
+
parent.run_goal("second")
|
|
207
|
+
parent.save_state(path)
|
|
208
|
+
n_parent = len(parent.graph.events)
|
|
209
|
+
|
|
210
|
+
store = SQLiteEventStore(path, run_id=fork_run)
|
|
211
|
+
assert list(store.iter_events()) == []
|
|
212
|
+
assert store.has_archived() # moved, never deleted
|
|
213
|
+
reloaded = Runtime.load(path, run_id=parent.run_id, behaviors=[])
|
|
214
|
+
assert len(reloaded.graph.events) == n_parent
|
|
215
|
+
# And the retired fork no longer pins the still-live parent.
|
|
216
|
+
assert not any("live-lineage" in r for r in pins(path, parent.run_id))
|
|
217
|
+
|
|
218
|
+
|
|
184
219
|
def test_live_lineage_pins_the_parent_until_children_retire(tmp_path):
|
|
185
220
|
path, parent = _seeded_runtime(tmp_path)
|
|
186
221
|
child = parent.fork(at_event=parent.trace.events()[-1].id)
|