diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 2abeb5812..18826a01d 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -8,7 +8,10 @@ on: - "scripts/**" - "tests/**" - "examples/**" + - "package.json" + - "package-lock.json" - "pyproject.toml" + - "tsconfig.control-plane.json" push: branches: - main @@ -18,7 +21,10 @@ on: - "scripts/**" - "tests/**" - "examples/**" + - "package.json" + - "package-lock.json" - "pyproject.toml" + - "tsconfig.control-plane.json" permissions: contents: read @@ -43,9 +49,22 @@ jobs: python-version: "3.11" cache: pip + - name: Set up the TypeScript Effect runtime + uses: actions/setup-node@v6 + with: + node-version: "22.6" + cache: npm + cache-dependency-path: package-lock.json + - name: Install test dependencies run: python -m pip install --disable-pip-version-check -e ".[test]" + - name: Qualify the TypeScript Effect core + run: | + npm ci --ignore-scripts + npm run typecheck:control-plane + npm run test:control-plane + - name: Lint test suite run: >- python -m ruff check @@ -85,6 +104,11 @@ jobs: python-version: "3.11" cache: pip + - name: Set up the TypeScript Effect runtime + uses: actions/setup-node@v6 + with: + node-version: "22.6" + - name: Install test dependencies run: python -m pip install --disable-pip-version-check -e ".[test]" @@ -95,5 +119,6 @@ jobs: tests/test_doctor_install_freshness.py tests/test_file_lock.py tests/test_file_lock_cross_process.py + tests/control_plane/test_effect_runtime_integration.py tests/test_self_update_runtime_activation.py tests/test_windows_install.py diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 8c6d00f38..aeb6d2bbd 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -6,9 +6,12 @@ on: - ".github/workflows/release-artifacts.yml" - "examples/release-artifacts-smoke.py" - "loopx/**" + - "package.json" + - "package-lock.json" - "pyproject.toml" - "README.md" - "scripts/release_artifacts.py" + - "tsconfig.control-plane.json" release: types: [published] workflow_dispatch: @@ -49,6 +52,11 @@ jobs: python-version: "3.11" cache: pip + - name: Set up the packaged TypeScript control-plane runtime + uses: actions/setup-node@v6 + with: + node-version: "22.6" + - name: Resolve and validate release identity id: identity env: @@ -102,6 +110,93 @@ jobs: dist/packages/*.whl test "$("${RUNNER_TEMP}/loopx-wheel/bin/loopx" --version)" = \ "loopx ${RELEASE_TAG#v}" + "${RUNNER_TEMP}/loopx-wheel/bin/python" - <<'PY' + from loopx.control_plane.effect_program import SettlementIdentity + from loopx.control_plane.effect_runtime import ( + collect_effect_runtime_readiness, + ) + from loopx.control_plane.turn_driver.turn_journal_runtime import ( + interpret_turn_journal_projection, + write_turn_journal, + ) + from pathlib import Path + from tempfile import TemporaryDirectory + + turn_key = "sha256:" + "a" * 64 + journal = { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "fixture-goal", + "turn_key": turn_key, + "status": "committed", + "completed_phases": [ + "host_execute", + "typed_result", + "validation", + "durable_writeback", + "quota_spend", + "scheduler_apply", + "scheduler_ack", + ], + "plan": { + "turn_envelope": { + "goal_id": "fixture-goal", + "agent_id": "fixture-agent", + }, + "transaction": { + "turn_key": turn_key, + "settlement_plan": { + "identity": { + "goal_id": "fixture-goal", + "agent_id": "fixture-agent", + } + }, + }, + }, + } + result = interpret_turn_journal_projection( + journal, + goal_id="fixture-goal", + agent_id="fixture-agent", + turn_key=turn_key, + ) + assert result["decision"] == "replay_legal", result + assert result["effects"] == [], result + identity = SettlementIdentity( + goal_id="fixture-goal", + agent_id="fixture-agent", + todo_id="fixture-todo", + turn_instance_id="fixture-turn", + ) + assert identity.effect_id == ( + "fixture-goal:fixture-agent:fixture-todo:fixture-turn" + ), identity + readiness = collect_effect_runtime_readiness(deep=True) + assert readiness["status"] == "ready", readiness + assert readiness["semantic_probe"] == "passed", readiness + with TemporaryDirectory() as temporary_directory: + effect_id = identity.effect_id + writable_journal = { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "fixture-goal", + "turn_key": turn_key, + "status": "in_progress", + "completed_phases": ["host_execute"], + "plan": { + "transaction": { + "settlement_plan": { + "identity": {"effect_id": effect_id} + } + } + }, + } + committed = write_turn_journal( + str(Path(temporary_directory) / "turn.json"), + writable_journal, + expected_effect_id=effect_id, + ) + assert committed["appended"] is True, committed + assert committed["replayed"] is False, committed + PY skills_dir="${RUNNER_TEMP}/loopx-wheel-skills" "${RUNNER_TEMP}/loopx-wheel/bin/loopx" --format json \ workflow-skills --install --skills-dir "${skills_dir}" \ diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 1997986f2..993a59655 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -568,19 +568,25 @@ and budget rejection distinct, and leave scheduler apply or ACK outside the agent-owned settlement boundary. M7.3: after both M7.2 adapters consume the proven plan and receipt semantics, -compare their execution ownership. Extract the smallest shared executor or -Kleisli-like bind protocol only if it deletes duplicate orchestration without -crossing the Codex App agent/host boundary. Do not add a registry or generic -composition framework. `quota should-run` may derive both its packet and -effect projection from one canonical decision plan, but constructing -`EffectTurn` earlier is not itself an acceptance condition. If the two callers -share only the algebra and not an executor boundary, close M7.3 with a -structured no-follow-up decision and keep their executors local. +compare their execution ownership. The 2026-08-21 cutover qualification found +that settlement identity, bind/short-circuit, replay seeding, next-action +selection, and commit reduction were still duplicated across the adapters. +This reopens M7.3 for one bounded TypeScript Effect runtime. The runtime owns +that shared algebra and the first internal effect, atomic Turn-journal +checkpointing. Its server is only a temporary Python-to-TypeScript transport; +one static typed handler registry routes coarse transactions to domain owners. +It is not a generic composition framework and does not move model, user, host +scheduler, credential, or third-party authority behind a universal executor. +Every replaced Python semantic path is deleted in the same cutover PR. M7.4: expand one bounded family at a time only when it removes duplicate -knowledge. Todo, monitor, capability, scheduler, and gate state machines keep -their domain transition invariants. They do not move behind a shared protocol -merely because their packets have similar fields. +knowledge and switches a real production caller. Todo, monitor, capability, +scheduler, and gate state machines keep their domain transition invariants. +They may execute through the same managed runtime as they migrate, but they do +not move behind one generic state protocol merely because their packets have +similar fields. After the CLI is native TypeScript, CLI-only execution imports +the kernel in-process; the daemon remains optional for App/multi-client shared +authority rather than a mandatory server per family. The replan semantic-exit repair in #3208 is an explicit non-candidate: `refresh-state` already re-derives the current obligation and records a typed diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 22061a960..92cce4a71 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -373,9 +373,9 @@ M7.1:在添加 protocol 前刻画选中的 vertical slice。为合法与非法 M7.2:用一个 typed plan/receipt algebra 替换核心 settlement truth。plan step 必须携带稳定 kind、owner、precondition、idempotency identity 和 expected receipt。默认 Codex App path 与隔离 turn driver 把 validation、durable writeback、quota spend 和 conditional terminal closeout 绑定到原始 quota-turn effect identity。普通 successor completion 可以在 settlement 前推进 Todo frontier;final `no_followup` 只有在 matching writeback/spend receipt 后才提交,不能增加 terminal guard 例外。每个 replacement PR 都必须删除对应的 manual command 或 settlement truth。Raw mappings 和 free-form CLI commands 可以保留为 compatibility payloads,但不是语义执行合同。组合必须满足上文定义的 identity、associativity、short-circuit、replay 和 ordering 性质,保持 cancellation、permission denial 和 budget rejection 可区分,并让 scheduler apply 或 ACK 留在 agent-owned settlement boundary 之外。 -M7.3:在两个 M7.2 adapter 都消费经过验证的 plan/receipt 语义后,比较它们的执行所有权。只有在删除重复编排且不跨越 Codex App agent/host boundary 时,才抽取最小的共享 executor 或 Kleisli-like bind protocol。不要增加 registry 或通用组合框架。`quota should-run` 可以从同一个 canonical decision plan 派生 packet 和 effect projection,但更早构造 `EffectTurn` 本身不是验收条件。如果两个 caller 只共享 algebra 而不共享 executor boundary,用结构化 no-follow-up decision 关闭 M7.3,并保留各自的 local executor。 +M7.3:在两个 M7.2 adapter 都消费经过验证的 plan/receipt 语义后,比较它们的执行所有权。2026-08-21 的 cutover qualification 发现,settlement identity、bind/short-circuit、replay seeding、next-action selection 与 commit reduction 仍在 adapter 间重复。因此重新打开 M7.3,引入一个 bounded TypeScript Effect runtime。Runtime 拥有共享 algebra 和第一个内部 effect——atomic Turn-journal checkpoint。它的 server 只是临时 Python-to-TypeScript transport;一个静态 typed handler registry 把粗粒度 transaction 路由给 domain owner。它不是通用组合框架,也不会把 model、user、host scheduler、credential 或第三方 authority 藏到万能 executor 后面。每条被替代的 Python 语义路径都必须在同一 cutover PR 删除。 -M7.4:只有在移除重复知识时,才一次扩展一个有界状态族。Todo、monitor、capability、scheduler 和 gate 状态机保留自己的 domain transition invariants。它们不能仅仅因为 packet 字段相似就移到共享 protocol 后面。 +M7.4:只有在移除重复知识并切换真实生产 caller 时,才一次扩展一个 bounded 状态族。Todo、monitor、capability、scheduler 和 gate 状态机保留自己的 domain transition invariants。它们迁移后可以通过同一个 managed runtime 执行,但不能仅仅因为 packet 字段相似就移到一个通用状态协议后面。CLI 原生迁到 TypeScript 后,CLI-only 在进程内 import kernel;daemon 只在 App/多 client 共享 authority 时可选保留,而不是每个状态族一个必选 server。 #3208 的 replan semantic-exit 修复明确不是候选:`refresh-state` 已经会重新推导当前 obligation 并记录 typed semantic ACK,实际缺陷是 goal-frontier 中一个额外的 settlement 条件在 acceptance gaps 仍存在时忽略了合法的 non-successor ACK。这是 domain-local reducer/ACK invariant,不是第二个 multi-step executor,应继续由 replan/goal-frontier owner 持有。只有第二个真实 runtime 场景(例如具有相同 plan/receipt lifecycle 的 quota/status read ACK)出现,并且能在两个 adapter 间删除重复编排时,才重新评估 Effect Program 迁移。 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 0f3076d1a..a1cf1c002 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -1,12 +1,12 @@ # RFC: TypeScript Control-Plane Migration Direction v0 -- Status: Draft, under maintainer review +- Status: Draft, first bounded cutover under maintainer review - Proposed by: LoopX maintainers - Date: 2026-08-15 -- Scope: an incremental, contract-first migration strategy for the LoopX - control-plane core from Python to TypeScript; parity-gated, block-by-block - replacement -- Source baseline: `d1fe05932` +- Last revised: 2026-08-21 +- Scope: an incremental, replacement-first migration of the LoopX control-plane + core from Python to TypeScript without maintaining two semantic + implementations - Tracking issue: [#3225](https://github.com/huangruiteng/loopx/issues/3225) - Language note: the [Chinese version](./typescript-control-plane-migration-v0.zh-CN.md) and this @@ -14,190 +14,289 @@ --- -## 0. Example +## 0. Decision in one example -A host wants to embed LoopX decision-making without requiring a Python -runtime. Today, the Pi extension -(`loopx/pi_goal_mode/loopx-goal.ts`) already runs the quota decision by -shelling out to the Python CLI -(`loopx quota should-run --runtime-profile generic_cli`). The extension -implements its goal loop in TypeScript; only the decision kernel is Python. +During migration, the Python `loopx` CLI sends one coarse typed transaction to +a LoopX-managed TypeScript runtime. The migrated TypeScript module owns the +rule and any migrated internal LoopX effect; Python is only a transport and +legacy-callback adapter. The replaced Python rule and its implementation-only +tests are deleted in the same PR. -The migration question is whether that split can grow into a full TypeScript -control-plane core without a big-bang rewrite: Python and TypeScript calling -each other, one surface at a time, with the user experience unchanged. - -This RFC answers yes, but not through in-process mutual calls. It proposes a -contract-first "strangler" migration over three existing seams: the event -store, the parity-fixture layer, and the CLI boundary. +After the CLI itself migrates to TypeScript, CLI-only use imports the same +kernel in-process and the Python-to-TypeScript bridge disappears. When the App, +CLI, scheduler, or several hosts need one shared writer, the same kernel may run +inside one optional managed daemon. This is one kernel with two deployment +forms, not one server per control-plane family. ## 1. Problem -- External projects are already porting the LoopX kernel to TypeScript; the - most complete sketch is - [Foreman PR #1](https://github.com/needware/foreman/pull/1). -- The frontstage/dashboard surface is already TypeScript. -- A shared runtime would simplify CLI distribution and host integration, - including an npm package. -- The control-plane core is roughly 343k lines of Python, with more than - 1,200 files under `tests/` and `examples/`. A one-pass rewrite is - high-risk, effectively un-reviewable, and would strand production behavior - behind a single cut-over. -- The naive reading of "Python and TypeScript calling each other" — - in-process imports — is impractical: embedding CPython inside Node and - V8 inside Python both carry GIL/ABI/packaging costs, and Pyodide/WASM has - startup, single-thread, filesystem, and process limitations. - -The practical question is therefore: which boundary can carry a -dual-language transition with parity guarantees and rollback? - -## 2. Decision - -Adopt a contract-first, parity-gated, block-by-block migration: - -1. Interop happens over a process boundary with JSON contracts - (stdio JSON-RPC/NDJSON), not in-process imports. Calls are coarse-grained — - a CLI command, a projection render, or a decision request — so per-call - latency is acceptable. -2. The event store is the shared fact surface: append-only events with - versioned schemas (`loopx_state_event_v0`) form the only cross-language - state contract. Either language can build projections from the same event - stream. -3. Read paths and projections migrate first (pure, side-effect-free), then - deterministic decision kernels (quota `should-run`, todo lifecycle - transitions, scheduler state transitions) validated by parity fixtures and - decision replay. The event store and write paths migrate last, through a - dual-read → dual-write → flip sequence with a bounded canary and a recorded - rollback plan. -4. Python remains the canonical implementation during the transition. A block - flips only when its parity gate passes and its rollback plan is recorded. - -### 2.1 Interop options - -| Option | Verdict | Notes | -| --- | --- | --- | -| TS → Python subprocess | ✅ already in production | `pi_goal_mode` uses `execFile("loopx", ...)` with a 30s timeout; simplest and proven for coarse calls | -| Python → TS subprocess | ✅ viable | `node dist/...` with JSON on stdin; same shape when a migrated kernel must be called from Python | -| Long-lived sidecar (JSON-RPC over Unix socket) | ⚠️ optimize later | Amortizes startup for hot paths; requires lifecycle, version, and lock discipline | -| Pyodide / WASM | ❌ not for production CLI | Startup in seconds, single-threaded, filesystem/process limits | -| Rust core + PyO3/napi-rs dual bindings | ⚠️ separate project | True shared core with thin Python/TS bindings (like huggingface/tokenizers), but it is a Rust rewrite, not a TypeScript migration | - -## 3. Existing seams that make this viable - -The migration is not a leap into the unknown; three seams already exist in the -repository: - -- `loopx/pi_goal_mode/loopx-goal.ts` and `pi-goal-loop-runtime.mjs`: a - production TypeScript surface that delegates quota decisions to the Python - CLI over a process boundary. -- `loopx/control_plane/testing/quota_should_run_parity.py`: a compact, - stable parity surface for comparing old and new quota builders; the template - for every future parity fixture. -- `loopx/control_plane/testing/decision_replay.py`: replays historical - payloads against a decision builder — the dual-implementation comparison - harness. -- `loopx/control_plane/testing/cli_output_differential.py`: enforces CLI - output contracts and growth budgets. -- `loopx/event_sourced_state.py`: append-only, schema-versioned event state - (`loopx_state_event_v0`). -- `loopx/control_plane/runtime/event_store_migration_bridge.py`: already - models dual-read parity, bounded canaries, and rollback records for event - projection promotion. - -## 4. Migration phases - -### Phase 0 — Contract freeze - -Turn the candidate scope in #3225 into typed schemas and a parity-fixture -inventory: append-only event state and idempotent writes; todo lifecycle -(claim, lease, status, revision, completion validation); gates and decision -scope; quota (`should-run`/spend) and scheduler/monitor contracts; the Turn -envelope and transaction semantics; handoff and review-packet projection; CLI -and status/quota JSON parity. No code migration happens in this phase. - -### Phase 1 — Read paths and projections - -Migrate status JSON, todo list projection, handoff review-packet projection, -and frontstage rendering. These are pure functions with no side effects. -TypeScript implements each surface; Python generates golden fixtures; a -dual-read gate requires the TypeScript projection to match the Python head -before it may serve. - -### Phase 2 — Deterministic decision kernels - -Migrate `quota should-run`, todo state transitions, and scheduler state -transition rules. These are pure decisions with no I/O, so they are the -natural parity candidates. `decision_replay` replays historical inputs -through both implementations; a block flips only when outputs are identical -field-by-field. After this phase, the Pi extension can drop its Python quota -dependency. - -### Phase 3 — Event store and write paths - -TypeScript first reads the event store as a projection reader (dual-read), -then dual-writes with idempotency checks, then flips the write path. The -`event_store_migration_bridge` canary and rollback gates apply at each step. - -### Phase 4 — Distribution - -Publish an npm package and a pip shim (or both). A TypeScript CLI shim -forwards unmigrated commands to Python, so the `loopx` user experience stays -unchanged throughout. - -## 5. Interop contract - -- Every surface carries a versioned JSON schema (`..._v0`). -- Requests/responses travel as NDJSON over stdio; the long-lived sidecar may - add content-length framing. -- Errors use a machine-readable envelope (code + message), never raw - tracebacks. -- Callers set timeouts, following the existing `LOOPX_CLI_TIMEOUT_MS` pattern. -- Writes are idempotent: events carry stable ids and duplicate application is - a no-op. -- No credentials, raw logs, or private paths cross the boundary. - -## 6. Validation - -- Parity fixtures per surface: the same input corpus produces identical - compact JSON output from both implementations. -- Decision replay: historical payloads replay against both implementations. -- Dual-read gate: TypeScript projection must match the Python head before it - serves traffic. -- Bounded canary: a small canary goal set runs on the new path before flip. -- Rollback record: the flip is flag-gated and the rollback plan is recorded - before the flip happens. -- CLI output budgets remain enforced by `cli_output_differential`. - -## 7. Non-goals - -- No behavior change; Python remains canonical during the transition. -- No in-process embedding (CPython inside Node, or V8 inside Python). -- No Pyodide/WASM as the production runtime. -- No fork-first migration; upstream-owned and contribution-friendly. -- No one-pass rewrite of the full control-plane core. - -## 8. Open questions - -- Runtime choice: Node.js, Bun, or Deno? -- Packaging/distribution: npm package, pip shim, or both? -- Contributor ownership and review lane for the TypeScript track? -- Hot-path budget: which surfaces justify a long-lived sidecar? -- Should the eventual shared core be Rust with thin Python/TypeScript - bindings instead (a separate RFC)? - -## 9. Smallest useful implementation slice - -A TypeScript implementation of the `quota should-run` compact parity surface, -run against the same fixture corpus as `quota_should_run_parity.py`, producing -identical JSON. Optionally paired with one read-path probe: a TypeScript todo -list/status projection rendering the same event fixtures as the Python -projection. This validates the entire pipeline — contract, parity fixtures, -dual implementation, and the process boundary — at near-zero production risk. - -## 10. Rollout and rollback - -Every block follows the same sequence: dual implementation → parity gate → -dual-read (for read paths) → bounded canary → flip behind a flag → recorded -rollback plan. Any parity mismatch blocks the flip. Rollback means flipping -back and keeping both implementations until the block is re-qualified; no -existing user state is migrated twice or left half-migrated. +LoopX already has TypeScript host and dashboard surfaces, but its canonical +control-plane rules live in Python. A big-bang rewrite is too risky, while a +long-lived dual implementation would be worse: every bug fix would need to be +made twice and parity would become a permanent product feature. + +The migration therefore needs intermediate states that satisfy all of these +constraints: + +- one semantic owner for every migrated rule; +- no user-visible CLI split and no manual daemon lifecycle; +- real side effects can migrate, not only pure projections; +- correctness is qualified against a pinned pre-migration baseline and + independently stated invariants; +- latency, packaging, upgrade, rollback, and crash recovery are measured at + every cutover; +- each PR is a complete, reviewable replacement slice. + +## 2. Architecture decision + +### 2.1 One TypeScript kernel + +`@loopx/control-plane` is the intended semantic kernel. Domain modules own +typed state, interpretation, transition rules, and the internal effects that +belong to those rules. A transport shell must not become a second business +owner. + +```text +Python CLI during migration ─┐ +LoopX App / scheduler ───────┼─> one typed runtime boundary ─> TS kernel +future TS CLI ───────────────┘ +``` + +The boundary uses coarse, versioned requests such as “settle this Turn” or +“commit this journal”, not chatty property getters. The runtime has a static +typed handler registry. Adding a domain handler does not create another +server. + +### 2.2 Two deployment forms, one implementation + +| Product topology | Execution form | +| --- | --- | +| CLI-only after the TS CLI cutover | Import and execute the TS kernel in the CLI process; no daemon | +| App-only | Embed the same kernel in the App runtime | +| App + CLI + scheduler, or concurrent clients | One managed local authority daemon; clients connect to the active writer | +| Migration while Python remains the CLI | One idle-exiting loopback runtime bridges Python to the migrated TS kernel | + +If an authority daemon owns a registry/workspace, a CLI process must connect +to it instead of opening a second direct writer. Runtime discovery and startup +are automatic; users do not configure ports or supervise processes. + +### 2.3 TypeScript owns migrated effects + +The target is not “TypeScript decides, Python always executes”. TypeScript may +own internal LoopX effects such as atomic state checkpoints, event appends, +receipt commits, and idempotent reducer writes. Each effect has a typed request, +stable idempotency identity, typed receipt, and retry policy. + +Asynchronous execution does not weaken settlement ordering: an effect receipt +is emitted only after the awaited durability boundary succeeds. It does, +however, permit concurrent requests, so the authority that owns a migrated +write must also own its per-key serialization or compare-and-swap contract. +Caller-side locking is acceptable only as an explicitly transitional guard; a +native TypeScript caller must not bypass the invariant after cutover. Retry +identity is operation-specific: when one Turn effect checkpoints several +successive journal states, the broad Turn effect id alone is not proof that two +write payloads are the same operation. + +External authorities remain explicit adapters: model calls, human gates, host +schedulers, credentials, and third-party mutations are not hidden behind a +universal executor. Their receipts return to the Effect Program for +settlement. + +### 2.4 Replacement, not production dual-running + +Characterization may execute the old and new implementations offline against +the same pinned corpus. Production does not keep two rule engines or dual-write +semantic state. Once a slice passes its gates, callers flip to TypeScript and +the replaced Python rule is removed. A narrow compatibility facade may remain +only for a real public import, persisted schema, or unmigrated callback. + +### 2.5 Validate once at every trust boundary + +TypeScript types are erased at runtime. Network/RPC payloads, parsed JSON, +persisted state, extension input, and adapter responses therefore enter the +system as `unknown`; a static annotation or `as T` assertion does not prove +that those bytes satisfy the contract. Each migrated domain must decode these +values through a typed decoder or an explicit versioned schema parser before a +domain handler or Effect interpreter consumes them. + +After successful decoding, the TypeScript kernel owns the typed value and may +rely on the compiler instead of repeating ad hoc field checks throughout the +domain. Transport checks such as framing, authentication, and size limits stay +separate from schema validation and semantic invariants. An unchecked +`JSON.parse(...) as T` must not establish control-plane authority. + +`as unknown as T` is permitted only as a named migration seam: its exact call +site, upstream validator, negative boundary coverage, and removal owner must be +visible in the cutover PR. A migrated domain cannot pass its promotion gate +while public, persisted, RPC, or extension input still reaches its semantic +core through an unvalidated assertion. TypeScript complements runtime +validation; it does not replace it. + +## 3. Why Effect Program moves first + +Effect Program is the bottom contract that already joins ordered steps, +identity, short-circuit failure, replay, receipts, and settlement. Migrating it +first gives later todo, quota, scheduler, and gate work one typed execution +language instead of independently inventing cross-language contracts. + +This is not permission to move every state machine into one generic protocol. +Domain transition invariants stay with their domain owner. A family migrates +only when a real caller can switch and the PR deletes corresponding Python +knowledge. + +## 4. Migration sequence + +### Stage 0 — Pin behavior and authority + +For the selected slice, record: + +- authoritative schemas and independently reviewed legal/illegal transitions; +- pinned-base characterization fixtures; +- production callers and side effects; +- latency and package/install baseline; +- rollback boundary and state compatibility. + +### Stage 1 — Effect Program cutover + +Move the Effect algebra and normal-Turn settlement semantics to TypeScript: +ordered programs, settlement identity, bind/short-circuit behavior, replay, +receipt construction, next-action selection, and commit reduction. Add one +native internal effect—atomic Turn-journal checkpointing—to prove that the +runtime owns more than pure projection. + +Python callers use the managed runtime and retain only DTO conversion plus +unmigrated external callbacks. Delete the Python semantic implementation and +its implementation-specific tests after parity and invariant coverage exist. + +### Stage 2 — Domain slices + +Migrate one bounded owner at a time, selected by duplicate-knowledge and +runtime value rather than file size. Candidate order is: + +1. todo lifecycle and completion fence; +2. quota settlement/spend reducers and typed receipts; +3. scheduler/monitor state transitions while host mutation remains delegated; +4. gates, capability resolution, and status projections; +5. event-store writer and multi-client authority. + +Each slice moves its tests with the rule. The repository does not first rewrite +the entire Python test suite into TypeScript, because tests without a migrated +owner would either call Python indirectly or duplicate implementation +assumptions. + +### Stage 3 — CLI and App convergence + +Ship a native TS CLI that imports the kernel in-process. Keep one automatically +selected authority path: direct in-process execution for CLI-only use, or the +managed daemon when the App/scheduler already owns the workspace. Remove the +Python bridge and its protocol after no production caller needs them. + +### Stage 4 — Distribution cleanup + +Package the kernel for npm and LoopX release artifacts, remove the Python +runtime requirement, and decide whether the optional daemon ships as a normal +Node entry point or a LoopX-built single executable. Do not silently depend on +an unofficial third-party Node wheel. + +## 5. First bounded PR contract + +The first PR is intentionally one coherent vertical replacement: + +- complete TS ownership of the existing Effect Program and settlement + semantics used by production callers; +- one managed, idle-exiting loopback runtime with transport separated from a + typed handler registry; +- centralized runtime decoders for migrated authority inputs; the first slice + carries no `as unknown as T` or `as never` assertion across its RPC boundary; +- TS-owned Turn-journal interpretation and atomic checkpoint write; +- Python compatibility facades switched to the TS owner, with replaced Python + rule code and obsolete tests deleted; +- Node readiness and actionable doctor output; +- automatic stale-PID and abandoned-start-lock recovery, stable public-safe + startup diagnostic codes, and one lifecycle health projection consumable by + CLI and App surfaces without a second health model; +- wheel and sdist inclusion, clean-environment probes, Windows coverage, crash + restart, idempotent retry, and upgrade fingerprinting; +- pinned-base characterization, native TS invariant tests, Python caller + regressions, and end-to-end latency evidence. + +It does **not** migrate the full CLI, todo/quota/scheduler domains, publish a +release, or authorize a second PR. It stops at an owner review gate. + +## 6. Correctness and performance gates + +### Correctness + +- Independently stated algebra properties: identity, associativity where + applicable, ordering, short-circuit, replay, and effect-id isolation. +- Exact output parity for the pinned characterization corpus. +- Negative cases for malformed state, cross-effect overwrite, partial commit, + cancellation, permission denial, and budget rejection. +- Boundary decoders reject missing fields, wrong types, unsupported schema + versions, and oversized or malformed payloads before domain dispatch. The + cutover inventory lists any remaining `as unknown as T` seam and proves that + it is guarded; promotion requires removing unvalidated assertions from the + migrated domain's authority inputs. +- Awaited writes emit receipts only after their declared durability point; + concurrent same-key mutations are serialized or use a tested CAS contract, + and retry identity distinguishes successive checkpoints within one Turn. +- Process crash and retry cannot duplicate a committed internal effect. +- Wheel and sdist are installed into fresh environments and execute deep + semantic probes from packaged files. + +Characterization output is evidence, not specification. If a pinned behavior +contradicts an independently reviewed invariant, the PR must disclose and +separately approve the behavior change. + +### Performance + +Measure cold startup separately from steady-state execution. The first PR must +report: + +- managed runtime cold-start p50/p95; +- warm typed request p50/p95; +- representative settlement transaction p50/p95; +- full CLI p50/p95 versus the pinned Python baseline; +- daemon memory after idle and under a bounded request burst. + +The default acceptance target is warm internal transitions below 2 ms p95 and +no material full-CLI regression (greater than 5% or an unexplained 25 ms +additive p95). A miss is an owner review gate, not a benchmark that may be +silently relaxed. + +## 7. Install, upgrade, and rollback + +The migration must not ask users to manage a service. The Python-transition +release may require Node.js 22.6 or newer, but installer and `loopx doctor` +must detect it before normal control-plane work and provide exact remediation. +The wheel and sdist carry the TS source and versioned schemas. + +The runtime is healthy while idle-exited: `stopped` means the next +control-plane request will start it automatically, not that the user must run a +daemon command. CLI and App surfaces consume the same lifecycle projection +(`running`, `stopped`, or `unavailable`) and stable diagnostic code. Raw stderr, +tokens, local paths, and private runtime metadata are not projected. + +The runtime fingerprint includes every executed TS module and contract. An +upgrade starts a runtime for the new fingerprint; an old process can finish +in-flight work and exits on idle. Requests carry stable effect identities, so a +transport retry is safe only for handlers that are explicitly idempotent. + +Rollback restores the previous artifact and fingerprint. Persisted state is +not rewritten into a TS-only format until a separately qualified state-schema +cutover. + +## 8. Non-goals and stop conditions + +- No permanent Python and TS semantic twins. +- No server per domain and no generic arbitrary-command executor. +- No big-bang CLI rewrite. +- No dual-write of production semantic state as a migration strategy. +- No performance claim from microbenchmarks alone. +- No later slice starts until the first PR's owner review accepts correctness, + performance, packaging, and maintainability evidence. + +Stop or replan if the bridge becomes user-managed, a migrated rule still has a +Python semantic owner, the handler boundary becomes chatty, or the first slice +cannot meet its parity/recovery/performance gates without weakening existing +behavior. diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 8e74b2bb5..2c2110ba3 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -1,172 +1,267 @@ # RFC:LoopX 控制面 TypeScript 渐进迁移方向 v0 -- Status: Draft,维护者评审中 -- Proposed by: LoopX maintainers -- Date: 2026-08-15 -- Scope: LoopX 控制面核心从 Python 到 TypeScript 的增量迁移策略; - 契约优先、parity 门禁、逐块替换 -- Source baseline: `d1fe05932` -- Tracking issue: [#3225](https://github.com/huangruiteng/loopx/issues/3225) -- Language note: 本中文版与 +- Status:Draft,首个 bounded cutover 等待维护者评审 +- Proposed by:LoopX maintainers +- Date:2026-08-15 +- Last revised:2026-08-21 +- Scope:LoopX 控制面核心从 Python 到 TypeScript 的增量、replacement-first + 迁移;不长期维护两份语义实现 +- Tracking issue:[#3225](https://github.com/huangruiteng/loopx/issues/3225) +- Language note:本中文版与 [英文版](./typescript-control-plane-migration-v0.md) 为语义镜像; 两者不一致视为缺陷。 --- -## 0. 示例 +## 0. 用一个例子说明决策 -某个宿主希望在不需要 Python 运行时的情况下内嵌 LoopX 的决策能力。现在 -Pi 扩展(`loopx/pi_goal_mode/loopx-goal.ts`)已经通过子进程调用 Python CLI -(`loopx quota should-run --runtime-profile generic_cli`)来完成 quota -决策。扩展本身的 goal loop 用 TypeScript 实现,只有决策内核是 Python。 +迁移期间,Python `loopx` CLI 向 LoopX 托管的 TypeScript runtime 发送一笔 +粗粒度 typed transaction。已迁移的 TypeScript 模块拥有规则和已经迁移的 +LoopX 内部 effect;Python 只保留 transport 与 legacy callback adapter。同一 +PR 会删除被替代的 Python 规则和只验证旧实现的测试。 -迁移问题是:这个切分能否不经过一次性重写,逐步长成完整的 TypeScript -控制面核心——Python 和 TypeScript 相互调用、一次迁移一块、用户体验不变。 - -本 RFC 的结论是:可以,但不是靠进程内互调,而是基于三个已有接缝做 -契约优先的 strangler 迁移:事件存储、parity fixture 层、CLI 边界。 +CLI 自身迁到 TypeScript 后,CLI-only 使用方式会在进程内直接 import 同一份 +kernel,Python 到 TypeScript 的桥随之删除。当 App、CLI、scheduler 或多个 +host 需要一个共享 writer 时,同一 kernel 可以运行在一个可选的 managed +daemon 内。这是一份 kernel 的两种部署形态,不是每个控制面状态族一个 +server。 ## 1. 问题 -- 外部项目已经在把 LoopX 内核移植到 TypeScript,最完整的草图是 - [Foreman PR #1](https://github.com/needware/foreman/pull/1)。 -- frontstage/dashboard 表面已经是 TypeScript。 -- 共享运行时能简化 CLI 分发与宿主集成,包括 npm 包。 -- 控制面核心约 34.3 万行 Python,`tests/` 与 `examples/` 下超过 1,200 - 个文件。一次性重写风险高、几乎不可评审,并且会把生产行为押在一次 - 切换上。 -- "Python 和 TypeScript 互相调"如果指进程内直接 import,不现实: - 在 Node 里嵌入 CPython、在 Python 里嵌入 V8 都有 GIL/ABI/打包成本; - Pyodide/WASM 有启动、单线程、文件系统与进程能力的限制。 - -因此真正的问题变成:哪条边界能承载"双语言渐进切换 + parity 保证 + -可回滚"? - -## 2. 决策 - -采用契约优先、parity 门禁、逐块迁移: - -1. 互调走进程边界 + JSON 契约(stdio JSON-RPC/NDJSON),不做进程内 - import。调用粒度是粗粒度的——一条 CLI 命令、一次投影渲染、一个决策 - 请求——所以单次调用延迟可接受。 -2. 事件存储是双语言的共同事实面:append-only 事件带版本化 schema - (`loopx_state_event_v0`),两种语言都从同一事件流构建投影。 -3. 读路径与投影先迁(纯函数、无副作用),然后是确定性决策内核 - (quota `should-run`、todo 生命周期迁移、scheduler 状态迁移),用 - parity fixture 与决策回放校验;事件存储与写路径最后迁,走 - dual-read → dual-write → flip 序列,带有限 canary 与已记录的 - rollback 计划。 -4. 过渡期内 Python 仍是权威实现。一个块只有在 parity 门禁通过且 - rollback 计划已记录之后才能翻转。 - -### 2.1 互调方案对比 - -| 方案 | 结论 | 说明 | -| --- | --- | --- | -| TS 子进程调 Python | ✅ 已在生产 | `pi_goal_mode` 用 `execFile("loopx", ...)`,30s 超时;粗粒度调用最简单、已被验证 | -| Python 子进程调 TS | ✅ 可行 | `node dist/...` + stdin JSON;已迁移的内核被 Python 调用时同构 | -| 长驻 sidecar(Unix socket 上 JSON-RPC) | ⚠️ 后续优化 | 摊薄启动开销,适合热路径;需要生命周期、版本与锁纪律 | -| Pyodide / WASM | ❌ 不适合生产 CLI | 启动秒级、单线程、文件系统/进程受限 | -| Rust 核心 + PyO3/napi-rs 双绑定 | ⚠️ 另一个项目 | 真正的共享核心 + Python/TS 薄绑定(类似 huggingface/tokenizers),但那是 Rust 重写,不是 TS 迁移 | - -## 3. 让该方案可行的现有接缝 - -迁移不是从零赌一个方案,仓库里已有三个接缝: - -- `loopx/pi_goal_mode/loopx-goal.ts` 与 `pi-goal-loop-runtime.mjs`: - 生产级 TypeScript 表面,通过进程边界把 quota 决策委托给 Python CLI。 -- `loopx/control_plane/testing/quota_should_run_parity.py`:用于新旧 quota - 构建器对比的 compact 稳定表面;是未来所有 parity fixture 的模板。 -- `loopx/control_plane/testing/decision_replay.py`:用历史真实输入回放决策 - 构建器——即双实现对比的 harness。 -- `loopx/control_plane/testing/cli_output_differential.py`:约束 CLI 输出 - 契约与增长预算。 -- `loopx/event_sourced_state.py`:append-only、schema 版本化的事件状态 - (`loopx_state_event_v0`)。 -- `loopx/control_plane/runtime/event_store_migration_bridge.py`:已建模 - dual-read parity、有限 canary 与事件投影晋升的 rollback 记录。 - -## 4. 迁移阶段 - -### Phase 0 — 契约冻结 - -把 #3225 的候选范围固化为类型化 schema 与 parity fixture 清单: -append-only 事件状态与幂等写入;todo 生命周期(claim、lease、status、 -revision、完成校验);gates 与决策范围;quota(`should-run`/spend)与 -scheduler/monitor 契约;Turn envelope 与事务语义;handoff 与 review-packet -投影;CLI 与 status/quota JSON parity。本阶段不迁移任何代码。 - -### Phase 1 — 读路径与投影 - -迁移 status JSON、todo list projection、handoff review-packet projection 与 -frontstage 渲染。这些是纯函数、无副作用。TypeScript 实现每个表面,Python -生成 golden fixture;dual-read 门禁要求 TS 投影与 Python 头一致后才能对外 -服务。 - -### Phase 2 — 确定性决策内核 - -迁移 `quota should-run`、todo 状态迁移与 scheduler 状态迁移规则。这些是 -无 IO 的纯决策,天然适合 parity 验证。`decision_replay` 把历史输入回放给 -两个实现;只有逐字段一致才允许翻转。此阶段完成后,Pi 扩展可以去掉对 -Python quota 的依赖。 - -### Phase 3 — 事件存储与写路径 - -TypeScript 先作为投影读取者(dual-read),再双写并做幂等校验,最后翻转 -写路径。每一步都套用 `event_store_migration_bridge` 的 canary 与 rollback -门禁。 - -### Phase 4 — 分发 - -发布 npm 包与 pip shim(或二者之一)。TypeScript CLI shim 把未迁移命令 -转发给 Python,`loopx` 的用户体验全程不变。 - -## 5. 互调契约 - -- 每个表面带版本化 JSON schema(`..._v0`)。 -- 请求/响应以 NDJSON 走 stdio;长驻 sidecar 可加 content-length 帧。 -- 错误用机器可读信封(code + message),不传原始 traceback。 -- 调用方设置超时,沿用现有 `LOOPX_CLI_TIMEOUT_MS` 模式。 -- 写入幂等:事件带稳定 id,重复应用是 no-op。 -- 边界上不出现凭据、原始日志或私有路径。 - -## 6. 验证 - -- 每个表面的 parity fixture:同一输入语料在两个实现上产生完全相同的 - compact JSON 输出。 -- 决策回放:历史输入回放两个实现。 -- Dual-read 门禁:TS 投影服务流量前必须与 Python 头一致。 -- 有限 canary:翻转前在一小组 canary goal 上运行新路径。 -- Rollback 记录:翻转由 flag 控制,翻转前记录回滚计划。 -- CLI 输出预算继续由 `cli_output_differential` 强制。 - -## 7. 非目标 - -- 不做行为变更;过渡期内 Python 仍是权威实现。 -- 不做进程内嵌入(Node 内嵌 CPython,或 Python 内嵌 V8)。 -- 不以 Pyodide/WASM 作为生产运行时。 -- 不做 fork-first 迁移;上游主导、欢迎贡献。 -- 不做控制面核心的一次性全量重写。 - -## 8. 开放问题 - -- 运行时选择:Node.js、Bun 还是 Deno? -- 打包/分发:npm 包、pip shim,还是两者都要? -- TypeScript 轨道的贡献者归属与评审通道? -- 热路径预算:哪些表面值得引入长驻 sidecar? -- 最终共享核心是否应改为 Rust + Python/TS 薄绑定(单独 RFC)? +LoopX 已有 TypeScript host 与 dashboard 表面,但权威控制面规则在 Python。 +一次性重写风险过高,长期保留双实现则更差:每次修 bug 都要改两份,parity +会从迁移工具变成永久产品能力。 + +因此,中间迁移节点必须同时满足: + +- 每条已迁规则只有一个语义 owner; +- 用户看不到 CLI 分叉,也无需手动管理 daemon; +- 可以迁移真实副作用,而不只迁纯投影; +- 基于 pinned 迁移前基线和独立定义的不变量验证正确性; +- 每次 cutover 都测量 latency、packaging、upgrade、rollback 与 crash recovery; +- 每个 PR 都是完整、可评审的 replacement slice。 + +## 2. 架构决策 + +### 2.1 一份 TypeScript kernel + +`@loopx/control-plane` 是目标语义 kernel。Domain module 拥有 typed state、 +解释、transition rule 和属于这些规则的内部 effect。Transport shell 不能成为 +第二个业务 owner。 + +```text +迁移期 Python CLI ─────────┐ +LoopX App / scheduler ─────┼─> 一个 typed runtime boundary ─> TS kernel +未来 TS CLI ───────────────┘ +``` + +边界传递“结算这个 Turn”“提交这个 journal”这类粗粒度、版本化请求,而不是 +频繁的属性 getter。Runtime 只有一个静态 typed handler registry;新增 domain +handler 不会新增 server。 + +### 2.2 两种部署形态,一份实现 + +| 产品拓扑 | 执行形态 | +| --- | --- | +| TS CLI cutover 后的 CLI-only | CLI 进程内 import 并执行 TS kernel;没有 daemon | +| 仅 App | App runtime 内嵌同一 kernel | +| App + CLI + scheduler,或多个并发 client | 一个 managed local authority daemon;client 连接当前 writer | +| Python 仍是 CLI 的迁移期 | 一个 idle-exiting loopback runtime 把 Python 桥接到已迁 TS kernel | + +如果 authority daemon 已拥有某个 registry/workspace,CLI 必须连接它,而不能 +绕过它再打开第二个直接 writer。Runtime discovery 与启动全自动;用户无需配置 +端口或守护进程。 + +### 2.3 TypeScript 拥有已迁 effect + +目标不是“TypeScript 决策、Python 永远执行”。TypeScript 可以拥有 atomic +state checkpoint、event append、receipt commit、幂等 reducer write 等 LoopX +内部 effect。每个 effect 都有 typed request、稳定 idempotency identity、typed +receipt 与 retry policy。 + +异步执行不会削弱 settlement ordering:只有被 `await` 的 durability boundary +成功后,才能发出 effect receipt。但异步允许请求并发,因此拥有已迁写入 authority +的一方也必须拥有按 key 串行化或 compare-and-swap 合同。Caller-side lock 只能作为 +明确的迁移期 guard;native TypeScript caller 在 cutover 后不得绕过这个 invariant。 +Retry identity 必须绑定具体 operation:当一个 Turn effect 连续 checkpoint 多个 +journal 状态时,仅凭宽粒度 Turn effect id 不能证明两次写入 payload 是同一 operation。 + +外部 authority 仍是显式 adapter:model call、human gate、host scheduler、 +credential 和第三方 mutation 不会藏到一个万能 executor 后面。它们的 receipt +回到 Effect Program 完成 settlement。 + +### 2.4 替换,而不是生产双跑 + +Characterization 可以离线让新旧实现运行同一份 pinned corpus。生产环境不保留 +两个 rule engine,也不 dual-write semantic state。一个 slice 通过门禁后,caller +翻到 TypeScript,并删除被替代的 Python 规则。只有真实 public import、持久化 +schema 或未迁 callback 需要时,才保留窄 compatibility facade。 + +### 2.5 在每个信任边界只验证一次 + +TypeScript 类型在运行时会被擦除。因此 network/RPC payload、解析后的 JSON、 +持久化状态、extension 输入与 adapter response 都必须以 `unknown` 进入系统; +静态类型标注或 `as T` 断言不能证明这些字节满足合同。每个已迁 domain 都必须先 +通过 typed decoder 或显式的版本化 schema parser 解码,再交给 domain handler +或 Effect interpreter 消费。 + +解码成功后,TypeScript kernel 拥有这个 typed value,domain 内部可以依赖编译器, +而不必在每层重复临时字段检查。Framing、authentication、size limit 等 transport +检查与 schema validation、semantic invariant 分层负责。未经检查的 +`JSON.parse(...) as T` 不能建立控制面 authority。 + +`as unknown as T` 只允许作为具名迁移缝:cutover PR 必须明确其调用点、上游 +validator、负向边界覆盖和移除 owner。只要 public、持久化、RPC 或 extension +输入仍通过未经验证的断言进入已迁 domain 的 semantic core,该 domain 就不能 +通过 promotion gate。TypeScript 补充运行时验证,而不是替代它。 + +## 3. 为什么先迁 Effect Program + +Effect Program 是已经连接 ordered step、identity、short-circuit failure、replay、 +receipt 与 settlement 的底层合同。先迁它,后续 todo、quota、scheduler 和 gate +就能共用一套 typed execution language,而不是各自发明跨语言合同。 + +这不意味着把所有状态机塞进一个通用 protocol。Domain transition invariant +仍属于 domain owner。只有真实 caller 能切换、且 PR 能删除相应 Python 知识时, +才迁一个状态族。 + +## 4. 迁移顺序 + +### Stage 0 — 固定行为与 authority + +对选中的 slice 记录: + +- 权威 schema 和经过独立 review 的合法/非法 transition; +- pinned-base characterization fixtures; +- 生产 caller 与 side effects; +- latency 与 package/install 基线; +- rollback boundary 与 state compatibility。 + +### Stage 1 — Effect Program cutover + +把 Effect algebra 与 normal-Turn settlement 语义迁到 TypeScript:ordered +program、settlement identity、bind/short-circuit、replay、receipt construction、 +next-action selection 和 commit reduction。增加第一个 native internal effect—— +atomic Turn-journal checkpoint——证明 runtime 不只拥有纯投影。 + +Python caller 使用 managed runtime,只保留 DTO conversion 和未迁外部 callback。 +Parity 与 invariant coverage 成立后,删除 Python 语义实现及其 implementation- +specific tests。 + +### Stage 2 — Domain slices + +一次迁一个 bounded owner,按重复知识与 runtime 价值选,不按文件大小选。候选 +顺序是: + +1. todo lifecycle 与 completion fence; +2. quota settlement/spend reducer 与 typed receipt; +3. scheduler/monitor state transition,host mutation 仍保持 delegated; +4. gate、capability resolution 与 status projection; +5. event-store writer 与 multi-client authority。 + +测试跟随规则一起迁。仓库不会先把整套 Python 测试改写成 TypeScript,因为 +没有迁移 owner 的 TS 测试要么间接调用 Python,要么复制实现假设。 + +### Stage 3 — CLI 与 App 汇合 + +交付 native TS CLI,并在进程内 import kernel。只保留一个自动选择的 authority +路径:CLI-only 时进程内直接执行;App/scheduler 已拥有 workspace 时连接 managed +daemon。所有生产 caller 不再需要 Python bridge 后,删除 bridge 与协议。 + +### Stage 4 — 清理分发 + +通过 npm 与 LoopX release artifact 分发 kernel,删除 Python runtime 依赖,并 +决定可选 daemon 使用普通 Node entry point 还是 LoopX 自建 single executable。 +不要静默依赖非官方第三方 Node wheel。 + +## 5. 首个 bounded PR 合同 + +第一个 PR 是一个有意收敛的完整 vertical replacement: + +- TypeScript 完整拥有现有生产 caller 使用的 Effect Program 与 settlement 语义; +- 一个 managed、idle-exiting loopback runtime,transport 与 typed handler + registry 分离; +- 对已迁 authority input 使用集中式 runtime decoder;首个 slice 不允许 + `as unknown as T` 或 `as never` 断言跨过 RPC 边界; +- TS-owned Turn-journal interpretation 与 atomic checkpoint write; +- Python compatibility facade 切到 TS owner,并删除被替代的 Python rule code + 与 obsolete tests; +- Node readiness 与可行动的 doctor 输出; +- 自动恢复 stale PID 与 abandoned startup lock,提供稳定、public-safe 的启动 + diagnostic code,并让 CLI 与 App 消费同一个 lifecycle health projection, + 不另建第二套健康模型; +- wheel/sdist 包含、clean-environment probe、Windows coverage、crash restart、 + idempotent retry 与 upgrade fingerprint; +- pinned-base characterization、native TS invariant tests、Python caller regressions + 与 end-to-end latency evidence。 -## 9. 最小可用实现切片 +它**不**迁完整 CLI,不迁 todo/quota/scheduler domain,不发布 release,也不授权 +第二个 PR。完成后停在 owner review gate。 + +## 6. 正确性与性能门禁 + +### 正确性 + +- 独立定义 algebra properties:identity、适用场景下的 associativity、ordering、 + short-circuit、replay 与 effect-id isolation。 +- pinned characterization corpus 输出精确一致。 +- malformed state、cross-effect overwrite、partial commit、cancellation、 + permission denial 与 budget rejection 的负例。 +- 边界 decoder 必须在 domain dispatch 前拒绝缺失字段、错误类型、不支持的 schema + 版本,以及 oversized 或 malformed payload。Cutover inventory 必须列出仍存在的 + `as unknown as T` 迁移缝并证明其已受保护;promotion 要求移除已迁 domain + authority 输入上的未经验证断言。 +- 被 `await` 的写入只有在其声明的 durability point 成功后才能发出 receipt;同 key + 并发 mutation 必须串行化或使用经过测试的 CAS 合同,retry identity 必须区分同一 + Turn 内连续发生的 checkpoint。 +- 进程 crash 与 retry 不得重复已经提交的内部 effect。 +- wheel 与 sdist 安装到全新环境后,从打包文件执行 deep semantic probe。 -用 TypeScript 实现 `quota should-run` 的 compact parity 表面,与 -`quota_should_run_parity.py` 使用同一 fixture 语料,输出完全一致的 JSON。 -可选配一个读路径探针:TypeScript 的 todo list/status 投影渲染与 Python -投影相同的事件 fixtures。这能以接近零的生产风险验证整条管线——契约、 -parity fixtures、双实现与进程边界。 +Characterization output 是证据,不是 specification。Pinned 行为若与独立 review 的 +invariant 冲突,PR 必须披露,并把行为变更单独批准。 -## 10. 上线与回滚 +### 性能 + +Cold startup 与 steady-state 分开测量。第一个 PR 必须报告: -每个块都走同一序列:双实现 → parity 门禁 → dual-read(读路径)→ 有限 -canary → flag 控制翻转 → 记录回滚计划。任何 parity 不一致都会阻止翻转。 -回滚即翻回并保留双实现,直到该块重新通过验证;用户状态不会二次迁移, -也不会留下半迁移状态。 +- managed runtime cold-start p50/p95; +- warm typed request p50/p95; +- representative settlement transaction p50/p95; +- 相比 pinned Python baseline 的完整 CLI p50/p95; +- idle 后和 bounded request burst 下的 daemon 内存。 + +默认验收目标是 warm internal transition p95 低于 2 ms,完整 CLI 不出现物质 +回退(p95 超过 5% 或出现无法解释的 25 ms 额外开销)。不达标是 owner review +gate,不能静默放宽 benchmark。 + +## 7. 安装、升级与回滚 + +迁移不能要求用户管理服务。Python 过渡版本可以要求 Node.js 22.6 或更新版本, +但 installer 与 `loopx doctor` 必须在正常控制面工作前检测,并给出精确修复方式。 +Wheel 与 sdist 携带 TS source 和版本化 schema。 + +Runtime 因 idle 退出时仍是健康状态:`stopped` 表示下一次控制面请求会自动拉起, +不表示用户需要手工执行 daemon 命令。CLI 与 App 消费同一个 lifecycle projection +(`running`、`stopped` 或 `unavailable`)和稳定 diagnostic code;raw stderr、token、 +本地路径和私有 runtime metadata 不进入投影。 + +Runtime fingerprint 包含每个实际执行的 TS module 与 contract。升级会启动新 +fingerprint 的 runtime;旧进程可完成 in-flight work,并在 idle 后退出。Request +携带稳定 effect identity;只有显式幂等的 handler 才允许 transport retry。 + +Rollback 恢复上一版本 artifact 与 fingerprint。在单独通过 state-schema cutover +前,不把持久化状态改写为 TS-only 格式。 + +## 8. 非目标与停止条件 + +- 不永久维护 Python/TS 语义双胞胎。 +- 不为每个 domain 建 server,也不建 arbitrary-command 通用 executor。 +- 不 big-bang 重写 CLI。 +- 不以 dual-write production semantic state 作为迁移策略。 +- 不只凭 microbenchmark 声称性能。 +- 首个 PR 的正确性、性能、packaging 与 maintainability evidence 未通过 owner + review 前,不开始下一 slice。 + +如果 bridge 需要用户手动管理、已迁规则仍有 Python 语义 owner、handler boundary +变得 chatty,或首个 slice 只能靠削弱既有行为才能通过 parity/recovery/performance +门禁,就停止或 replan。 diff --git a/docs/guides/installing-loopx.md b/docs/guides/installing-loopx.md index 0a949ff39..42e8303f0 100644 --- a/docs/guides/installing-loopx.md +++ b/docs/guides/installing-loopx.md @@ -10,7 +10,26 @@ loopx workflow-skills --install loopx doctor ``` -The package has no runtime dependencies outside the Python standard library. +LoopX's Effect Program core runs in a managed, idle-exiting TypeScript runtime +and requires Node.js 22.6 or later. LoopX starts and reuses that local runtime +automatically; users do not run a daemon manually. The runtime binds only to +loopback, authenticates requests with a user-private token, rotates when the +packaged Effect core changes, and exits after an idle period. `loopx doctor` +reports it as `ready`, `missing`, `unsupported`, or `probe_failed`; a missing +or stale runtime fails closed instead of falling back to a second Python rule +engine. The same doctor projection exposes `runtime_lifecycle.state` as +`running`, `stopped`, or `unavailable`, plus a public-safe `diagnostic_code`; +the App can render this projection without inventing a second health model. +`stopped` is healthy and means the idle-exited runtime will restart on the next +control-plane request. Validate Node before installing or upgrading LoopX: + +```bash +node --version +# v22.6.0 or newer +``` + +Use `loopx doctor --deep` after installation to start the managed runtime and +exercise the packaged Effect semantics and native journal checkpoint handler. `workflow-skills --install` copies the packaged LoopX workflow skills into the user's Codex skill directory and writes a revision readback; it does not change project state or grant repository, network, or merge authority. Restart the @@ -111,7 +130,19 @@ loopx doctor `loopx doctor` reports `install_kind: python_distribution` for this path and returns the same pip-native repair sequence when packaged skills are missing or -stale. +stale. It also verifies the required TypeScript Effect runtime before a +control-plane upgrade is considered healthy. Runtime metadata is fingerprinted +by the installed sources, so an upgraded LoopX starts a matching process while +an older process exits after becoming idle. When a release needs to be rolled +back, reinstall the previously selected version, refresh the packaged host +material, and validate again: + +```bash +python3 -m pip install "loopx==" +loopx workflow-skills --install +loopx slash-commands --install +loopx doctor +``` ## Archive Fallback diff --git a/docs/project/technical-directions.md b/docs/project/technical-directions.md index 16fb6a3f9..4f8659a15 100644 --- a/docs/project/technical-directions.md +++ b/docs/project/technical-directions.md @@ -133,7 +133,7 @@ remain later explicit decisions. | Exploration | Stage | Current entry | Implementation rule | | --- | --- | --- | --- | | Effect Program and settlement algebra | Accepted / runtime hardening | [RFC](../architecture/rfcs/agent-loop-effect-interpreter-v0.md) | Improve the shared typed contract and negative coverage; keep scheduler ownership and domain-local ACK semantics explicit. | -| TypeScript control-plane migration | Draft / parity experiment | [#3225](https://github.com/huangruiteng/loopx/issues/3225) | Start with process-boundary parity over existing fixtures; Python remains canonical during transition. | +| TypeScript control-plane migration | Draft / first bounded cutover | [RFC](../architecture/rfcs/typescript-control-plane-migration-v0.md) | Replace the Effect Program vertical first behind one managed runtime; keep one semantic owner per migrated rule and stop at owner review before later domain slices. | | Hierarchical agent stride | Active research | [#3203](https://github.com/huangruiteng/loopx/issues/3203) | Qualify read-only and shadow evidence before adaptive selection. | | Research exploration control plane | Draft / typed frontier | [RFC](../architecture/rfcs/research-exploration-control-plane-v0.md) | Keep Explore, goal-frontier, and execution authority separate. | | Human Attention Wishlist | Draft / non-blocking sidecar | [#3179](https://github.com/huangruiteng/loopx/issues/3179) | Do not change user gates, selected work, quota, or notification authority. | diff --git a/docs/project/technical-directions.zh-CN.md b/docs/project/technical-directions.zh-CN.md index d5a6d1970..4ecd66fa5 100644 --- a/docs/project/technical-directions.zh-CN.md +++ b/docs/project/technical-directions.zh-CN.md @@ -118,7 +118,7 @@ replay。真实 NoKV qualification、renew/reclaim、distributed quota、认证 | 探索 | 阶段 | 当前入口 | 实现规则 | | --- | --- | --- | --- | | Effect Program 与 settlement algebra | Accepted / runtime hardening | [RFC](../architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md) | 改善共享 typed contract 与 negative coverage;明确 scheduler ownership 和 domain-local ACK 语义。 | -| TypeScript 控制面迁移 | Draft / parity experiment | [#3225](https://github.com/huangruiteng/loopx/issues/3225) | 从基于已有 fixture 的进程边界 parity 开始;迁移期内 Python 保持 canonical。 | +| TypeScript 控制面迁移 | Draft / 首个 bounded cutover | [RFC](../architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md) | 先在一个 managed runtime 后替换 Effect Program vertical;每条已迁规则只保留一个语义 owner,后续 domain slice 必须等待 owner review。 | | 分层 Agent stride | Active research | [#3203](https://github.com/huangruiteng/loopx/issues/3203) | 引入 adaptive selection 前先验证 read-only 与 shadow evidence。 | | 研究型探索控制面 | Draft / typed frontier | [RFC](../architecture/rfcs/research-exploration-control-plane-v0.zh-CN.md) | 保持 Explore、goal-frontier 和 execution authority 分离。 | | Human Attention Wishlist | Draft / non-blocking sidecar | [#3179](https://github.com/huangruiteng/loopx/issues/3179) | 不改变 user gate、selected work、quota 或 notification authority。 | diff --git a/loopx/control_plane/effect_program.py b/loopx/control_plane/effect_program.py index 94e302e3e..7e6337cd0 100644 --- a/loopx/control_plane/effect_program.py +++ b/loopx/control_plane/effect_program.py @@ -1,9 +1,14 @@ from __future__ import annotations +import json from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field from enum import StrEnum -from typing import Any, Generic, TypeVar, cast +from functools import lru_cache +from pathlib import Path +from typing import Any, Generic, TypeVar + +from .effect_runtime import EffectRuntimeRejected, effect_runtime_result # Identity remains stable while the plan and receipt versions advance with the @@ -75,30 +80,13 @@ class EffectProgram: execution_mode: str | None = None -class TurnTransactionPhase(StrEnum): - HOST_EXECUTE = "host_execute" - TYPED_RESULT = "typed_result" - VALIDATION = "validation" - DURABLE_WRITEBACK = "durable_writeback" - QUOTA_SPEND = "quota_spend" - SCHEDULER_APPLY = "scheduler_apply" - SCHEDULER_ACK = "scheduler_ack" - - -TURN_TRANSACTION_PHASES = tuple(phase.value for phase in TurnTransactionPhase) - - -class TurnJournalViolation(StrEnum): - GOAL_IDENTITY_MISSING = "goal_identity_missing" - GOAL_MISMATCH = "goal_mismatch" - OWNER_IDENTITY_MISSING = "owner_identity_missing" - OWNER_MISMATCH = "owner_mismatch" - TURN_KEY_IDENTITY_MISSING = "turn_key_identity_missing" - TURN_KEY_MISMATCH = "turn_key_mismatch" - COMPLETED_PHASES_INVALID = "completed_phases_invalid" - COMPLETED_PHASES_NOT_ORDERED_PREFIX = "completed_phases_not_ordered_prefix" - JOURNAL_NOT_TERMINAL = "journal_not_terminal" - JOURNAL_STATUS_UNSUPPORTED = "journal_status_unsupported" +_TURN_TRANSACTION_CONTRACT_PATH = Path(__file__).with_name( + "turn_transaction_contract.json" +) +_TURN_TRANSACTION_CONTRACT = json.loads( + _TURN_TRANSACTION_CONTRACT_PATH.read_text(encoding="utf-8") +) +TURN_TRANSACTION_PHASES = tuple(_TURN_TRANSACTION_CONTRACT["phases"]) class SettlementStepKind(StrEnum): @@ -127,6 +115,36 @@ class SettlementFailureKind(StrEnum): BUDGET_REJECTED = "budget_rejected" +@lru_cache(maxsize=4096) +def _settlement_identity_full( + goal_id: str, + agent_id: str, + todo_id: str | None, + turn_instance_id: str, + replan_obligation_id: str | None, +) -> tuple[dict[str, Any], dict[str, Any]]: + try: + result = effect_runtime_result( + "settlement.identity_full", + { + "goal_id": goal_id, + "agent_id": agent_id, + "todo_id": todo_id, + "turn_instance_id": turn_instance_id, + "replan_obligation_id": replan_obligation_id, + }, + ) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from None + if not isinstance(result, Mapping): + raise RuntimeError("TypeScript settlement identity shape mismatch") + identity = result.get("identity") + payload = result.get("payload") + if not isinstance(identity, Mapping) or not isinstance(payload, Mapping): + raise RuntimeError("TypeScript settlement identity shape mismatch") + return dict(identity), dict(payload) + + @dataclass(frozen=True, slots=True) class SettlementIdentity: goal_id: str @@ -134,72 +152,46 @@ class SettlementIdentity: todo_id: str | None turn_instance_id: str replan_obligation_id: str | None = None + _runtime_identity: Mapping[str, Any] = field( + init=False, + repr=False, + compare=False, + ) + _runtime_payload: Mapping[str, Any] = field( + init=False, + repr=False, + compare=False, + ) def __post_init__(self) -> None: - todo_id = str(self.todo_id or "").strip() - replan_obligation_id = str(self.replan_obligation_id or "").strip() - if todo_id and replan_obligation_id: - raise ValueError( - "settlement identity cannot bind both todo_id and " - "replan_obligation_id" - ) - object.__setattr__(self, "todo_id", todo_id or None) + identity, payload = _settlement_identity_full( + self.goal_id, + self.agent_id, + self.todo_id, + self.turn_instance_id, + self.replan_obligation_id, + ) + object.__setattr__(self, "todo_id", identity.get("todo_id")) object.__setattr__( - self, - "replan_obligation_id", - replan_obligation_id or None, + self, "replan_obligation_id", identity.get("replan_obligation_id") ) + object.__setattr__(self, "_runtime_identity", identity) + object.__setattr__(self, "_runtime_payload", payload) @property def binding_kind(self) -> SettlementBindingKind: - if self.todo_id: - return SettlementBindingKind.TODO - if self.replan_obligation_id: - return SettlementBindingKind.AUTONOMOUS_REPLAN - return SettlementBindingKind.UNBOUND + return SettlementBindingKind(str(self._runtime_identity["binding_kind"])) @property def binding_id(self) -> str: - return str(self.todo_id or self.replan_obligation_id or "").strip() + return str(self._runtime_identity["binding_id"]) @property def effect_id(self) -> str: - if self.todo_id: - # Preserve the v0 Todo-bound effect id for compatibility with - # already-persisted receipts. - return ( - f"{self.goal_id}:{self.agent_id}:{self.todo_id}:" - f"{self.turn_instance_id}" - ) - if self.replan_obligation_id: - return ( - f"{self.goal_id}:{self.agent_id}:autonomous_replan:" - f"{self.replan_obligation_id}:{self.turn_instance_id}" - ) - return ( - f"{self.goal_id}:{self.agent_id}::{self.turn_instance_id}" - ) + return str(self._runtime_identity["effect_id"]) def as_dict(self) -> dict[str, str]: - if self.todo_id or not self.replan_obligation_id: - return { - "schema_version": SETTLEMENT_IDENTITY_SCHEMA_VERSION, - "effect_id": self.effect_id, - "goal_id": self.goal_id, - "agent_id": self.agent_id, - "todo_id": str(self.todo_id or ""), - "turn_instance_id": self.turn_instance_id, - } - return { - "schema_version": SCOPED_SETTLEMENT_IDENTITY_SCHEMA_VERSION, - "effect_id": self.effect_id, - "goal_id": self.goal_id, - "agent_id": self.agent_id, - "turn_instance_id": self.turn_instance_id, - "binding_kind": self.binding_kind, - "binding_id": self.binding_id, - "replan_obligation_id": str(self.replan_obligation_id), - } + return {key: str(value) for key, value in self._runtime_payload.items()} @dataclass(frozen=True, slots=True) @@ -239,6 +231,67 @@ def as_dict(self) -> dict[str, Any]: return result +def _runtime_receipt(receipt: SettlementReceipt) -> dict[str, Any]: + return { + "step_kind": receipt.step_kind.value, + "status": receipt.status, + "effect_id": receipt.effect_id, + **({"source_ref": receipt.source_ref} if receipt.source_ref else {}), + } + + +def _runtime_failure(failure: SettlementFailure | None) -> dict[str, Any] | None: + if failure is None: + return None + return { + "kind": failure.kind.value, + "step_kind": failure.step_kind.value, + "reason": failure.reason, + **({"details": dict(failure.details)} if failure.details else {}), + } + + +def _runtime_result(result: SettlementResult[Any]) -> dict[str, Any]: + return { + "value": None, + "receipts": [_runtime_receipt(receipt) for receipt in result.receipts], + "failure": _runtime_failure(result.failure), + } + + +def _runtime_result_metadata( + payload: Any, +) -> tuple[tuple[SettlementReceipt, ...], SettlementFailure | None]: + if not isinstance(payload, Mapping): + raise RuntimeError("TypeScript settlement result shape mismatch") + receipts_value = payload.get("receipts") + receipts = ( + tuple( + SettlementReceipt( + step_kind=SettlementStepKind(str(receipt["step_kind"])), + status=str(receipt["status"]), + effect_id=str(receipt["effect_id"]), + source_ref=str(receipt.get("source_ref") or "") or None, + ) + for receipt in receipts_value + if isinstance(receipt, Mapping) + ) + if isinstance(receipts_value, list) + else () + ) + failure_value = payload.get("failure") + failure = None + if isinstance(failure_value, Mapping): + details = failure_value.get("details") + failure = SettlementFailure( + kind=SettlementFailureKind(str(failure_value["kind"])), + step_kind=SettlementStepKind(str(failure_value["step_kind"])), + reason=str(failure_value["reason"]), + details=dict(details) if isinstance(details, Mapping) else None, + ) + return receipts, failure + + T = TypeVar("T") U = TypeVar("U") @@ -280,17 +333,31 @@ def failed( ) def bind(self, step: Callable[[T], SettlementResult[U]]) -> SettlementResult[U]: - if self.failure is not None: + gate = effect_runtime_result( + "settlement.bind_gate", + {"result": _runtime_result(self)}, + ) + if not isinstance(gate, Mapping) or not isinstance(gate.get("execute"), bool): + raise RuntimeError("TypeScript settlement bind gate shape mismatch") + if gate["execute"] is False: return SettlementResult( value=None, receipts=self.receipts, failure=self.failure, ) - next_result = step(cast(T, self.value)) + next_result = step(self.value) # type: ignore[arg-type] + reduced = effect_runtime_result( + "settlement.bind_reduce", + { + "current": _runtime_result(self), + "next": _runtime_result(next_result), + }, + ) + receipts, failure = _runtime_result_metadata(reduced) return SettlementResult( value=next_result.value, - receipts=(*self.receipts, *next_result.receipts), - failure=next_result.failure, + receipts=receipts, + failure=failure, ) @@ -325,58 +392,116 @@ class SettlementPlan: steps: tuple[SettlementStep, ...] def as_dict(self) -> dict[str, Any]: - return { - "schema_version": SETTLEMENT_PLAN_SCHEMA_VERSION, - "identity": self.identity.as_dict(), - "ordered_steps": [step.as_dict() for step in self.steps], - "host_handoff": { - "owner": "host", - "kind": "scheduler_handoff", - "inside_agent_settlement": False, + payload = effect_runtime_result( + "settlement.plan_payload", + { + "plan": { + "identity": self.identity.as_dict(), + "steps": [step.as_dict() for step in self.steps], + } }, - } + ) + if not isinstance(payload, Mapping): + raise RuntimeError("TypeScript settlement plan shape mismatch") + return dict(payload) def settlement_result_payload(result: SettlementResult[Any]) -> dict[str, Any]: - return { - "ok": result.failure is None, - "receipts": [receipt.as_dict() for receipt in result.receipts], - "failure": result.failure.as_dict() if result.failure else None, - } - - -def _mapping(value: Any) -> Mapping[str, Any]: - return value if isinstance(value, Mapping) else {} - - -def _valid_identity_value(value: Any) -> bool: - return isinstance(value, str) and bool(value.strip()) - - -def _identity_state( - required_values: Sequence[Any], - *, - optional_values: Sequence[tuple[bool, Any]] = (), - expected: str | None = None, -) -> tuple[bool, bool]: - required_complete = all( - _valid_identity_value(value) for value in required_values + payload = effect_runtime_result( + "settlement.result_payload", + {"result": _runtime_result(result)}, ) - optional_complete = all( - not present or _valid_identity_value(value) - for present, value in optional_values - ) - expected_complete = expected is None or _valid_identity_value(expected) - complete = required_complete and optional_complete and expected_complete - observed = [value for value in required_values if _valid_identity_value(value)] - observed.extend( - value - for present, value in optional_values - if present and _valid_identity_value(value) + if not isinstance(payload, Mapping): + raise RuntimeError("TypeScript settlement result payload shape mismatch") + return dict(payload) + + +def _effect_turn_from_payload(payload: Any) -> EffectTurn: + if not isinstance(payload, Mapping): + raise RuntimeError("TypeScript Effect turn shape mismatch") + request = payload.get("request") + interpretation = payload.get("interpretation") + observation = payload.get("observation") + next_effect = payload.get("next_effect") + if not all( + isinstance(value, Mapping) + for value in (request, interpretation, observation, next_effect) + ): + raise RuntimeError("TypeScript Effect turn shape mismatch") + assert isinstance(request, Mapping) + assert isinstance(interpretation, Mapping) + assert isinstance(observation, Mapping) + assert isinstance(next_effect, Mapping) + context = request.get("context") + normalized_context = dict(context) if isinstance(context, Mapping) else {} + if isinstance(normalized_context.get("completed_phases"), list): + normalized_context["completed_phases"] = tuple( + str(phase) for phase in normalized_context["completed_phases"] + ) + return EffectTurn( + request=EffectRequest( + kind=str(request.get("kind") or ""), + source=str(request.get("source") or ""), + goal_id=str(request.get("goal_id")) + if request.get("goal_id") is not None + else None, + agent_id=str(request.get("agent_id")) + if request.get("agent_id") is not None + else None, + capabilities=tuple(str(item) for item in request.get("capabilities", [])), + context=normalized_context, + ), + interpretation=EffectInterpretation( + route=str(interpretation.get("route") or ""), + obligation=str(interpretation.get("obligation") or ""), + interaction_mode=str(interpretation.get("interaction_mode") or ""), + capability_action=( + str(interpretation["capability_action"]) + if interpretation.get("capability_action") is not None + else None + ), + cadence_class=( + str(interpretation["cadence_class"]) + if interpretation.get("cadence_class") is not None + else None + ), + ), + observation=EffectObservation( + decision=str(observation.get("decision") or ""), + should_run=observation.get("should_run") is True, + effective_action=str(observation.get("effective_action") or ""), + recommended_action=str(observation.get("recommended_action") or ""), + protocol_summary=( + str(observation["protocol_summary"]) + if observation.get("protocol_summary") is not None + else None + ), + ), + next_effect=EffectNext( + cli_actions=tuple(str(item) for item in next_effect.get("cli_actions", [])), + execution_mode=( + str(next_effect["execution_mode"]) + if next_effect.get("execution_mode") is not None + else None + ), + scheduler_action=( + str(next_effect["scheduler_action"]) + if next_effect.get("scheduler_action") is not None + else None + ), + cadence_class=( + str(next_effect["cadence_class"]) + if next_effect.get("cadence_class") is not None + else None + ), + ack_cli_args=tuple( + str(item) for item in next_effect.get("ack_cli_args", []) + ), + failure_cli_args=tuple( + str(item) for item in next_effect.get("failure_cli_args", []) + ), + ), ) - if expected is not None and _valid_identity_value(expected): - observed.append(expected) - return complete, complete and len(set(observed)) == 1 def effect_program_from_ordered_steps( @@ -386,30 +511,32 @@ def effect_program_from_ordered_steps( ) -> EffectProgram: """Map existing `guided_transaction.ordered_steps` onto an effect program.""" - steps: list[EffectStep] = [] - for step in ordered_steps: - if not isinstance(step, Mapping): - continue - steps.append( - EffectStep( - step_id=str(step.get("id") or "") or None, - kind=str(step.get("kind") or "") or None, - command=( - str( - step.get("command") - or step.get("command_template") - or step.get("prompt") - or "" - ) - or None - ), - purpose=str(step.get("purpose") or "") or None, - raw=dict(step), - ) + payload = effect_runtime_result( + "effect.program_from_ordered_steps", + { + "ordered_steps": [ + dict(step) if isinstance(step, Mapping) else step + for step in ordered_steps + ], + "execution_mode": execution_mode, + }, + ) + if not isinstance(payload, Mapping) or not isinstance(payload.get("steps"), list): + raise RuntimeError("TypeScript Effect Program shape mismatch") + steps = tuple( + EffectStep( + step_id=str(step["step_id"]) if step.get("step_id") is not None else None, + kind=str(step["kind"]) if step.get("kind") is not None else None, + command=str(step["command"]) if step.get("command") is not None else None, + purpose=str(step["purpose"]) if step.get("purpose") is not None else None, + raw=dict(step["raw"]) if isinstance(step.get("raw"), Mapping) else {}, ) + for step in payload["steps"] + if isinstance(step, Mapping) + ) + mode = payload.get("execution_mode") return EffectProgram( - steps=tuple(steps), - execution_mode=str(execution_mode or "") or None, + steps=steps, execution_mode=str(mode) if mode is not None else None ) @@ -421,198 +548,18 @@ def interpret_quota_should_run_packet( capabilities: Sequence[str] = (), ) -> EffectTurn: """Map an existing `quota should-run` packet onto canonical effect slots.""" - - interaction = _mapping(packet.get("interaction_contract")) - lane = _mapping(packet.get("work_lane_contract")) - scheduler = _mapping(packet.get("scheduler_hint")) - codex_app = _mapping(scheduler.get("codex_app")) - ack_hint = _mapping(codex_app.get("ack_hint")) - failure_hint = _mapping(codex_app.get("failure_hint")) - cli_channel = _mapping(interaction.get("cli_channel")) - gate = packet.get("capability_gate") - protocol = _mapping(packet.get("protocol_action_packet")) - - request = EffectRequest( - kind="quota_should_run", - source="agent_or_host", - goal_id=goal_id, - agent_id=agent_id, - capabilities=tuple(capabilities), - ) - interpretation = EffectInterpretation( - route=str(lane.get("lane") or ""), - obligation=str(lane.get("obligation") or ""), - interaction_mode=str(interaction.get("mode") or ""), - capability_action=( - str(gate.get("action")) if isinstance(gate, Mapping) else None - ), - cadence_class=str(scheduler.get("cadence_class") or None) or None, - ) - observation = EffectObservation( - decision=str(packet.get("decision") or ""), - should_run=bool(packet.get("should_run")), - effective_action=str(packet.get("effective_action") or ""), - recommended_action=str(packet.get("recommended_action") or ""), - protocol_summary=( - str(protocol.get("summary")) if protocol.get("summary") else None - ), - ) - next_effect = EffectNext( - cli_actions=tuple( - str(action) - for action in cli_channel.get("next_cli_actions", []) - if str(action).strip() - ), - execution_mode=( - str( - packet.get("execution_mode") - or codex_app.get("execution_mode") - or scheduler.get("execution_mode") - or None - ) - or None - ), - scheduler_action=str(scheduler.get("action") or None) or None, - cadence_class=str(scheduler.get("cadence_class") or None) or None, - ack_cli_args=tuple( - str(arg) - for arg in ack_hint.get("cli_args", []) - if str(arg).strip() - ), - failure_cli_args=tuple( - str(arg) - for arg in failure_hint.get("cli_args", []) - if str(arg).strip() - ), - ) - return EffectTurn( - request=request, - interpretation=interpretation, - observation=observation, - next_effect=next_effect, - ) - - -def interpret_turn_journal( - journal: Mapping[str, Any], - *, - goal_id: str | None = None, - agent_id: str | None = None, - turn_key: str | None = None, - capabilities: Sequence[str] = (), -) -> EffectTurn: - """Read one fenced Turn journal through the canonical effect slots.""" - - plan = _mapping(journal.get("plan")) - envelope = _mapping(plan.get("turn_envelope")) - transaction = _mapping(plan.get("transaction")) - settlement = _mapping(transaction.get("settlement_plan")) - identity = _mapping(settlement.get("identity")) - host_result = _mapping(journal.get("host_result")) - receipt = _mapping(journal.get("receipt")) - - goal_complete, goal_matches = _identity_state( - ( - journal.get("goal_id"), - envelope.get("goal_id"), - identity.get("goal_id"), - ), - expected=goal_id, - ) - owner_complete, owner_matches = _identity_state( - (envelope.get("agent_id"), identity.get("agent_id")), - expected=agent_id, - ) - turn_key_complete, turn_key_matches = _identity_state( - (journal.get("turn_key"), transaction.get("turn_key")), - optional_values=( - ("turn_key" in host_result, host_result.get("turn_key")), - ("turn_key" in receipt, receipt.get("turn_key")), - ), - expected=turn_key, - ) - - violations: list[TurnJournalViolation] = [] - if not goal_complete: - violations.append(TurnJournalViolation.GOAL_IDENTITY_MISSING) - elif not goal_matches: - violations.append(TurnJournalViolation.GOAL_MISMATCH) - if not owner_complete: - violations.append(TurnJournalViolation.OWNER_IDENTITY_MISSING) - elif not owner_matches: - violations.append(TurnJournalViolation.OWNER_MISMATCH) - if not turn_key_complete: - violations.append(TurnJournalViolation.TURN_KEY_IDENTITY_MISSING) - elif not turn_key_matches: - violations.append(TurnJournalViolation.TURN_KEY_MISMATCH) - - raw_completed_phases = journal.get("completed_phases") - if isinstance(raw_completed_phases, list): - completed_phases = tuple(str(phase) for phase in raw_completed_phases) - phases_form_ordered_prefix = completed_phases == TURN_TRANSACTION_PHASES[ - : len(completed_phases) - ] - if not phases_form_ordered_prefix: - violations.append( - TurnJournalViolation.COMPLETED_PHASES_NOT_ORDERED_PREFIX - ) - else: - completed_phases = () - phases_form_ordered_prefix = False - violations.append(TurnJournalViolation.COMPLETED_PHASES_INVALID) - - journal_status = str(journal.get("status") or "") - tombstone_retained = journal_status in {"committed", "stopped", "failed"} - if journal_status in {"in_progress", "scheduler_action_required"}: - violations.append(TurnJournalViolation.JOURNAL_NOT_TERMINAL) - elif not tombstone_retained: - violations.append(TurnJournalViolation.JOURNAL_STATUS_UNSUPPORTED) - - replay_legal = not violations - context = { - "replay_legal": replay_legal, - "goal_matches": goal_matches, - "owner_matches": owner_matches, - "turn_key_matches": turn_key_matches, - "phases_form_ordered_prefix": phases_form_ordered_prefix, - "journal_status": journal_status, - "tombstone_retained": tombstone_retained, - "completed_phases": completed_phases, - "violations": tuple(violation.value for violation in violations), - } - return EffectTurn( - request=EffectRequest( - kind="turn_journal", - source="turn_journal", - goal_id=goal_id, - agent_id=agent_id, - capabilities=tuple(capabilities), - context=context, - ), - interpretation=EffectInterpretation( - route="turn_journal_replay", - obligation="observe_fenced_replay", - interaction_mode="read_only", - ), - observation=EffectObservation( - decision="replay_legal" if replay_legal else "replay_blocked", - should_run=False, - effective_action=("observe_replay" if replay_legal else "block_replay"), - recommended_action=( - "Retain the terminal Turn journal tombstone." - if replay_legal - else "Inspect the structured Turn journal violations before replay." - ), - protocol_summary=( - "Turn journal replay is legal and effect-free." - if replay_legal - else ( - "Turn journal replay is blocked by " - f"{len(violations)} structured violation(s)." - ) - ), - ), - next_effect=EffectNext(), + return _effect_turn_from_payload( + effect_runtime_result( + "effect.interpret_quota", + { + "packet": dict(packet), + "identity": { + "goal_id": goal_id, + "agent_id": agent_id, + "capabilities": list(capabilities), + }, + }, + ) ) @@ -624,81 +571,16 @@ def interpret_turn_result_packet( capabilities: Sequence[str] = (), ) -> EffectTurn: """Map an existing `loopx_turn_result_v0` packet onto canonical slots.""" - - scheduler = _mapping(packet.get("scheduler_hint")) - codex_app = _mapping(scheduler.get("codex_app")) - ack_hint = _mapping(codex_app.get("ack_hint")) - failure_hint = _mapping(codex_app.get("failure_hint")) - completed_phases = tuple( - str(phase) - for phase in packet.get("completed_phases", []) - if str(phase).strip() - ) - failed_phase = str(packet.get("failed_phase") or "") or None - result_kind = str(packet.get("result_kind") or "") - - request = EffectRequest( - kind="turn_result", - source="host", - goal_id=goal_id, - agent_id=agent_id, - capabilities=tuple(capabilities), - context={ - "completed_phases": completed_phases, - "failed_phase": failed_phase, - "classification": packet.get("classification"), - "delivery_outcome": packet.get("delivery_outcome"), - }, - ) - interpretation = EffectInterpretation( - route="turn_result_settlement", - obligation="settle_turn_receipt", - interaction_mode="host_result", - cadence_class=str(scheduler.get("cadence_class") or None) or None, - ) - observation = EffectObservation( - decision=result_kind, - should_run=False, - effective_action=str(packet.get("effective_action") or result_kind), - recommended_action=( - str(packet.get("recommended_action") or "") - or "settle the turn receipt" - ), - protocol_summary=( - str(packet.get("summary")) if packet.get("summary") else None - ), - ) - next_effect = EffectNext( - cli_actions=tuple( - str(action) - for action in packet.get("next_cli_actions", []) - if str(action).strip() - ), - execution_mode=( - str( - packet.get("execution_mode") - or codex_app.get("execution_mode") - or scheduler.get("execution_mode") - or None - ) - or None - ), - scheduler_action=str(scheduler.get("action") or None) or None, - cadence_class=str(scheduler.get("cadence_class") or None) or None, - ack_cli_args=tuple( - str(arg) - for arg in ack_hint.get("cli_args", []) - if str(arg).strip() - ), - failure_cli_args=tuple( - str(arg) - for arg in failure_hint.get("cli_args", []) - if str(arg).strip() - ), - ) - return EffectTurn( - request=request, - interpretation=interpretation, - observation=observation, - next_effect=next_effect, + return _effect_turn_from_payload( + effect_runtime_result( + "effect.interpret_turn_result", + { + "packet": dict(packet), + "identity": { + "goal_id": goal_id, + "agent_id": agent_id, + "capabilities": list(capabilities), + }, + }, + ) ) diff --git a/loopx/control_plane/effect_program.ts b/loopx/control_plane/effect_program.ts new file mode 100644 index 000000000..f4e10b37e --- /dev/null +++ b/loopx/control_plane/effect_program.ts @@ -0,0 +1,866 @@ +export const SETTLEMENT_IDENTITY_SCHEMA_VERSION = + "quota_settlement_identity_v0"; +export const SCOPED_SETTLEMENT_IDENTITY_SCHEMA_VERSION = + "quota_settlement_identity_v1"; +export const SETTLEMENT_PLAN_SCHEMA_VERSION = "quota_settlement_plan_v1"; +export const SETTLEMENT_RECEIPT_SCHEMA_VERSION = + "quota_settlement_receipt_v1"; + +export type JsonObject = Record; + +export interface EffectRequest { + kind: string; + source: string; + goal_id: string | null; + agent_id: string | null; + capabilities: readonly string[]; + context: Context; +} + +export interface EffectInterpretation { + route: string; + obligation: string; + interaction_mode: string; + capability_action: string | null; + cadence_class: string | null; +} + +export interface EffectObservation { + decision: Decision; + should_run: boolean; + effective_action: string; + recommended_action: string; + protocol_summary: string | null; +} + +export interface EffectNext { + cli_actions: readonly string[]; + execution_mode: string | null; + scheduler_action: string | null; + cadence_class: string | null; + ack_cli_args: readonly string[]; + failure_cli_args: readonly string[]; +} + +export interface EffectTurn { + request: EffectRequest; + interpretation: EffectInterpretation; + observation: EffectObservation; + next_effect: EffectNext; +} + +export interface EffectStep { + step_id: string | null; + kind: string | null; + command: string | null; + purpose: string | null; + raw: JsonObject; +} + +export interface EffectProgram { + steps: readonly EffectStep[]; + execution_mode: string | null; +} + +export const SETTLEMENT_STEP_KINDS = [ + "validation", + "durable_writeback", + "quota_spend", + "terminal_closeout", +] as const; +export type SettlementStepKind = (typeof SETTLEMENT_STEP_KINDS)[number]; + +export const SETTLEMENT_BINDING_KINDS = [ + "todo", + "autonomous_replan", + "unbound", +] as const; +export type SettlementBindingKind = (typeof SETTLEMENT_BINDING_KINDS)[number]; + +export const SETTLEMENT_FAILURE_KINDS = [ + "invalid_identity", + "receipt_missing", + "identity_mismatch", + "writeback_missing", + "writeback_rejected", + "quota_spend_rejected", + "terminal_closeout_rejected", + "cancelled", + "permission_denied", + "budget_rejected", +] as const; +export type SettlementFailureKind = (typeof SETTLEMENT_FAILURE_KINDS)[number]; + +export interface SettlementIdentityInput { + goal_id: string; + agent_id: string; + todo_id?: string | null; + turn_instance_id: string; + replan_obligation_id?: string | null; +} + +export interface SettlementIdentity extends SettlementIdentityInput { + todo_id: string | null; + replan_obligation_id: string | null; + binding_kind: SettlementBindingKind; + binding_id: string; + effect_id: string; +} + +export interface SettlementReceipt { + step_kind: SettlementStepKind; + status: string; + effect_id: string; + source_ref?: string; +} + +export interface SettlementFailure { + kind: SettlementFailureKind; + step_kind: SettlementStepKind; + reason: string; + details?: JsonObject; +} + +export interface SettlementSuccess { + value: Value; + receipts: readonly SettlementReceipt[]; + failure: null; +} + +export interface SettlementFailureResult { + value: null; + receipts: readonly SettlementReceipt[]; + failure: SettlementFailure; +} + +export type SettlementResult = + | SettlementSuccess + | SettlementFailureResult; + +export interface SettlementStep { + kind: SettlementStepKind; + owner: string; + precondition: string; + idempotency_key_ref: string; + expected_receipt: string; + command_template?: string; + conditional?: true; +} + +export interface SettlementPlan { + identity: SettlementIdentity; + steps: readonly SettlementStep[]; +} + +export type SettlementBindGate = + | { execute: true; result: SettlementSuccess } + | { execute: false; result: SettlementFailureResult }; + +export interface SettlementCommitReduction { + result: SettlementResult; + completed_phases: readonly string[] | null; +} + +export type SettlementNextAction = + | { + decision: "failed"; + step_kind: null; + result: SettlementFailureResult; + } + | { + decision: "execute"; + step_kind: SettlementStepKind; + result: SettlementSuccess; + } + | { + decision: "complete"; + step_kind: null; + result: SettlementSuccess; + }; + +function asObject(value: unknown): JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : {}; +} + +function pythonString(value: unknown): string { + if (value === null || value === undefined) return "None"; + if (value === true) return "True"; + if (value === false) return "False"; + return String(value); +} + +function truthyString(value: unknown): string { + return value ? pythonString(value) : ""; +} + +function nullableTruthyString(value: unknown): string | null { + const rendered = truthyString(value); + return rendered || null; +} + +function stringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map(pythonString).filter((item) => item.trim().length > 0); +} + +function requireStepKind(value: unknown): SettlementStepKind { + const rendered = pythonString(value); + if (!SETTLEMENT_STEP_KINDS.includes(rendered as SettlementStepKind)) { + throw new Error(`unsupported settlement step kind: ${rendered}`); + } + return rendered as SettlementStepKind; +} + +function requireFailureKind(value: unknown): SettlementFailureKind { + const rendered = pythonString(value); + if (!SETTLEMENT_FAILURE_KINDS.includes(rendered as SettlementFailureKind)) { + throw new Error(`unsupported settlement failure kind: ${rendered}`); + } + return rendered as SettlementFailureKind; +} + +export function effectProgramFromOrderedSteps( + orderedSteps: unknown, + executionMode: unknown = null, +): EffectProgram { + const steps: EffectStep[] = []; + if (Array.isArray(orderedSteps)) { + for (const candidate of orderedSteps) { + if ( + typeof candidate !== "object" || + candidate === null || + Array.isArray(candidate) + ) { + continue; + } + const step = candidate as JsonObject; + const commandValue = + step.command || step.command_template || step.prompt || ""; + steps.push({ + step_id: nullableTruthyString(step.id), + kind: nullableTruthyString(step.kind), + command: nullableTruthyString(commandValue), + purpose: nullableTruthyString(step.purpose), + raw: { ...step }, + }); + } + } + return { + steps, + execution_mode: nullableTruthyString(executionMode), + }; +} + +export function interpretQuotaShouldRunPacket( + packetValue: unknown, + options: { + goal_id?: string | null; + agent_id?: string | null; + capabilities?: readonly string[]; + } = {}, +): EffectTurn { + const packet = asObject(packetValue); + const interaction = asObject(packet.interaction_contract); + const lane = asObject(packet.work_lane_contract); + const scheduler = asObject(packet.scheduler_hint); + const codexApp = asObject(scheduler.codex_app); + const ackHint = asObject(codexApp.ack_hint); + const failureHint = asObject(codexApp.failure_hint); + const cliChannel = asObject(interaction.cli_channel); + const gate = asObject(packet.capability_gate); + const protocol = asObject(packet.protocol_action_packet); + return { + request: { + kind: "quota_should_run", + source: "agent_or_host", + goal_id: options.goal_id ?? null, + agent_id: options.agent_id ?? null, + capabilities: [...(options.capabilities ?? [])], + context: {}, + }, + interpretation: { + route: truthyString(lane.lane), + obligation: truthyString(lane.obligation), + interaction_mode: truthyString(interaction.mode), + capability_action: + Object.keys(gate).length > 0 ? pythonString(gate.action) : null, + cadence_class: nullableTruthyString(scheduler.cadence_class), + }, + observation: { + decision: truthyString(packet.decision), + should_run: Boolean(packet.should_run), + effective_action: truthyString(packet.effective_action), + recommended_action: truthyString(packet.recommended_action), + protocol_summary: nullableTruthyString(protocol.summary), + }, + next_effect: { + cli_actions: stringArray(cliChannel.next_cli_actions), + execution_mode: nullableTruthyString( + packet.execution_mode || codexApp.execution_mode || scheduler.execution_mode, + ), + scheduler_action: nullableTruthyString(scheduler.action), + cadence_class: nullableTruthyString(scheduler.cadence_class), + ack_cli_args: stringArray(ackHint.cli_args), + failure_cli_args: stringArray(failureHint.cli_args), + }, + }; +} + +export function interpretTurnResultPacket( + packetValue: unknown, + options: { + goal_id?: string | null; + agent_id?: string | null; + capabilities?: readonly string[]; + } = {}, +): EffectTurn { + const packet = asObject(packetValue); + const scheduler = asObject(packet.scheduler_hint); + const codexApp = asObject(scheduler.codex_app); + const ackHint = asObject(codexApp.ack_hint); + const failureHint = asObject(codexApp.failure_hint); + const completedPhases = stringArray(packet.completed_phases); + const failedPhase = nullableTruthyString(packet.failed_phase); + const resultKind = truthyString(packet.result_kind); + return { + request: { + kind: "turn_result", + source: "host", + goal_id: options.goal_id ?? null, + agent_id: options.agent_id ?? null, + capabilities: [...(options.capabilities ?? [])], + context: { + completed_phases: completedPhases, + failed_phase: failedPhase, + classification: packet.classification, + delivery_outcome: packet.delivery_outcome, + }, + }, + interpretation: { + route: "turn_result_settlement", + obligation: "settle_turn_receipt", + interaction_mode: "host_result", + capability_action: null, + cadence_class: nullableTruthyString(scheduler.cadence_class), + }, + observation: { + decision: resultKind, + should_run: false, + effective_action: truthyString(packet.effective_action) || resultKind, + recommended_action: + truthyString(packet.recommended_action) || "settle the turn receipt", + protocol_summary: nullableTruthyString(packet.summary), + }, + next_effect: { + cli_actions: stringArray(packet.next_cli_actions), + execution_mode: nullableTruthyString( + packet.execution_mode || codexApp.execution_mode || scheduler.execution_mode, + ), + scheduler_action: nullableTruthyString(scheduler.action), + cadence_class: nullableTruthyString(scheduler.cadence_class), + ack_cli_args: stringArray(ackHint.cli_args), + failure_cli_args: stringArray(failureHint.cli_args), + }, + }; +} + +export function settlementIdentity( + input: SettlementIdentityInput, +): SettlementIdentity { + const todoId = truthyString(input.todo_id).trim() || null; + const replanObligationId = + truthyString(input.replan_obligation_id).trim() || null; + if (todoId && replanObligationId) { + throw new Error( + "settlement identity cannot bind both todo_id and replan_obligation_id", + ); + } + const bindingKind: SettlementBindingKind = todoId + ? "todo" + : replanObligationId + ? "autonomous_replan" + : "unbound"; + const bindingId = todoId ?? replanObligationId ?? ""; + let effectId: string; + if (todoId) { + effectId = `${input.goal_id}:${input.agent_id}:${todoId}:${input.turn_instance_id}`; + } else if (replanObligationId) { + effectId = `${input.goal_id}:${input.agent_id}:autonomous_replan:${replanObligationId}:${input.turn_instance_id}`; + } else { + effectId = `${input.goal_id}:${input.agent_id}::${input.turn_instance_id}`; + } + return { + goal_id: input.goal_id, + agent_id: input.agent_id, + todo_id: todoId, + turn_instance_id: input.turn_instance_id, + replan_obligation_id: replanObligationId, + binding_kind: bindingKind, + binding_id: bindingId, + effect_id: effectId, + }; +} + +export function settlementIdentityPayload( + identityValue: SettlementIdentityInput, +): JsonObject { + const identity = settlementIdentity(identityValue); + if (identity.todo_id || !identity.replan_obligation_id) { + return { + schema_version: SETTLEMENT_IDENTITY_SCHEMA_VERSION, + effect_id: identity.effect_id, + goal_id: identity.goal_id, + agent_id: identity.agent_id, + todo_id: identity.todo_id ?? "", + turn_instance_id: identity.turn_instance_id, + }; + } + return { + schema_version: SCOPED_SETTLEMENT_IDENTITY_SCHEMA_VERSION, + effect_id: identity.effect_id, + goal_id: identity.goal_id, + agent_id: identity.agent_id, + turn_instance_id: identity.turn_instance_id, + binding_kind: identity.binding_kind, + binding_id: identity.binding_id, + replan_obligation_id: identity.replan_obligation_id, + }; +} + +export function settlementReceiptPayload( + receipt: SettlementReceipt, +): JsonObject { + const payload: JsonObject = { + schema_version: SETTLEMENT_RECEIPT_SCHEMA_VERSION, + step_kind: requireStepKind(receipt.step_kind), + status: receipt.status, + effect_id: receipt.effect_id, + }; + if (receipt.source_ref) payload.source_ref = receipt.source_ref; + return payload; +} + +export function settlementFailurePayload( + failure: SettlementFailure, +): JsonObject { + const payload: JsonObject = { + kind: requireFailureKind(failure.kind), + step_kind: requireStepKind(failure.step_kind), + reason: failure.reason, + }; + if (failure.details && Object.keys(failure.details).length > 0) { + payload.details = { ...failure.details }; + } + return payload; +} + +export function settlementPure( + value: Value, + receipts: readonly SettlementReceipt[] = [], +): SettlementResult { + return { value, receipts: [...receipts], failure: null }; +} + +export function settlementFailed(options: { + kind: SettlementFailureKind; + step_kind: SettlementStepKind; + reason: string; + receipts?: readonly SettlementReceipt[]; + details?: JsonObject | null; +}): SettlementResult { + return { + value: null, + receipts: [...(options.receipts ?? [])], + failure: { + kind: requireFailureKind(options.kind), + step_kind: requireStepKind(options.step_kind), + reason: options.reason, + ...(options.details && Object.keys(options.details).length > 0 + ? { details: { ...options.details } } + : {}), + }, + }; +} + +export function settlementResultPayload( + result: SettlementResult, +): JsonObject { + return { + ok: result.failure === null, + receipts: result.receipts.map(settlementReceiptPayload), + failure: result.failure ? settlementFailurePayload(result.failure) : null, + }; +} + +export function settlementBindGate( + current: SettlementResult, +): SettlementBindGate { + if (current.failure) { + return { + execute: false, + result: { + value: null, + receipts: [...current.receipts], + failure: { ...current.failure }, + }, + }; + } + return { execute: true, result: current }; +} + +export function settlementBindReduce( + current: SettlementResult, + next: SettlementResult, +): SettlementResult { + if (current.failure) { + return { + value: null, + receipts: [...current.receipts], + failure: { ...current.failure }, + }; + } + if (next.failure) { + return { + value: null, + receipts: [...current.receipts, ...next.receipts], + failure: { ...next.failure }, + }; + } + return { + value: next.value, + receipts: [...current.receipts, ...next.receipts], + failure: null, + }; +} + +export function settlementStepPayload(step: SettlementStep): JsonObject { + const payload: JsonObject = { + kind: requireStepKind(step.kind), + owner: step.owner, + precondition: step.precondition, + idempotency_key_ref: step.idempotency_key_ref, + expected_receipt: step.expected_receipt, + }; + if (step.command_template) payload.command_template = step.command_template; + if (step.conditional) payload.conditional = true; + return payload; +} + +export function settlementPlanPayload(plan: SettlementPlan): JsonObject { + return { + schema_version: SETTLEMENT_PLAN_SCHEMA_VERSION, + identity: settlementIdentityPayload(plan.identity), + ordered_steps: plan.steps.map(settlementStepPayload), + host_handoff: { + owner: "host", + kind: "scheduler_handoff", + inside_agent_settlement: false, + }, + }; +} + +export function effectIdsMatch( + committedEffectId: string | null | undefined, + expectedEffectId: string, +): boolean { + return !committedEffectId || committedEffectId === expectedEffectId; +} + +export function settlementReceipt( + identityValue: SettlementIdentityInput, + stepKind: SettlementStepKind, + sourceRef: string | null = null, +): SettlementReceipt { + const identity = settlementIdentity(identityValue); + return { + step_kind: requireStepKind(stepKind), + status: "committed", + effect_id: identity.effect_id, + ...(sourceRef ? { source_ref: sourceRef } : {}), + }; +} + +export function settlementIdentityFromPlan( + transactionPlanValue: unknown, +): SettlementResult { + const transactionPlan = asObject(transactionPlanValue); + const settlementPlanValue = transactionPlan.settlement_plan; + if ( + typeof settlementPlanValue !== "object" || + settlementPlanValue === null || + Array.isArray(settlementPlanValue) + ) { + return settlementFailed({ + kind: "receipt_missing", + step_kind: "validation", + reason: "Turn transaction has no typed settlement plan", + }); + } + const identityValue = (settlementPlanValue as JsonObject).identity; + if ( + typeof identityValue !== "object" || + identityValue === null || + Array.isArray(identityValue) + ) { + return settlementFailed({ + kind: "invalid_identity", + step_kind: "validation", + reason: "Turn settlement plan has no identity", + }); + } + const identityObject = identityValue as JsonObject; + if ( + ["goal_id", "agent_id", "turn_instance_id"].some( + (field) => truthyString(identityObject[field]).trim().length === 0, + ) + ) { + return settlementFailed({ + kind: "invalid_identity", + step_kind: "validation", + reason: "Turn settlement plan has an incomplete identity", + }); + } + const todoId = truthyString(identityObject.todo_id).trim(); + const replanObligationId = truthyString( + identityObject.replan_obligation_id, + ).trim(); + if (Boolean(todoId) === Boolean(replanObligationId)) { + return settlementFailed({ + kind: "invalid_identity", + step_kind: "validation", + reason: + "Turn settlement plan requires exactly one Todo or autonomous replan obligation binding", + }); + } + let built: SettlementIdentity; + try { + built = settlementIdentity({ + goal_id: pythonString(identityObject.goal_id), + agent_id: pythonString(identityObject.agent_id), + todo_id: todoId || null, + turn_instance_id: pythonString(identityObject.turn_instance_id), + replan_obligation_id: replanObligationId || null, + }); + } catch (error) { + return settlementFailed({ + kind: "invalid_identity", + step_kind: "validation", + reason: error instanceof Error ? error.message : pythonString(error), + }); + } + const effectId = truthyString(identityObject.effect_id).trim(); + if (!effectId) { + return settlementFailed({ + kind: "invalid_identity", + step_kind: "validation", + reason: "Turn settlement plan has no effect id", + }); + } + if (effectId !== built.effect_id) { + return settlementFailed({ + kind: "invalid_identity", + step_kind: "validation", + reason: "Turn settlement plan effect id does not match its identity", + }); + } + return settlementPure(built); +} + +export function requireMatchingEffectId( + committedEffectId: string | null | undefined, + expectedEffectId: string, +): SettlementResult { + if (!effectIdsMatch(committedEffectId, expectedEffectId)) { + return settlementFailed({ + kind: "identity_mismatch", + step_kind: "validation", + reason: + `Turn journal belongs to another settlement effect: journal effect is ${committedEffectId} ` + + `but plan effect is ${expectedEffectId}`, + }); + } + return settlementPure(expectedEffectId); +} + +export function isCommittedPayload(value: unknown): value is JsonObject { + const payload = asObject(value); + return payload.ok === true && payload.appended === true; +} + +const DEFAULT_MISSING_FAILURE_KINDS: Partial< + Record +> = { durable_writeback: "receipt_missing" }; + +const MISSING_PAYLOAD_REASONS: Partial> = { + durable_writeback: "Turn journal is missing its committed writeback payload", + quota_spend: "Turn journal is missing its committed quota spend payload", +}; + +export function seedCommittedSteps(options: { + identity: SettlementIdentityInput; + ordered_steps: readonly SettlementStepKind[]; + committed_payloads: Partial>; + completed_phases?: readonly string[] | null; + transaction_phases?: readonly string[] | null; + require_validation?: boolean; + missing_failure_kinds?: Partial< + Record + >; + source_ref_prefix?: string; +}): SettlementResult { + const identity = settlementIdentity(options.identity); + const requireValidation = options.require_validation ?? true; + const completedPhases = options.completed_phases ?? null; + const transactionPhases = options.transaction_phases ?? null; + if (completedPhases && transactionPhases) { + const prefix = transactionPhases.slice(0, completedPhases.length); + if ( + completedPhases.length !== prefix.length || + completedPhases.some((phase, index) => phase !== prefix[index]) + ) { + return settlementFailed({ + kind: "receipt_missing", + step_kind: "validation", + reason: "Turn journal phases are not an ordered transaction prefix", + }); + } + if (requireValidation && !completedPhases.includes("validation")) { + return settlementFailed({ + kind: "receipt_missing", + step_kind: "validation", + reason: "Turn settlement requires a committed validation receipt", + }); + } + } + const missingKinds = { + ...DEFAULT_MISSING_FAILURE_KINDS, + ...(options.missing_failure_kinds ?? {}), + }; + const receipts: SettlementReceipt[] = []; + const committed: JsonObject = {}; + const sourcePrefix = options.source_ref_prefix ?? "turn_journal"; + for (const rawStepKind of options.ordered_steps) { + const stepKind = requireStepKind(rawStepKind); + if (stepKind === "validation" && requireValidation) { + if (completedPhases && !completedPhases.includes("validation")) { + return settlementFailed({ + kind: "receipt_missing", + step_kind: stepKind, + reason: "Turn settlement requires a committed validation receipt", + receipts, + }); + } + receipts.push( + settlementReceipt( + identity, + stepKind, + `${sourcePrefix}:${identity.effect_id}#${stepKind}`, + ), + ); + committed[stepKind] = {}; + continue; + } + const committedFlag = completedPhases + ? completedPhases.includes(stepKind) + : true; + if (!committedFlag) continue; + const payload = options.committed_payloads[stepKind]; + if (!isCommittedPayload(payload)) { + return settlementFailed({ + kind: missingKinds[stepKind] ?? "receipt_missing", + step_kind: stepKind, + reason: + MISSING_PAYLOAD_REASONS[stepKind] ?? + `Turn journal is missing its committed ${stepKind} payload`, + receipts, + }); + } + receipts.push( + settlementReceipt( + identity, + stepKind, + `${sourcePrefix}:${identity.effect_id}#${stepKind}`, + ), + ); + committed[stepKind] = payload; + } + return settlementPure(committed, receipts); +} + +export function settlementNextAction(options: { + identity: SettlementIdentityInput; + ordered_steps: readonly SettlementStepKind[]; + committed_payloads: Partial>; + completed_phases?: readonly string[] | null; + transaction_phases?: readonly string[] | null; + require_validation?: boolean; + missing_failure_kinds?: Partial< + Record + >; + source_ref_prefix?: string; +}): SettlementNextAction { + const seeded = seedCommittedSteps(options); + if (seeded.failure !== null) { + return { decision: "failed", step_kind: null, result: seeded }; + } + const completed = new Set(options.completed_phases ?? []); + const next = options.ordered_steps.find( + (step) => step !== "validation" && !completed.has(step), + ); + if (next) { + return { decision: "execute", step_kind: next, result: seeded }; + } + return { decision: "complete", step_kind: null, result: seeded }; +} + +export function callbackFailureKind( + stepKind: SettlementStepKind, + reason: string, +): SettlementFailureKind { + if (stepKind === "durable_writeback") return "writeback_rejected"; + if (stepKind === "terminal_closeout") return "terminal_closeout_rejected"; + if (reason.toLowerCase().includes("budget")) return "budget_rejected"; + return "quota_spend_rejected"; +} + +export function commitStepPayload(options: { + identity: SettlementIdentityInput; + step_kind: SettlementStepKind; + transaction_phases: readonly string[]; + payload: unknown; + source_ref_prefix?: string; +}): SettlementCommitReduction { + const identity = settlementIdentity(options.identity); + const stepKind = requireStepKind(options.step_kind); + const payload = asObject(options.payload); + if (!isCommittedPayload(options.payload)) { + const reason = truthyString(payload.error || payload.reason) || + `${stepKind} callback rejected the settlement`; + return { + result: settlementFailed({ + kind: callbackFailureKind(stepKind, reason), + step_kind: stepKind, + reason, + }), + completed_phases: null, + }; + } + const phaseIndex = options.transaction_phases.indexOf(stepKind); + if (phaseIndex < 0) { + throw new Error(`transaction phases do not contain ${stepKind}`); + } + const completedPhases = options.transaction_phases.slice(0, phaseIndex + 1); + const sourcePrefix = options.source_ref_prefix ?? "turn_journal"; + return { + result: settlementPure(payload, [ + settlementReceipt( + identity, + stepKind, + `${sourcePrefix}:${identity.effect_id}#${stepKind}`, + ), + ]), + completed_phases: completedPhases, + }; +} diff --git a/loopx/control_plane/effect_runtime.py b/loopx/control_plane/effect_runtime.py new file mode 100644 index 000000000..81bbfefb7 --- /dev/null +++ b/loopx/control_plane/effect_runtime.py @@ -0,0 +1,470 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +import secrets +import shutil +import socket +import subprocess +import tempfile +import time +import uuid +from collections.abc import Mapping +from functools import lru_cache +from pathlib import Path +from typing import Any + + +EFFECT_RUNTIME_REQUEST_SCHEMA_VERSION = "loopx_effect_runtime_request_v0" +EFFECT_RUNTIME_RESPONSE_SCHEMA_VERSION = "loopx_effect_runtime_response_v0" +EFFECT_RUNTIME_INFO_SCHEMA_VERSION = "loopx_effect_runtime_info_v0" +EFFECT_RUNTIME_READINESS_SCHEMA_VERSION = "loopx_effect_runtime_readiness_v0" +MINIMUM_NODE_VERSION = (22, 6, 0) +MINIMUM_NODE_VERSION_TEXT = ".".join(str(part) for part in MINIMUM_NODE_VERSION) +MAX_RESPONSE_BYTES = 2 * 1024 * 1024 +MAX_REQUEST_BYTES = 2 * 1024 * 1024 +_NODE_VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$") +_SOURCE_FILES = ( + "effect_program.ts", + "effect_runtime_handlers.ts", + "effect_runtime_io.ts", + "effect_runtime_server.ts", + "turn_driver/turn_journal.ts", + "turn_driver/turn_journal_effects.ts", + "turn_transaction_contract.json", +) + + +class EffectRuntimeRejected(RuntimeError): + """A typed request reached the runtime but failed semantic validation.""" + + +class EffectRuntimeStartupError(RuntimeError): + """The managed runtime could not reach a request-serving state.""" + + def __init__(self, message: str, *, diagnostic_code: str) -> None: + super().__init__(message) + self.diagnostic_code = diagnostic_code + + +def _control_plane_root() -> Path: + return Path(__file__).resolve().parent + + +@lru_cache(maxsize=1) +def _runtime_fingerprint() -> str: + digest = hashlib.sha256() + root = _control_plane_root() + for relative in _SOURCE_FILES: + digest.update(relative.encode("utf-8")) + digest.update((root / relative).read_bytes()) + return digest.hexdigest() + + +def _runtime_dir() -> Path: + owner = str(getattr(os, "getuid", lambda: Path.home())()) + suffix = hashlib.sha256(owner.encode("utf-8")).hexdigest()[:12] + return Path(tempfile.gettempdir()) / f"loopx-effect-runtime-{suffix}" + + +def _runtime_info_path(fingerprint: str) -> Path: + return _runtime_dir() / f"runtime-{fingerprint[:16]}.json" + + +def _runtime_server_path() -> Path: + return _control_plane_root() / "effect_runtime_server.ts" + + +def _node_executable() -> str: + status, executable, _version = _probe_node() + if status != "ready" or executable is None: + raise RuntimeError( + f"LoopX Effect runtime requires Node.js {MINIMUM_NODE_VERSION_TEXT} " + "or newer" + ) + return executable + + +def _probe_node() -> tuple[str, str | None, str | None]: + executable = shutil.which("node") + if executable is None: + return "missing", None, None + try: + completed = subprocess.run( + [executable, "--version"], + check=False, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.TimeoutExpired): + return "probe_failed", executable, None + match = _NODE_VERSION_RE.fullmatch(completed.stdout.strip()) + version = tuple(int(part) for part in match.groups()) if match else None + if completed.returncode != 0 or version is None: + return "probe_failed", executable, None + version_text = ".".join(str(part) for part in version) + if version < MINIMUM_NODE_VERSION: + return "unsupported", executable, version_text + return "ready", executable, version_text + + +def _pid_is_alive(value: object) -> bool: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + return False + try: + os.kill(value, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return False + return True + + +def _start_lock_holder_pid(path: Path) -> int | None: + try: + value = int(path.read_text(encoding="utf-8").strip()) + except (FileNotFoundError, OSError, ValueError): + return None + return value if value > 0 else None + + +def _read_info(path: Path, *, fingerprint: str) -> dict[str, Any] | None: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + if not isinstance(payload, dict): + return None + if ( + payload.get("schema_version") != EFFECT_RUNTIME_INFO_SCHEMA_VERSION + or payload.get("fingerprint") != fingerprint + or payload.get("host") != "127.0.0.1" + or not isinstance(payload.get("port"), int) + or not isinstance(payload.get("token"), str) + or not _pid_is_alive(payload.get("pid")) + ): + return None + return payload + + +def _request_with_info( + info: Mapping[str, Any], + *, + request_id: str, + method: str, + params: Mapping[str, Any], + timeout: float, +) -> dict[str, Any]: + request = { + "schema_version": EFFECT_RUNTIME_REQUEST_SCHEMA_VERSION, + "token": info["token"], + "request_id": request_id, + "method": method, + "params": dict(params), + } + encoded = (json.dumps(request, separators=(",", ":")) + "\n").encode() + if len(encoded) > MAX_REQUEST_BYTES: + raise EffectRuntimeRejected("TypeScript Effect runtime request is oversized") + chunks: list[bytes] = [] + size = 0 + with socket.create_connection( + (str(info["host"]), int(info["port"])), timeout=timeout + ) as connection: + connection.settimeout(timeout) + connection.sendall(encoded) + while True: + chunk = connection.recv(64 * 1024) + if not chunk: + break + chunks.append(chunk) + size += len(chunk) + if size > MAX_RESPONSE_BYTES: + raise RuntimeError("TypeScript Effect runtime response is oversized") + if b"\n" in chunk: + break + try: + response = json.loads(b"".join(chunks).split(b"\n", 1)[0]) + except (json.JSONDecodeError, IndexError): + raise RuntimeError( + "TypeScript Effect runtime returned malformed JSON" + ) from None + if ( + not isinstance(response, dict) + or response.get("schema_version") != EFFECT_RUNTIME_RESPONSE_SCHEMA_VERSION + or response.get("request_id") != request_id + ): + raise RuntimeError("TypeScript Effect runtime response shape mismatch") + if response.get("ok") is not True: + error = response.get("error") + message = error.get("message") if isinstance(error, Mapping) else None + raise EffectRuntimeRejected( + str(message or "TypeScript Effect runtime request failed") + ) + return response + + +def _start_runtime(*, fingerprint: str, info_path: Path) -> dict[str, Any]: + runtime_dir = info_path.parent + runtime_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + try: + runtime_dir.chmod(0o700) + except OSError: + pass + lock = runtime_dir / f"start-{fingerprint[:16]}.lock" + deadline = time.monotonic() + 5.0 + acquired = False + while time.monotonic() < deadline: + try: + descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as lock_file: + lock_file.write(f"{os.getpid()}\n") + lock_file.flush() + os.fsync(lock_file.fileno()) + acquired = True + break + except FileExistsError: + existing = _read_info(info_path, fingerprint=fingerprint) + if existing is not None: + return existing + holder_pid = _start_lock_holder_pid(lock) + if holder_pid is not None and not _pid_is_alive(holder_pid): + try: + lock.unlink() + except FileNotFoundError: + pass + continue + try: + if time.time() - lock.stat().st_mtime > 10: + lock.unlink(missing_ok=True) + except OSError: + pass + time.sleep(0.025) + if not acquired: + raise EffectRuntimeStartupError( + "TypeScript Effect runtime startup lock timed out", + diagnostic_code="startup_lock_timeout", + ) + try: + existing = _read_info(info_path, fingerprint=fingerprint) + if existing is not None: + return existing + token = secrets.token_urlsafe(32) + environment = os.environ.copy() + environment["LOOPX_EFFECT_RUNTIME_TOKEN"] = token + try: + process = subprocess.Popen( + [ + _node_executable(), + "--no-warnings", + "--experimental-strip-types", + str(_runtime_server_path()), + "--info", + str(info_path), + "--fingerprint", + fingerprint, + ], + env=environment, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=os.name != "nt", + close_fds=os.name != "nt", + ) + except OSError as exc: + raise EffectRuntimeStartupError( + "TypeScript Effect runtime process could not be launched", + diagnostic_code="runtime_launch_failed", + ) from exc + while time.monotonic() < deadline: + info = _read_info(info_path, fingerprint=fingerprint) + if info is not None: + return info + exit_code = process.poll() + if exit_code is not None: + raise EffectRuntimeStartupError( + "TypeScript Effect runtime exited before becoming ready " + f"(exit_code={exit_code})", + diagnostic_code="runtime_exited_before_ready", + ) + time.sleep(0.025) + if process.poll() is None: + process.terminate() + raise EffectRuntimeStartupError( + "TypeScript Effect runtime did not become ready before the startup deadline", + diagnostic_code="runtime_startup_timeout", + ) + finally: + lock.unlink(missing_ok=True) + + +def effect_runtime_request( + method: str, + params: Mapping[str, Any], + *, + timeout: float = 5.0, + retry_safe: bool = True, +) -> dict[str, Any]: + """Call the managed TS runtime, retrying only idempotent typed effects.""" + + fingerprint = _runtime_fingerprint() + info_path = _runtime_info_path(fingerprint) + request_id = str(uuid.uuid4()) + last_error: OSError | RuntimeError | None = None + for attempt in range(2 if retry_safe else 1): + info = _read_info(info_path, fingerprint=fingerprint) + if info is None: + info = _start_runtime(fingerprint=fingerprint, info_path=info_path) + try: + return _request_with_info( + info, + request_id=request_id, + method=method, + params=params, + timeout=timeout, + ) + except EffectRuntimeRejected: + raise + except (OSError, RuntimeError) as exc: + last_error = exc + if attempt == 0 and retry_safe: + info_path.unlink(missing_ok=True) + continue + break + raise RuntimeError("TypeScript Effect runtime request failed") from last_error + + +def effect_runtime_result( + method: str, + params: Mapping[str, Any], + *, + timeout: float = 5.0, + retry_safe: bool = True, +) -> Any: + return effect_runtime_request( + method, + params, + timeout=timeout, + retry_safe=retry_safe, + ).get("result") + + +def collect_effect_runtime_readiness(*, deep: bool = False) -> dict[str, object]: + """Report whether the managed TS Effect runtime can serve control-plane work.""" + + status, _executable, version = _probe_node() + ready = status == "ready" + runtime_state = "unavailable" + runtime_diagnostic_code: str | None = None + if ready: + try: + fingerprint = _runtime_fingerprint() + runtime_state = ( + "running" + if _read_info( + _runtime_info_path(fingerprint), + fingerprint=fingerprint, + ) + is not None + else "stopped" + ) + except OSError: + ready = False + status = "package_invalid" + runtime_diagnostic_code = "packaged_runtime_source_unreadable" + runtime_lifecycle: dict[str, object] = { + "schema_version": "loopx_effect_runtime_lifecycle_v0", + "management": "on_demand_managed", + "state": runtime_state, + "manual_start_required": False, + "restart_policy": "automatic_on_next_control_plane_request", + "idle_shutdown": True, + "diagnostic_code": runtime_diagnostic_code, + } + result: dict[str, object] = { + "schema_version": EFFECT_RUNTIME_READINESS_SCHEMA_VERSION, + "ready": ready, + "status": status, + "required_for": ["control_plane"], + "default_cli_blocking": True, + "minimum_node_version": MINIMUM_NODE_VERSION_TEXT, + "detected_node_version": version, + "semantic_probe": "not_requested" if not deep else "not_run", + "runtime_lifecycle": runtime_lifecycle, + "recommended_action": ( + None + if ready + else ( + f"Install Node.js {MINIMUM_NODE_VERSION_TEXT} or newer, then " + "rerun `loopx doctor --deep`." + if status in {"missing", "unsupported"} + else "Repair Node.js on PATH, then rerun `loopx doctor --deep`." + ) + ), + } + if not ready or not deep: + return result + try: + ping = effect_runtime_result("runtime.ping", {}) + identity = effect_runtime_result( + "settlement.identity", + { + "goal_id": "doctor-probe", + "agent_id": "doctor-probe", + "todo_id": "doctor-probe", + "turn_instance_id": "doctor-probe", + }, + ) + except RuntimeError as exc: + diagnostic_code = getattr(exc, "diagnostic_code", "semantic_probe_failed") + return { + **result, + "ready": False, + "status": "probe_failed", + "semantic_probe": "failed", + "runtime_lifecycle": { + **runtime_lifecycle, + "state": "unavailable", + "diagnostic_code": diagnostic_code, + }, + "recommended_action": ( + "Run `loopx doctor --deep` again after any concurrent startup " + "finishes. If the same diagnostic code remains, reinstall LoopX " + "and verify Node.js before retrying." + ), + } + if ( + not isinstance(ping, Mapping) + or ping.get("ready") is not True + or not isinstance(identity, Mapping) + or identity.get("effect_id") + != "doctor-probe:doctor-probe:doctor-probe:doctor-probe" + ): + return { + **result, + "ready": False, + "status": "probe_failed", + "semantic_probe": "failed", + "runtime_lifecycle": { + **runtime_lifecycle, + "state": "unavailable", + "diagnostic_code": "semantic_probe_shape_mismatch", + }, + "recommended_action": ( + "Reinstall LoopX and verify the packaged TypeScript runtime with " + "`loopx doctor --deep`." + ), + } + return { + **result, + "semantic_probe": "passed", + "runtime_lifecycle": { + **runtime_lifecycle, + "state": "running", + "diagnostic_code": None, + }, + } diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts new file mode 100644 index 000000000..c5b93f60e --- /dev/null +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -0,0 +1,410 @@ +import { + commitStepPayload, + effectIdsMatch, + effectProgramFromOrderedSteps, + requireMatchingEffectId, + interpretQuotaShouldRunPacket, + interpretTurnResultPacket, + seedCommittedSteps, + settlementBindGate, + settlementBindReduce, + settlementReceipt, + isCommittedPayload, + settlementIdentity, + settlementIdentityPayload, + settlementIdentityFromPlan, + settlementNextAction, + settlementPlanPayload, + settlementResultPayload, + SETTLEMENT_FAILURE_KINDS, + SETTLEMENT_STEP_KINDS, + type JsonObject, + type SettlementFailure, + type SettlementFailureKind, + type SettlementIdentityInput, + type SettlementPlan, + type SettlementReceipt, + type SettlementResult, + type SettlementStep, + type SettlementStepKind, +} from "./effect_program.ts"; +import { + interpretTurnJournal, + type TurnJournalInspectionRequest, +} from "./turn_driver/turn_journal.ts"; +import { commitTurnJournal } from "./turn_driver/turn_journal_effects.ts"; + +type EffectRuntimeHandler = (params: JsonObject) => unknown | Promise; + +export interface EffectRuntimeHandlerContext { + fingerprint: string; + requestShutdown: () => void; +} + +function asObject(value: unknown): JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : {}; +} + +function requiredObject(value: unknown, label: string): JsonObject { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as JsonObject; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +function optionalString(value: unknown, label: string): string | null { + if (value === null || value === undefined || value === "") return null; + return requiredString(value, label); +} + +function stringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + throw new Error(`${label} must be an array of strings`); + } + return [...value]; +} + +function settlementStepKind(value: unknown, label: string): SettlementStepKind { + const candidate = requiredString(value, label); + if (!SETTLEMENT_STEP_KINDS.includes(candidate as SettlementStepKind)) { + throw new Error(`${label} has an unsupported settlement step kind`); + } + return candidate as SettlementStepKind; +} + +function settlementFailureKind( + value: unknown, + label: string, +): SettlementFailureKind { + const candidate = requiredString(value, label); + if (!SETTLEMENT_FAILURE_KINDS.includes(candidate as SettlementFailureKind)) { + throw new Error(`${label} has an unsupported settlement failure kind`); + } + return candidate as SettlementFailureKind; +} + +function settlementIdentityInput( + value: unknown, + label = "identity", +): SettlementIdentityInput { + const input = requiredObject(value, label); + return { + goal_id: requiredString(input.goal_id, `${label}.goal_id`), + agent_id: requiredString(input.agent_id, `${label}.agent_id`), + todo_id: optionalString(input.todo_id, `${label}.todo_id`), + turn_instance_id: requiredString( + input.turn_instance_id, + `${label}.turn_instance_id`, + ), + replan_obligation_id: optionalString( + input.replan_obligation_id, + `${label}.replan_obligation_id`, + ), + }; +} + +function settlementReceiptInput( + value: unknown, + label: string, +): SettlementReceipt { + const receipt = requiredObject(value, label); + return { + step_kind: settlementStepKind(receipt.step_kind, `${label}.step_kind`), + status: requiredString(receipt.status, `${label}.status`), + effect_id: requiredString(receipt.effect_id, `${label}.effect_id`), + ...(optionalString(receipt.source_ref, `${label}.source_ref`) + ? { source_ref: optionalString(receipt.source_ref, `${label}.source_ref`)! } + : {}), + }; +} + +function settlementFailureInput(value: unknown, label: string): SettlementFailure { + const failure = requiredObject(value, label); + const details = failure.details === undefined + ? undefined + : requiredObject(failure.details, `${label}.details`); + return { + kind: settlementFailureKind(failure.kind, `${label}.kind`), + step_kind: settlementStepKind(failure.step_kind, `${label}.step_kind`), + reason: requiredString(failure.reason, `${label}.reason`), + ...(details ? { details } : {}), + }; +} + +function settlementResultInput(value: unknown, label: string): SettlementResult { + const result = requiredObject(value, label); + if (!Array.isArray(result.receipts)) { + throw new Error(`${label}.receipts must be an array`); + } + const receipts = result.receipts.map((receipt, index) => + settlementReceiptInput(receipt, `${label}.receipts[${index}]`) + ); + if (result.failure === null) { + return { value: result.value, receipts, failure: null }; + } + if (result.value !== null) { + throw new Error(`${label} cannot carry both a value and a failure`); + } + return { + value: null, + receipts, + failure: settlementFailureInput(result.failure, `${label}.failure`), + }; +} + +function settlementStepInput(value: unknown, label: string): SettlementStep { + const step = requiredObject(value, label); + if (step.conditional !== undefined && step.conditional !== true) { + throw new Error(`${label}.conditional must be true when present`); + } + return { + kind: settlementStepKind(step.kind, `${label}.kind`), + owner: requiredString(step.owner, `${label}.owner`), + precondition: requiredString(step.precondition, `${label}.precondition`), + idempotency_key_ref: requiredString( + step.idempotency_key_ref, + `${label}.idempotency_key_ref`, + ), + expected_receipt: requiredString( + step.expected_receipt, + `${label}.expected_receipt`, + ), + ...(optionalString(step.command_template, `${label}.command_template`) + ? { + command_template: optionalString( + step.command_template, + `${label}.command_template`, + )!, + } + : {}), + ...(step.conditional === true ? { conditional: true } : {}), + }; +} + +function settlementPlanInput(value: unknown, label: string): SettlementPlan { + const plan = requiredObject(value, label); + if (!Array.isArray(plan.steps)) { + throw new Error(`${label}.steps must be an array`); + } + return { + identity: settlementIdentity(settlementIdentityInput(plan.identity, `${label}.identity`)), + steps: plan.steps.map((step, index) => + settlementStepInput(step, `${label}.steps[${index}]`) + ), + }; +} + +function turnJournalInspectionRequest( + value: unknown, +): TurnJournalInspectionRequest { + const request = requiredObject(value, "turn_journal.inspect params"); + if ( + request.schema_version !== + "loopx_turn_journal_interpretation_request_v0" + ) { + throw new Error("Turn-journal interpretation request schema mismatch"); + } + return { + schema_version: request.schema_version, + journal: requiredObject(request.journal, "turn_journal.inspect journal"), + goal_id: requiredString(request.goal_id, "turn_journal.inspect goal_id"), + agent_id: requiredString(request.agent_id, "turn_journal.inspect agent_id"), + turn_key: requiredString(request.turn_key, "turn_journal.inspect turn_key"), + }; +} + +function settlementStepKinds(value: unknown, label: string): SettlementStepKind[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + return value.map((step, index) => + settlementStepKind(step, `${label}[${index}]`) + ); +} + +function missingFailureKinds(value: unknown): Partial< + Record +> { + const source = requiredObject(value, "missing_failure_kinds"); + const decoded: Partial> = {}; + for (const [step, kind] of Object.entries(source)) { + decoded[settlementStepKind(step, "missing_failure_kinds key")] = + settlementFailureKind(kind, `missing_failure_kinds.${step}`); + } + return decoded; +} + +export function createEffectRuntimeHandlers( + context: EffectRuntimeHandlerContext, +): ReadonlyMap { + return new Map([ + [ + "runtime.ping", + () => ({ ready: true, pid: process.pid, fingerprint: context.fingerprint }), + ], + [ + "runtime.shutdown", + () => { + context.requestShutdown(); + return { stopped: true }; + }, + ], + [ + "turn_journal.inspect", + (params) => interpretTurnJournal(turnJournalInspectionRequest(params)), + ], + ["turn_journal.write", commitTurnJournal], + [ + "effect.program_from_ordered_steps", + (params) => effectProgramFromOrderedSteps( + Array.isArray(params.ordered_steps) ? params.ordered_steps : [], + typeof params.execution_mode === "string" ? params.execution_mode : null, + ), + ], + [ + "effect.interpret_quota", + (params) => interpretQuotaShouldRunPacket( + asObject(params.packet), + asObject(params.identity), + ), + ], + [ + "effect.interpret_turn_result", + (params) => interpretTurnResultPacket( + asObject(params.packet), + asObject(params.identity), + ), + ], + [ + "settlement.identity", + (params) => settlementIdentity(settlementIdentityInput(params)), + ], + [ + "settlement.identity_full", + (params) => { + const identity = settlementIdentity( + settlementIdentityInput(params), + ); + return { identity, payload: settlementIdentityPayload(identity) }; + }, + ], + ["settlement.identity_from_plan", settlementIdentityFromPlan], + [ + "settlement.match_effect", + (params) => requireMatchingEffectId( + typeof params.committed_effect_id === "string" + ? params.committed_effect_id + : null, + requiredString(params.expected_effect_id, "expected_effect_id"), + ), + ], + [ + "settlement.effect_ids_match", + (params) => effectIdsMatch( + typeof params.committed_effect_id === "string" + ? params.committed_effect_id + : null, + requiredString(params.expected_effect_id, "expected_effect_id"), + ), + ], + [ + "settlement.receipt", + (params) => settlementReceipt( + settlementIdentityInput(params.identity), + settlementStepKind(params.step_kind, "step_kind"), + typeof params.source_ref === "string" ? params.source_ref : undefined, + ), + ], + ["settlement.is_committed_payload", (params) => isCommittedPayload(params.payload)], + [ + "settlement.bind_gate", + (params) => settlementBindGate(settlementResultInput(params.result, "result")), + ], + [ + "settlement.bind_reduce", + (params) => settlementBindReduce( + settlementResultInput(params.current, "current"), + settlementResultInput(params.next, "next"), + ), + ], + [ + "settlement.plan_payload", + (params) => settlementPlanPayload(settlementPlanInput(params.plan, "plan")), + ], + [ + "settlement.result_payload", + (params) => settlementResultPayload( + settlementResultInput(params.result, "result"), + ), + ], + [ + "settlement.seed", + (params) => seedCommittedSteps({ + identity: settlementIdentityInput(params.identity), + ordered_steps: settlementStepKinds(params.ordered_steps, "ordered_steps"), + committed_payloads: asObject(params.committed_payloads), + completed_phases: Array.isArray(params.completed_phases) + ? stringArray(params.completed_phases, "completed_phases") + : null, + transaction_phases: Array.isArray(params.transaction_phases) + ? stringArray(params.transaction_phases, "transaction_phases") + : null, + require_validation: params.require_validation !== false, + missing_failure_kinds: missingFailureKinds(params.missing_failure_kinds), + source_ref_prefix: typeof params.source_ref_prefix === "string" + ? params.source_ref_prefix + : undefined, + }), + ], + [ + "settlement.next_action", + (params) => settlementNextAction({ + identity: settlementIdentityInput(params.identity), + ordered_steps: settlementStepKinds(params.ordered_steps, "ordered_steps"), + committed_payloads: asObject(params.committed_payloads), + completed_phases: Array.isArray(params.completed_phases) + ? stringArray(params.completed_phases, "completed_phases") + : null, + transaction_phases: Array.isArray(params.transaction_phases) + ? stringArray(params.transaction_phases, "transaction_phases") + : null, + require_validation: params.require_validation !== false, + missing_failure_kinds: missingFailureKinds(params.missing_failure_kinds), + source_ref_prefix: typeof params.source_ref_prefix === "string" + ? params.source_ref_prefix + : undefined, + }), + ], + [ + "settlement.commit_reduction", + (params) => commitStepPayload({ + identity: settlementIdentityInput(params.identity), + step_kind: settlementStepKind(params.step_kind, "step_kind"), + transaction_phases: Array.isArray(params.transaction_phases) + ? stringArray(params.transaction_phases, "transaction_phases") + : [], + payload: params.payload, + source_ref_prefix: typeof params.source_ref_prefix === "string" + ? params.source_ref_prefix + : undefined, + }), + ], + ]); +} + +export async function dispatchEffectRuntimeMethod( + handlers: ReadonlyMap, + method: string, + params: JsonObject, +): Promise { + const handler = handlers.get(method); + if (!handler) throw new Error("unsupported Effect runtime method"); + return await handler(params); +} diff --git a/loopx/control_plane/effect_runtime_io.ts b/loopx/control_plane/effect_runtime_io.ts new file mode 100644 index 000000000..686ea5784 --- /dev/null +++ b/loopx/control_plane/effect_runtime_io.ts @@ -0,0 +1,121 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises"; +import { dirname } from "node:path"; + +import type { JsonObject } from "./effect_program.ts"; + +const MUTATION_LOCK_TIMEOUT_MS = 5_000; +const MUTATION_LOCK_POLL_MS = 25; +const INVALID_LOCK_STALE_MS = 10_000; + +interface MutationLockOwner { + pid: number; + token: string; +} + +function processIsAlive(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +async function readMutationLockOwner(path: string): Promise { + try { + const payload: unknown = JSON.parse(await readFile(path, "utf8")); + if ( + typeof payload === "object" && + payload !== null && + !Array.isArray(payload) && + typeof (payload as Record).pid === "number" && + typeof (payload as Record).token === "string" + ) { + return payload as MutationLockOwner; + } + } catch { + // A partially written or concurrently released lock is retried below. + } + return null; +} + +async function reclaimStaleMutationLock(path: string): Promise { + const owner = await readMutationLockOwner(path); + if (owner && processIsAlive(owner.pid)) return; + if (!owner) { + try { + if (Date.now() - (await stat(path)).mtimeMs < INVALID_LOCK_STALE_MS) return; + } catch { + return; + } + } + const stalePath = `${path}.stale.${randomUUID()}`; + try { + await rename(path, stalePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return; + } + await rm(stalePath, { force: true }); +} + +async function releaseMutationLock(path: string, token: string): Promise { + const owner = await readMutationLockOwner(path); + if (owner?.token === token) await rm(path, { force: true }); +} + +export async function withFileMutationLock( + targetPath: string, + operation: () => Promise, +): Promise { + await mkdir(dirname(targetPath), { recursive: true, mode: 0o700 }); + const lockPath = `${targetPath}.ts-effect.lock`; + const token = randomUUID(); + const deadline = Date.now() + MUTATION_LOCK_TIMEOUT_MS; + while (true) { + try { + const handle = await open(lockPath, "wx", 0o600); + try { + await handle.writeFile(JSON.stringify({ pid: process.pid, token }), "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + await reclaimStaleMutationLock(lockPath); + if (Date.now() >= deadline) { + throw new Error("Turn journal mutation lock timed out"); + } + await new Promise((resolve) => setTimeout(resolve, MUTATION_LOCK_POLL_MS)); + } + } + try { + return await operation(); + } finally { + await releaseMutationLock(lockPath, token); + } +} + +export async function atomicWriteJson( + path: string, + payload: JsonObject, +): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(`${JSON.stringify(payload, null, 2)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }); + } +} diff --git a/loopx/control_plane/effect_runtime_server.ts b/loopx/control_plane/effect_runtime_server.ts new file mode 100644 index 000000000..f67220f30 --- /dev/null +++ b/loopx/control_plane/effect_runtime_server.ts @@ -0,0 +1,133 @@ +import { createServer, type Socket } from "node:net"; +import { chmod, rm } from "node:fs/promises"; + +import type { JsonObject } from "./effect_program.ts"; +import { + createEffectRuntimeHandlers, + dispatchEffectRuntimeMethod, +} from "./effect_runtime_handlers.ts"; +import { atomicWriteJson } from "./effect_runtime_io.ts"; + +const REQUEST_SCHEMA = "loopx_effect_runtime_request_v0"; +const RESPONSE_SCHEMA = "loopx_effect_runtime_response_v0"; +const INFO_SCHEMA = "loopx_effect_runtime_info_v0"; +const MAX_REQUEST_BYTES = 2 * 1024 * 1024; +const DEFAULT_IDLE_MS = 5 * 60 * 1_000; +let shutdownRequested = false; + +interface RuntimeRequest { + schema_version: typeof REQUEST_SCHEMA; + token: string; + request_id: string; + method: string; + params: JsonObject; +} + +function asObject(value: unknown): JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : {}; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +function parseArg(name: string): string { + const index = process.argv.indexOf(name); + if (index < 0 || index + 1 >= process.argv.length) { + throw new Error(`missing ${name}`); + } + return requiredString(process.argv[index + 1], name); +} + +const infoPath = parseArg("--info"); +const fingerprint = parseArg("--fingerprint"); +const token = requiredString(process.env.LOOPX_EFFECT_RUNTIME_TOKEN, "runtime token"); +const idleMs = Number(process.env.LOOPX_EFFECT_RUNTIME_IDLE_MS ?? DEFAULT_IDLE_MS); +let idleTimer: NodeJS.Timeout; +const handlers = createEffectRuntimeHandlers({ + fingerprint, + requestShutdown: () => { + shutdownRequested = true; + }, +}); + +function resetIdleTimer(server: ReturnType): void { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => server.close(), idleMs); + idleTimer.unref(); +} + +function writeResponse(socket: Socket, response: JsonObject): void { + socket.end(`${JSON.stringify(response)}\n`); +} + +const server = createServer((socket) => { + resetIdleTimer(server); + socket.setEncoding("utf8"); + let raw = ""; + socket.on("data", (chunk: string) => { + raw += chunk; + if (Buffer.byteLength(raw, "utf8") > MAX_REQUEST_BYTES) { + socket.destroy(); + return; + } + if (!raw.includes("\n")) return; + socket.pause(); + void (async () => { + let requestId = "unknown"; + try { + const request = JSON.parse(raw.slice(0, raw.indexOf("\n"))) as RuntimeRequest; + requestId = requiredString(request.request_id, "request_id"); + if (request.schema_version !== REQUEST_SCHEMA || request.token !== token) { + throw new Error("Effect runtime request authentication failed"); + } + const result = await dispatchEffectRuntimeMethod( + handlers, + requiredString(request.method, "method"), + asObject(request.params), + ); + writeResponse(socket, { + schema_version: RESPONSE_SCHEMA, + request_id: requestId, + ok: true, + result, + }); + if (shutdownRequested) setImmediate(() => server.close()); + } catch (error) { + writeResponse(socket, { + schema_version: RESPONSE_SCHEMA, + request_id: requestId, + ok: false, + error: { + kind: "runtime_request_failed", + message: error instanceof Error ? error.message : String(error), + }, + }); + } + })(); + }); +}); + +server.on("close", () => { + void rm(infoPath, { force: true }).finally(() => process.exit(0)); +}); + +server.listen(0, "127.0.0.1", async () => { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("invalid address"); + await atomicWriteJson(infoPath, { + schema_version: INFO_SCHEMA, + fingerprint, + pid: process.pid, + host: "127.0.0.1", + port: address.port, + token, + }); + await chmod(infoPath, 0o600); + resetIdleTimer(server); +}); diff --git a/loopx/control_plane/settlement_driver.py b/loopx/control_plane/settlement_driver.py index 2d599c4a1..68a23e926 100644 --- a/loopx/control_plane/settlement_driver.py +++ b/loopx/control_plane/settlement_driver.py @@ -1,10 +1,9 @@ -"""Core-owned typed settlement receipt-chain driver. +"""Python callback adapter for the TS-owned Effect settlement runtime. -Quota and Turn adapters both settle a typed plan under one identity: validation -first, then writeback, then spend, replaying committed receipts for the same -effect id with every failure typed by step. This module owns that shared -receipt-chain semantics; adapters keep their provenance reads, effect bindings, -and domain policy (scheduler, Todo lifecycle, spend accounting, vision). +The TypeScript runtime owns identity, receipt, replay, ordering, phase advance, +and failure classification. Python remains only where an existing bounded +context still supplies an external callback; it submits the callback result to +the TS reducer before checkpointing it. """ from __future__ import annotations @@ -13,12 +12,14 @@ from typing import Any from .effect_program import ( + SettlementFailure, SettlementFailureKind, SettlementIdentity, SettlementReceipt, SettlementResult, SettlementStepKind, ) +from .effect_runtime import effect_runtime_result SettlementPayload = Mapping[str, Any] @@ -28,17 +29,46 @@ None, ] -DEFAULT_MISSING_FAILURE_KINDS: Mapping[SettlementStepKind, SettlementFailureKind] = { - SettlementStepKind.DURABLE_WRITEBACK: SettlementFailureKind.RECEIPT_MISSING, -} -MISSING_PAYLOAD_REASONS: Mapping[SettlementStepKind, str] = { - SettlementStepKind.DURABLE_WRITEBACK: ( - "Turn journal is missing its committed writeback payload" - ), - SettlementStepKind.QUOTA_SPEND: ( - "Turn journal is missing its committed quota spend payload" - ), -} +def _identity_payload(identity: SettlementIdentity) -> dict[str, Any]: + return identity.as_dict() + + +def _receipt_from_payload(payload: Mapping[str, Any]) -> SettlementReceipt: + return SettlementReceipt( + step_kind=SettlementStepKind(str(payload["step_kind"])), + status=str(payload["status"]), + effect_id=str(payload["effect_id"]), + source_ref=str(payload.get("source_ref") or "") or None, + ) + + +def _result_from_payload( + payload: Any, + *, + value_decoder: Callable[[Any], Any] | None = None, +) -> SettlementResult[Any]: + if not isinstance(payload, Mapping): + raise RuntimeError("TypeScript settlement result shape mismatch") + receipts_value = payload.get("receipts") + receipts = tuple( + _receipt_from_payload(receipt) + for receipt in receipts_value + if isinstance(receipt, Mapping) + ) if isinstance(receipts_value, list) else () + failure_value = payload.get("failure") + failure = None + if isinstance(failure_value, Mapping): + details = failure_value.get("details") + failure = SettlementFailure( + kind=SettlementFailureKind(str(failure_value["kind"])), + step_kind=SettlementStepKind(str(failure_value["step_kind"])), + reason=str(failure_value["reason"]), + details=dict(details) if isinstance(details, Mapping) else None, + ) + value = payload.get("value") + if value_decoder is not None and value is not None: + value = value_decoder(value) + return SettlementResult(value=value, receipts=receipts, failure=failure) def effect_ids_match( @@ -47,7 +77,15 @@ def effect_ids_match( ) -> bool: """Return whether committed receipts prove the expected effect id.""" - return not committed_effect_id or committed_effect_id == expected_effect_id + return bool( + effect_runtime_result( + "settlement.effect_ids_match", + { + "committed_effect_id": committed_effect_id, + "expected_effect_id": expected_effect_id, + }, + ) + ) def settlement_receipt( @@ -58,12 +96,17 @@ def settlement_receipt( ) -> SettlementReceipt: """Build one committed receipt for a settlement identity and step.""" - return SettlementReceipt( - step_kind=step_kind, - status="committed", - effect_id=identity.effect_id, - source_ref=source_ref, + payload = effect_runtime_result( + "settlement.receipt", + { + "identity": _identity_payload(identity), + "step_kind": step_kind.value, + "source_ref": source_ref, + }, ) + if not isinstance(payload, Mapping): + raise RuntimeError("TypeScript settlement receipt shape mismatch") + return _receipt_from_payload(payload) def settlement_identity_from_plan( @@ -71,69 +114,22 @@ def settlement_identity_from_plan( ) -> SettlementResult[SettlementIdentity]: """Resolve the complete typed settlement identity from a transaction plan.""" - settlement_plan = transaction_plan.get("settlement_plan") - if not isinstance(settlement_plan, Mapping): - return SettlementResult.failed( - kind=SettlementFailureKind.RECEIPT_MISSING, - step_kind=SettlementStepKind.VALIDATION, - reason="Turn transaction has no typed settlement plan", - ) - identity = settlement_plan.get("identity") - if not isinstance(identity, Mapping): - return SettlementResult.failed( - kind=SettlementFailureKind.INVALID_IDENTITY, - step_kind=SettlementStepKind.VALIDATION, - reason="Turn settlement plan has no identity", - ) - required = ("goal_id", "agent_id", "turn_instance_id") - if any(not str(identity.get(field) or "").strip() for field in required): - return SettlementResult.failed( - kind=SettlementFailureKind.INVALID_IDENTITY, - step_kind=SettlementStepKind.VALIDATION, - reason="Turn settlement plan has an incomplete identity", - ) - if bool(str(identity.get("todo_id") or "").strip()) == bool( - str(identity.get("replan_obligation_id") or "").strip() - ): - return SettlementResult.failed( - kind=SettlementFailureKind.INVALID_IDENTITY, - step_kind=SettlementStepKind.VALIDATION, - reason=( - "Turn settlement plan requires exactly one Todo or autonomous " - "replan obligation binding" - ), - ) - try: - built = SettlementIdentity( - goal_id=str(identity["goal_id"]), - agent_id=str(identity["agent_id"]), - todo_id=str(identity.get("todo_id") or "") or None, - turn_instance_id=str(identity["turn_instance_id"]), + def decode_identity(value: Any) -> SettlementIdentity: + if not isinstance(value, Mapping): + raise RuntimeError("TypeScript settlement identity shape mismatch") + return SettlementIdentity( + goal_id=str(value["goal_id"]), + agent_id=str(value["agent_id"]), + todo_id=str(value.get("todo_id") or "") or None, + turn_instance_id=str(value["turn_instance_id"]), replan_obligation_id=( - str(identity.get("replan_obligation_id") or "") or None + str(value.get("replan_obligation_id") or "") or None ), ) - except ValueError as exc: - return SettlementResult.failed( - kind=SettlementFailureKind.INVALID_IDENTITY, - step_kind=SettlementStepKind.VALIDATION, - reason=str(exc), - ) - effect_id = str(identity.get("effect_id") or "").strip() - if not effect_id: - return SettlementResult.failed( - kind=SettlementFailureKind.INVALID_IDENTITY, - step_kind=SettlementStepKind.VALIDATION, - reason="Turn settlement plan has no effect id", - ) - if effect_id and effect_id != built.effect_id: - return SettlementResult.failed( - kind=SettlementFailureKind.INVALID_IDENTITY, - step_kind=SettlementStepKind.VALIDATION, - reason="Turn settlement plan effect id does not match its identity", - ) - return SettlementResult.pure( - built, + + return _result_from_payload( + effect_runtime_result("settlement.identity_from_plan", transaction_plan), + value_decoder=decode_identity, ) @@ -143,26 +139,25 @@ def require_matching_effect_id( ) -> SettlementResult[str]: """Fail closed when committed receipts belong to another effect id.""" - if not effect_ids_match(committed_effect_id, expected_effect_id): - return SettlementResult.failed( - kind=SettlementFailureKind.IDENTITY_MISMATCH, - step_kind=SettlementStepKind.VALIDATION, - reason=( - "Turn journal belongs to another settlement effect: journal " - f"effect is {committed_effect_id} but plan effect is " - f"{expected_effect_id}" - ), + return _result_from_payload( + effect_runtime_result( + "settlement.match_effect", + { + "committed_effect_id": committed_effect_id, + "expected_effect_id": expected_effect_id, + }, ) - return SettlementResult.pure(expected_effect_id) + ) def is_committed_payload(value: Mapping[str, Any] | None) -> bool: """Return whether a step payload durably committed its effect.""" return bool( - isinstance(value, Mapping) - and value.get("ok") is True - and value.get("appended") is True + effect_runtime_result( + "settlement.is_committed_payload", + {"payload": dict(value) if isinstance(value, Mapping) else value}, + ) ) @@ -180,95 +175,70 @@ def seed_committed_steps( ) -> SettlementResult[dict[SettlementStepKind, Mapping[str, Any]]]: """Seed committed receipts in plan order from durable step payloads.""" - if completed_phases is not None and transaction_phases is not None: - phases = tuple(str(phase) for phase in completed_phases) - if phases != tuple(transaction_phases)[: len(phases)]: - return SettlementResult.failed( - kind=SettlementFailureKind.RECEIPT_MISSING, - step_kind=SettlementStepKind.VALIDATION, - reason="Turn journal phases are not an ordered transaction prefix", - ) - if require_validation and "validation" not in phases: - return SettlementResult.failed( - kind=SettlementFailureKind.RECEIPT_MISSING, - step_kind=SettlementStepKind.VALIDATION, - reason="Turn settlement requires a committed validation receipt", - ) - missing_kinds = dict(DEFAULT_MISSING_FAILURE_KINDS) - if missing_failure_kinds: - missing_kinds.update(missing_failure_kinds) - receipts: list[SettlementReceipt] = [] - committed: dict[SettlementStepKind, Mapping[str, Any]] = {} - for step_kind in ordered_steps: - if step_kind is SettlementStepKind.VALIDATION and require_validation: - if completed_phases is not None and "validation" not in completed_phases: - return SettlementResult.failed( - kind=SettlementFailureKind.RECEIPT_MISSING, - step_kind=step_kind, - reason="Turn settlement requires a committed validation receipt", - receipts=tuple(receipts), - ) - receipts.append( - settlement_receipt( - identity, - step_kind=step_kind, - source_ref=( - f"{source_ref_prefix}:{identity.effect_id}#{step_kind.value}" - ), - ) - ) - committed[step_kind] = {} - continue - if completed_phases is not None: - committed_flag = step_kind.value in completed_phases - else: - committed_flag = True - if not committed_flag: - # Pending step: not yet committed; the caller may run its effect. - continue - payload = committed_payloads.get(step_kind) - if payload is None: - return SettlementResult.failed( - kind=missing_kinds.get(step_kind, SettlementFailureKind.RECEIPT_MISSING), - step_kind=step_kind, - reason=MISSING_PAYLOAD_REASONS.get( - step_kind, - f"Turn journal is missing its committed {step_kind.value} payload", - ), - receipts=tuple(receipts), - ) - if not is_committed_payload(payload): - return SettlementResult.failed( - kind=missing_kinds.get(step_kind, SettlementFailureKind.RECEIPT_MISSING), - step_kind=step_kind, - reason=MISSING_PAYLOAD_REASONS.get( - step_kind, - f"Turn journal is missing its committed {step_kind.value} payload", - ), - receipts=tuple(receipts), - ) - receipts.append( - settlement_receipt( - identity, - step_kind=step_kind, - source_ref=f"{source_ref_prefix}:{identity.effect_id}#{step_kind.value}", - ) - ) - committed[step_kind] = payload - return SettlementResult.pure(committed, receipts=tuple(receipts)) + serialized_payloads = { + step_kind.value: dict(payload) if isinstance(payload, Mapping) else payload + for step_kind, payload in committed_payloads.items() + } + result = effect_runtime_result( + "settlement.seed", + { + "identity": _identity_payload(identity), + "ordered_steps": [step.value for step in ordered_steps], + "committed_payloads": serialized_payloads, + "completed_phases": list(completed_phases) if completed_phases is not None else None, + "transaction_phases": list(transaction_phases) if transaction_phases is not None else None, + "require_validation": require_validation, + "missing_failure_kinds": { + step.value: kind.value + for step, kind in (missing_failure_kinds or {}).items() + }, + "source_ref_prefix": source_ref_prefix, + }, + ) + return _result_from_payload(result) -def _callback_failure_kind( - step_kind: SettlementStepKind, - reason: str, -) -> SettlementFailureKind: - if step_kind is SettlementStepKind.DURABLE_WRITEBACK: - return SettlementFailureKind.WRITEBACK_REJECTED - if step_kind is SettlementStepKind.TERMINAL_CLOSEOUT: - return SettlementFailureKind.TERMINAL_CLOSEOUT_REJECTED - if "budget" in reason.casefold(): - return SettlementFailureKind.BUDGET_REJECTED - return SettlementFailureKind.QUOTA_SPEND_REJECTED +def settlement_next_action( + identity: SettlementIdentity, + *, + ordered_steps: Sequence[SettlementStepKind], + committed_payloads: Mapping[SettlementStepKind, Mapping[str, Any] | None], + completed_phases: Sequence[str] | None = None, + transaction_phases: Sequence[str] | None = None, + require_validation: bool = True, + missing_failure_kinds: Mapping[SettlementStepKind, SettlementFailureKind] + | None = None, + source_ref_prefix: str = "turn_journal", +) -> tuple[str, SettlementStepKind | None, SettlementResult[Any]]: + """Ask the TS runtime which typed settlement effect is runnable next.""" + + payload = effect_runtime_result( + "settlement.next_action", + { + "identity": _identity_payload(identity), + "ordered_steps": [step.value for step in ordered_steps], + "committed_payloads": { + step.value: dict(value) if isinstance(value, Mapping) else value + for step, value in committed_payloads.items() + }, + "completed_phases": list(completed_phases) if completed_phases is not None else None, + "transaction_phases": list(transaction_phases) if transaction_phases is not None else None, + "require_validation": require_validation, + "missing_failure_kinds": { + step.value: kind.value + for step, kind in (missing_failure_kinds or {}).items() + }, + "source_ref_prefix": source_ref_prefix, + }, + ) + if not isinstance(payload, Mapping): + raise RuntimeError("TypeScript settlement next-action shape mismatch") + decision = str(payload.get("decision") or "") + if decision not in {"failed", "execute", "complete"}: + raise RuntimeError("TypeScript settlement next-action decision mismatch") + raw_step = payload.get("step_kind") + step_kind = SettlementStepKind(str(raw_step)) if raw_step is not None else None + return decision, step_kind, _result_from_payload(payload.get("result")) def commit_step_effect( @@ -283,27 +253,22 @@ def commit_step_effect( """Run one step effect and record its committed receipt.""" payload = dict(effect()) - if not is_committed_payload(payload): - reason = str( - payload.get("error") - or payload.get("reason") - or f"{step_kind.value} callback rejected the settlement" - ) - return SettlementResult.failed( - kind=_callback_failure_kind(step_kind, reason), - step_kind=step_kind, - reason=reason, - ) - phase_index = tuple(transaction_phases).index(step_kind.value) - completed_phases = tuple(transaction_phases)[: phase_index + 1] - checkpoint(step_kind, payload, completed_phases) - return SettlementResult.pure( - payload, - receipts=( - settlement_receipt( - identity, - step_kind=step_kind, - source_ref=f"{source_ref_prefix}:{identity.effect_id}#{step_kind.value}", - ), - ), + reduction = effect_runtime_result( + "settlement.commit_reduction", + { + "identity": _identity_payload(identity), + "step_kind": step_kind.value, + "transaction_phases": list(transaction_phases), + "payload": payload, + "source_ref_prefix": source_ref_prefix, + }, ) + if not isinstance(reduction, Mapping): + raise RuntimeError("TypeScript settlement reduction shape mismatch") + result = _result_from_payload(reduction.get("result")) + completed = reduction.get("completed_phases") + if result.failure is None: + if not isinstance(completed, list): + raise RuntimeError("TypeScript settlement reduction has no phase prefix") + checkpoint(step_kind, payload, tuple(str(phase) for phase in completed)) + return result diff --git a/loopx/control_plane/testing/turn_journal_characterization.py b/loopx/control_plane/testing/turn_journal_characterization.py new file mode 100644 index 000000000..de77fd61f --- /dev/null +++ b/loopx/control_plane/testing/turn_journal_characterization.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import json +import os +import subprocess +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + + +TURN_JOURNAL_CHARACTERIZATION_CORPUS_VERSION = ( + "loopx_turn_journal_characterization_corpus_v0" +) +TURN_JOURNAL_CHARACTERIZATION_PROBE_VERSION = ( + "loopx_turn_journal_characterization_probe_v0" +) +TURN_JOURNAL_CHARACTERIZATION_REQUEST_VERSION = ( + "loopx_turn_journal_characterization_request_v0" +) +TURN_JOURNAL_CHARACTERIZATION_DIFFERENTIAL_VERSION = ( + "loopx_turn_journal_characterization_differential_v0" +) + +_PROJECTION_FIELDS = ( + "decision", + "journal_status", + "replay_legal", + "goal_matches", + "owner_matches", + "turn_key_matches", + "phases_form_ordered_prefix", + "completed_phases", + "tombstone_retained", + "violations", + "effects", +) +_INVARIANT_FIELDS = ( + "decision", + "replay_legal", + "goal_matches", + "owner_matches", + "turn_key_matches", + "phases_form_ordered_prefix", + "tombstone_retained", +) +_BANNED_KEYS = frozenset( + { + "credential", + "credentials", + "password", + "raw_log", + "raw_logs", + "secret", + "token", + "trajectory", + "trajectories", + "verifier_output", + } +) + +def _walk(value: Any) -> list[tuple[str, Any]]: + rows: list[tuple[str, Any]] = [] + if isinstance(value, Mapping): + for key, child in value.items(): + rows.append((str(key), child)) + rows.extend(_walk(child)) + elif isinstance(value, list): + for child in value: + rows.extend(_walk(child)) + return rows + + +def validate_turn_journal_characterization_corpus( + corpus: Mapping[str, Any], +) -> None: + if corpus.get("schema_version") != TURN_JOURNAL_CHARACTERIZATION_CORPUS_VERSION: + raise ValueError("Turn-journal corpus schema_version mismatch") + cases = corpus.get("cases") + if not isinstance(cases, list) or not cases: + raise ValueError("Turn-journal corpus requires at least one case") + + seen: set[str] = set() + for case in cases: + if not isinstance(case, Mapping): + raise ValueError("Turn-journal corpus cases must be objects") + case_id = str(case.get("case_id") or "").strip() + if not case_id or case_id in seen: + raise ValueError("Turn-journal case_id must be non-empty and unique") + seen.add(case_id) + if not str(case.get("invariant_id") or "").strip(): + raise ValueError(f"Turn-journal case {case_id} requires invariant_id") + if not str(case.get("rationale") or "").strip(): + raise ValueError(f"Turn-journal case {case_id} requires rationale") + + request = case.get("request") + invariant = case.get("invariant") + if not isinstance(request, Mapping) or not isinstance( + request.get("journal"), Mapping + ): + raise ValueError(f"Turn-journal case {case_id} requires request.journal") + for field in ("goal_id", "agent_id", "turn_key"): + value = request.get(field) + if not isinstance(value, str) or not value: + raise ValueError( + f"Turn-journal case {case_id} requires request.{field}" + ) + if not isinstance(invariant, Mapping): + raise ValueError(f"Turn-journal case {case_id} requires invariant") + unknown = set(invariant) - set(_INVARIANT_FIELDS) + if unknown: + raise ValueError( + f"Turn-journal case {case_id} has unsupported invariants: " + + ", ".join(sorted(unknown)) + ) + if not invariant: + raise ValueError(f"Turn-journal case {case_id} has no invariants") + + for key, value in _walk(corpus): + if key.lower() in _BANNED_KEYS: + raise ValueError(f"Turn-journal corpus contains banned key: {key}") + if isinstance(value, str) and (value.startswith("/") or "file://" in value): + raise ValueError(f"Turn-journal corpus contains a local path in {key}") + + +def load_turn_journal_characterization_corpus(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("Turn-journal corpus root must be an object") + validate_turn_journal_characterization_corpus(payload) + return payload + + +def run_turn_journal_probe_command( + corpus: Mapping[str, Any], + *, + command: Sequence[str], + cwd: Path, + environment: Mapping[str, str] | None = None, + implementation_id: str, +) -> dict[str, Any]: + """Run one semantic owner as an isolated whole-corpus JSON process.""" + + validate_turn_journal_characterization_corpus(corpus) + if not command: + raise ValueError("Turn-journal probe command must not be empty") + request = { + "schema_version": TURN_JOURNAL_CHARACTERIZATION_REQUEST_VERSION, + "implementation_id": implementation_id, + "corpus": corpus, + } + env = dict(os.environ) + if environment is not None: + env.update(environment) + completed = subprocess.run( + list(command), + cwd=cwd, + env=env, + check=False, + capture_output=True, + input=json.dumps(request, sort_keys=True), + text=True, + timeout=30, + ) + if completed.returncode != 0: + raise RuntimeError( + f"Turn-journal probe failed with exit {completed.returncode}: " + + completed.stderr.strip() + ) + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError("Turn-journal probe returned malformed JSON") from exc + if not isinstance(payload, dict): + raise RuntimeError("Turn-journal probe root must be an object") + if payload.get("schema_version") != TURN_JOURNAL_CHARACTERIZATION_PROBE_VERSION: + raise RuntimeError("Turn-journal probe returned an unsupported schema") + return payload + + +def _index_probe_rows( + corpus: Mapping[str, Any], + receipt: Mapping[str, Any], +) -> dict[str, Mapping[str, Any]]: + if receipt.get("schema_version") != TURN_JOURNAL_CHARACTERIZATION_PROBE_VERSION: + raise ValueError("Turn-journal probe receipt schema_version mismatch") + if receipt.get("corpus_schema_version") != corpus.get("schema_version"): + raise ValueError("Turn-journal probe receipt corpus_schema_version mismatch") + if not str(receipt.get("implementation_id") or "").strip(): + raise ValueError("Turn-journal probe receipt requires implementation_id") + rows = receipt.get("rows") + if not isinstance(rows, list): + raise ValueError("Turn-journal probe receipt requires rows") + expected_keys = {"case_id", *_PROJECTION_FIELDS} + indexed: dict[str, Mapping[str, Any]] = {} + for row in rows: + if not isinstance(row, Mapping): + raise ValueError("Turn-journal probe rows must be objects") + case_id = str(row.get("case_id") or "").strip() + if not case_id or case_id in indexed: + raise ValueError("Turn-journal probe case_id must be non-empty and unique") + if set(row) != expected_keys: + raise ValueError(f"Turn-journal probe {case_id} projection shape mismatch") + if row.get("effects") != []: + raise ValueError(f"Turn-journal probe {case_id} must remain effect-free") + indexed[case_id] = row + corpus_ids = {str(case["case_id"]) for case in corpus["cases"]} + if set(indexed) != corpus_ids: + raise ValueError("Turn-journal probe rows must match the corpus case set") + return indexed + + +def evaluate_turn_journal_invariants( + corpus: Mapping[str, Any], + receipt: Mapping[str, Any], +) -> dict[str, list[str]]: + validate_turn_journal_characterization_corpus(corpus) + by_id = _index_probe_rows(corpus, receipt) + failures: dict[str, list[str]] = {} + for case in corpus["cases"]: + assert isinstance(case, Mapping) + case_id = str(case["case_id"]) + row = by_id.get(case_id) + if row is None: + failures[case_id] = ["probe row missing"] + continue + invariant = case["invariant"] + assert isinstance(invariant, Mapping) + mismatches = [ + f"{field}: expected {expected!r}, got {row.get(field)!r}" + for field, expected in invariant.items() + if row.get(field) != expected + ] + if mismatches: + failures[case_id] = mismatches + return failures + + +def compare_turn_journal_characterization_receipts( + corpus: Mapping[str, Any], + base_receipt: Mapping[str, Any], + candidate_receipt: Mapping[str, Any], +) -> dict[str, Any]: + base_failures = evaluate_turn_journal_invariants(corpus, base_receipt) + candidate_failures = evaluate_turn_journal_invariants(corpus, candidate_receipt) + base_rows = _index_probe_rows(corpus, base_receipt) + candidate_rows = _index_probe_rows(corpus, candidate_receipt) + + comparisons: list[dict[str, Any]] = [] + for case in corpus["cases"]: + assert isinstance(case, Mapping) + case_id = str(case["case_id"]) + base = base_rows.get(case_id) + candidate = candidate_rows.get(case_id) + exact_match = base == candidate and base is not None + failures = candidate_failures.get(case_id, []) + if failures or base is None or candidate is None: + status = "failed" + elif exact_match: + status = "passed" + else: + status = "review_required" + comparisons.append( + { + "case_id": case_id, + "status": status, + "exact_match": exact_match, + "base_invariant_failures": base_failures.get(case_id, []), + "candidate_invariant_failures": failures, + } + ) + + failed = [row for row in comparisons if row["status"] == "failed"] + review = [row for row in comparisons if row["status"] == "review_required"] + return { + "schema_version": TURN_JOURNAL_CHARACTERIZATION_DIFFERENTIAL_VERSION, + "ok": not failed and not base_failures, + "exact_parity": not failed and not review and not base_failures, + "base_invariant_failures": base_failures, + "candidate_invariant_failures": candidate_failures, + "failed_case_count": len(failed), + "review_required_case_count": len(review), + "cases": comparisons, + } diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 459910c13..53a4eb883 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -3,10 +3,8 @@ from __future__ import annotations import json -import os import re import subprocess -import tempfile from collections.abc import Callable, Mapping, Sequence from pathlib import Path from typing import Any @@ -17,7 +15,6 @@ from ..effect_program import ( SettlementResult, SettlementStepKind, - interpret_turn_journal, interpret_turn_result_packet, settlement_result_payload, ) @@ -45,6 +42,10 @@ build_loopx_turn_transaction_plan, validate_loopx_turn_receipt, ) +from .turn_journal_runtime import ( + interpret_turn_journal_projection, + write_turn_journal, +) LOOPX_TURN_HOST_REQUEST_SCHEMA_VERSION = "loopx_turn_host_request_v0" LOOPX_TURN_JOURNAL_INSPECTION_SCHEMA_VERSION = "loopx_turn_journal_inspection_v0" @@ -565,16 +566,11 @@ def turn_journal_path(runtime_root: Path, *, goal_id: str, turn_key: str) -> Pat def _write_journal(path: Path, journal: Mapping[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) - temporary = Path(temporary_name) - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - json.dump(journal, handle, ensure_ascii=False, indent=2, sort_keys=True) - handle.write("\n") - os.replace(temporary, path) - finally: - temporary.unlink(missing_ok=True) + write_turn_journal( + str(path), + journal, + expected_effect_id=_journal_committed_effect_id(journal), + ) def _load_journal(path: Path) -> dict[str, Any] | None: @@ -615,28 +611,12 @@ def inspect_loopx_turn_journal( if journal is None: raise ValueError("LoopX Turn journal does not exist") - turn = interpret_turn_journal( + return interpret_turn_journal_projection( journal, goal_id=safe_goal_id, agent_id=agent_id, turn_key=turn_key, ) - context = turn.request.context - return { - "ok": True, - "schema_version": LOOPX_TURN_JOURNAL_INSPECTION_SCHEMA_VERSION, - "decision": turn.observation.decision, - "journal_status": context["journal_status"], - "replay_legal": context["replay_legal"], - "goal_matches": context["goal_matches"], - "owner_matches": context["owner_matches"], - "turn_key_matches": context["turn_key_matches"], - "phases_form_ordered_prefix": context["phases_form_ordered_prefix"], - "completed_phases": list(context["completed_phases"]), - "tombstone_retained": context["tombstone_retained"], - "violations": list(context["violations"]), - "effects": [], - } def _journal_committed_effect_id(journal: Mapping[str, Any]) -> str | None: diff --git a/loopx/control_plane/turn_driver/settlement.py b/loopx/control_plane/turn_driver/settlement.py index b5a96be41..9c57e65e2 100644 --- a/loopx/control_plane/turn_driver/settlement.py +++ b/loopx/control_plane/turn_driver/settlement.py @@ -14,6 +14,7 @@ commit_step_effect, require_matching_effect_id, seed_committed_steps, + settlement_next_action, settlement_identity_from_plan, ) from .driver import selected_turn_todo @@ -135,7 +136,13 @@ def execute_turn_driver_settlement( identity_result = settlement_identity_from_plan(transaction_plan) if identity_result.failure is not None: - return identity_result + return SettlementResult.failed( + kind=identity_result.failure.kind, + step_kind=identity_result.failure.step_kind, + reason=identity_result.failure.reason, + receipts=identity_result.receipts, + details=identity_result.failure.details, + ) identity = identity_result.value assert identity is not None matched = require_matching_effect_id(committed_effect_id, identity.effect_id) @@ -151,58 +158,41 @@ def execute_turn_driver_settlement( SettlementStepKind.DURABLE_WRITEBACK: writeback_payload, SettlementStepKind.QUOTA_SPEND: quota_spend_payload, } - seeded = seed_committed_steps( - identity, - ordered_steps=SETTLEMENT_STEPS, - committed_payloads=committed_payloads, - completed_phases=phases, - transaction_phases=transaction_phases, - require_validation=True, - source_ref_prefix="turn_journal", - ) - if seeded.failure is not None: - return SettlementResult.failed( - kind=seeded.failure.kind, - step_kind=seeded.failure.step_kind, - reason=seeded.failure.reason, - receipts=seeded.receipts, - ) state = TurnSettlementState( completed_phases=phases, writeback=writeback_payload, quota_spend=quota_spend_payload, ) - receipts = list(seeded.receipts) - if "durable_writeback" not in phases: - step_result = commit_step_effect( + callbacks = { + SettlementStepKind.DURABLE_WRITEBACK: writeback, + SettlementStepKind.QUOTA_SPEND: spend, + } + while True: + decision, step_kind, seeded = settlement_next_action( identity, - step_kind=SettlementStepKind.DURABLE_WRITEBACK, + ordered_steps=SETTLEMENT_STEPS, + committed_payloads=committed_payloads, + completed_phases=state.completed_phases, transaction_phases=transaction_phases, - effect=writeback, - checkpoint=checkpoint, + require_validation=True, + source_ref_prefix="turn_journal", ) - if step_result.failure is not None: + if seeded.failure is not None: return SettlementResult.failed( - kind=step_result.failure.kind, - step_kind=step_result.failure.step_kind, - reason=step_result.failure.reason, - receipts=tuple(receipts), + kind=seeded.failure.kind, + step_kind=seeded.failure.step_kind, + reason=seeded.failure.reason, + receipts=seeded.receipts, ) - receipts.extend(step_result.receipts) - phase_index = transaction_phases.index( - SettlementStepKind.DURABLE_WRITEBACK.value - ) - state = TurnSettlementState( - completed_phases=tuple(transaction_phases[: phase_index + 1]), - writeback=step_result.value, - quota_spend=state.quota_spend, - ) - if "quota_spend" not in phases: + if decision == "complete": + return SettlementResult.pure(state, receipts=seeded.receipts) + if decision != "execute" or step_kind not in callbacks: + raise RuntimeError("typed settlement selected an unsupported callback") step_result = commit_step_effect( identity, - step_kind=SettlementStepKind.QUOTA_SPEND, + step_kind=step_kind, transaction_phases=transaction_phases, - effect=spend, + effect=callbacks[step_kind], checkpoint=checkpoint, ) if step_result.failure is not None: @@ -210,16 +200,24 @@ def execute_turn_driver_settlement( kind=step_result.failure.kind, step_kind=step_result.failure.step_kind, reason=step_result.failure.reason, - receipts=tuple(receipts), + receipts=seeded.receipts, ) - receipts.extend(step_result.receipts) - phase_index = transaction_phases.index(SettlementStepKind.QUOTA_SPEND.value) + phase_index = transaction_phases.index(step_kind.value) + completed = tuple(transaction_phases[: phase_index + 1]) + committed_payloads[step_kind] = step_result.value state = TurnSettlementState( - completed_phases=tuple(transaction_phases[: phase_index + 1]), - writeback=state.writeback, - quota_spend=step_result.value, + completed_phases=completed, + writeback=( + step_result.value + if step_kind is SettlementStepKind.DURABLE_WRITEBACK + else state.writeback + ), + quota_spend=( + step_result.value + if step_kind is SettlementStepKind.QUOTA_SPEND + else state.quota_spend + ), ) - return SettlementResult.pure(state, receipts=tuple(receipts)) def execute_turn_terminal_closeout( @@ -240,7 +238,13 @@ def execute_turn_terminal_closeout( identity_result = settlement_identity_from_plan(transaction_plan) if identity_result.failure is not None: - return identity_result + return SettlementResult.failed( + kind=identity_result.failure.kind, + step_kind=identity_result.failure.step_kind, + reason=identity_result.failure.reason, + receipts=identity_result.receipts, + details=identity_result.failure.details, + ) identity = identity_result.value assert identity is not None matched = require_matching_effect_id(committed_effect_id, identity.effect_id) diff --git a/loopx/control_plane/turn_driver/turn_journal.ts b/loopx/control_plane/turn_driver/turn_journal.ts new file mode 100644 index 000000000..776bcb374 --- /dev/null +++ b/loopx/control_plane/turn_driver/turn_journal.ts @@ -0,0 +1,230 @@ +import transactionContract from "../turn_transaction_contract.json" with { + type: "json", +}; + +import type { EffectTurn } from "../effect_program.ts"; + +export const TURN_JOURNAL_INSPECTION_SCHEMA_VERSION = + "loopx_turn_journal_inspection_v0"; + +type JsonObject = Record; + +export interface TurnJournalInspectionRequest { + schema_version: "loopx_turn_journal_interpretation_request_v0"; + journal: JsonObject; + goal_id: string; + agent_id: string; + turn_key: string; +} + +export interface TurnJournalInspection { + ok: true; + schema_version: typeof TURN_JOURNAL_INSPECTION_SCHEMA_VERSION; + decision: "replay_legal" | "replay_blocked"; + journal_status: string; + replay_legal: boolean; + goal_matches: boolean; + owner_matches: boolean; + turn_key_matches: boolean; + phases_form_ordered_prefix: boolean; + completed_phases: string[]; + tombstone_retained: boolean; + violations: string[]; + effects: []; +} + +export interface TurnJournalEffectContext { + replay_legal: boolean; + goal_matches: boolean; + owner_matches: boolean; + turn_key_matches: boolean; + phases_form_ordered_prefix: boolean; + journal_status: string; + tombstone_retained: boolean; + completed_phases: string[]; + violations: string[]; +} + +export type TurnJournalEffect = EffectTurn< + TurnJournalEffectContext, + "replay_legal" | "replay_blocked" +>; + +const transactionPhases = Object.freeze([...transactionContract.phases]); + +function asObject(value: unknown): JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : {}; +} + +function isValidIdentity(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function identityState( + requiredValues: unknown[], + optionalValues: Array<[boolean, unknown]>, + expected: unknown, +): [complete: boolean, matches: boolean] { + const complete = + requiredValues.every(isValidIdentity) && + optionalValues.every(([present, value]) => !present || isValidIdentity(value)) && + isValidIdentity(expected); + const observed = requiredValues.filter(isValidIdentity); + for (const [present, value] of optionalValues) { + if (present && isValidIdentity(value)) observed.push(value); + } + if (isValidIdentity(expected)) observed.push(expected); + return [complete, complete && new Set(observed).size === 1]; +} + +function stringifyPhase(value: unknown): string { + if (value === null) return "None"; + if (value === true) return "True"; + if (value === false) return "False"; + return String(value); +} + +export function interpretTurnJournalEffect( + request: TurnJournalInspectionRequest, +): TurnJournalEffect { + if (request.schema_version !== "loopx_turn_journal_interpretation_request_v0") { + throw new Error("Turn-journal interpretation request schema mismatch"); + } + const journal = asObject(request.journal); + const plan = asObject(journal.plan); + const envelope = asObject(plan.turn_envelope); + const transaction = asObject(plan.transaction); + const settlement = asObject(transaction.settlement_plan); + const identity = asObject(settlement.identity); + const hostResult = asObject(journal.host_result); + const receipt = asObject(journal.receipt); + + const [goalComplete, goalMatches] = identityState( + [journal.goal_id, envelope.goal_id, identity.goal_id], + [], + request.goal_id, + ); + const [ownerComplete, ownerMatches] = identityState( + [envelope.agent_id, identity.agent_id], + [], + request.agent_id, + ); + const [turnKeyComplete, turnKeyMatches] = identityState( + [journal.turn_key, transaction.turn_key], + [ + [Object.hasOwn(hostResult, "turn_key"), hostResult.turn_key], + [Object.hasOwn(receipt, "turn_key"), receipt.turn_key], + ], + request.turn_key, + ); + + const violations: string[] = []; + if (!goalComplete) violations.push("goal_identity_missing"); + else if (!goalMatches) violations.push("goal_mismatch"); + if (!ownerComplete) violations.push("owner_identity_missing"); + else if (!ownerMatches) violations.push("owner_mismatch"); + if (!turnKeyComplete) violations.push("turn_key_identity_missing"); + else if (!turnKeyMatches) violations.push("turn_key_mismatch"); + + let completedPhases: string[] = []; + let phasesFormOrderedPrefix = false; + if (Array.isArray(journal.completed_phases)) { + completedPhases = journal.completed_phases.map(stringifyPhase); + phasesFormOrderedPrefix = completedPhases.every( + (phase, index) => transactionPhases[index] === phase, + ); + if (!phasesFormOrderedPrefix) { + violations.push("completed_phases_not_ordered_prefix"); + } + } else { + violations.push("completed_phases_invalid"); + } + + const journalStatus = journal.status ? String(journal.status) : ""; + const tombstoneRetained = ["committed", "stopped", "failed"].includes( + journalStatus, + ); + if (["in_progress", "scheduler_action_required"].includes(journalStatus)) { + violations.push("journal_not_terminal"); + } else if (!tombstoneRetained) { + violations.push("journal_status_unsupported"); + } + + const replayLegal = violations.length === 0; + const decision = replayLegal ? "replay_legal" : "replay_blocked"; + return { + request: { + kind: "turn_journal", + source: "turn_journal", + goal_id: request.goal_id, + agent_id: request.agent_id, + capabilities: [], + context: { + replay_legal: replayLegal, + goal_matches: goalMatches, + owner_matches: ownerMatches, + turn_key_matches: turnKeyMatches, + phases_form_ordered_prefix: phasesFormOrderedPrefix, + journal_status: journalStatus, + tombstone_retained: tombstoneRetained, + completed_phases: completedPhases, + violations, + }, + }, + interpretation: { + route: "turn_journal_replay", + obligation: "observe_fenced_replay", + interaction_mode: "read_only", + capability_action: null, + cadence_class: null, + }, + observation: { + decision, + should_run: false, + effective_action: replayLegal ? "observe_replay" : "block_replay", + recommended_action: replayLegal + ? "Retain the terminal Turn journal tombstone." + : "Inspect the structured Turn journal violations before replay.", + protocol_summary: replayLegal + ? "Turn journal replay is legal and effect-free." + : `Turn journal replay is blocked by ${violations.length} structured violation(s).`, + }, + next_effect: { + cli_actions: [], + execution_mode: null, + scheduler_action: null, + cadence_class: null, + ack_cli_args: [], + failure_cli_args: [], + }, + }; +} + +export function projectTurnJournalInspection( + turn: TurnJournalEffect, +): TurnJournalInspection { + const context = turn.request.context; + return { + ok: true, + schema_version: TURN_JOURNAL_INSPECTION_SCHEMA_VERSION, + decision: turn.observation.decision, + journal_status: context.journal_status, + replay_legal: context.replay_legal, + goal_matches: context.goal_matches, + owner_matches: context.owner_matches, + turn_key_matches: context.turn_key_matches, + phases_form_ordered_prefix: context.phases_form_ordered_prefix, + completed_phases: context.completed_phases, + tombstone_retained: context.tombstone_retained, + violations: context.violations, + effects: [], + }; +} + +export function interpretTurnJournal( + request: TurnJournalInspectionRequest, +): TurnJournalInspection { + return projectTurnJournalInspection(interpretTurnJournalEffect(request)); +} diff --git a/loopx/control_plane/turn_driver/turn_journal_effects.ts b/loopx/control_plane/turn_driver/turn_journal_effects.ts new file mode 100644 index 000000000..703a51e0f --- /dev/null +++ b/loopx/control_plane/turn_driver/turn_journal_effects.ts @@ -0,0 +1,115 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { isAbsolute } from "node:path"; + +import type { JsonObject } from "../effect_program.ts"; +import { + atomicWriteJson, + withFileMutationLock, +} from "../effect_runtime_io.ts"; + +function asObject(value: unknown): JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : {}; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +function journalEffectId(journal: JsonObject): string | null { + const plan = asObject(journal.plan); + const transaction = asObject(plan.transaction); + const settlement = asObject(transaction.settlement_plan); + const identity = asObject(settlement.identity); + const effectId = typeof identity.effect_id === "string" + ? identity.effect_id.trim() + : ""; + return effectId || null; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, stableValue(child)]), + ); +} + +function operationId(journal: JsonObject): string { + return `sha256:${sha256(JSON.stringify(stableValue(journal)))}`; +} + +export async function commitTurnJournal( + params: JsonObject, +): Promise { + const path = requiredString(params.path, "path"); + if (!isAbsolute(path)) { + throw new Error("Turn journal path must be absolute"); + } + const journal = asObject(params.journal); + if (journal.schema_version !== "loopx_turn_journal_v0") { + throw new Error("Turn journal has an unsupported schema"); + } + const expectedEffectId = typeof params.expected_effect_id === "string" + ? params.expected_effect_id.trim() + : ""; + const incomingEffectId = journalEffectId(journal); + if (expectedEffectId && incomingEffectId !== expectedEffectId) { + throw new Error("Turn journal does not carry the expected settlement effect"); + } + const expectedPreviousSha256 = params.expected_previous_sha256; + if (expectedPreviousSha256 !== null && typeof expectedPreviousSha256 !== "string") { + throw new Error("expected_previous_sha256 must be a string or null"); + } + const incomingOperationId = operationId(journal); + return await withFileMutationLock(path, async () => { + let existing: JsonObject | null = null; + let existingSha256: string | null = null; + try { + const encoded = await readFile(path, "utf8"); + existingSha256 = sha256(encoded); + existing = asObject(JSON.parse(encoded)); + const existingEffectId = journalEffectId(existing); + if ( + existingEffectId && + incomingEffectId && + existingEffectId !== incomingEffectId + ) { + throw new Error("Turn journal belongs to another settlement effect"); + } + if (operationId(existing) === incomingOperationId) { + return { + ok: true, + appended: false, + replayed: true, + effect_id: incomingEffectId, + operation_id: incomingOperationId, + }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (existingSha256 !== expectedPreviousSha256) { + throw new Error("Turn journal compare-and-swap precondition failed"); + } + await atomicWriteJson(path, journal); + return { + ok: true, + appended: true, + replayed: false, + effect_id: incomingEffectId, + operation_id: incomingOperationId, + }; + }); +} diff --git a/loopx/control_plane/turn_driver/turn_journal_runtime.py b/loopx/control_plane/turn_driver/turn_journal_runtime.py new file mode 100644 index 000000000..eb0b2697d --- /dev/null +++ b/loopx/control_plane/turn_driver/turn_journal_runtime.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from collections.abc import Mapping +import hashlib +from pathlib import Path +from typing import Any + +from ..effect_runtime import effect_runtime_result + + +TURN_JOURNAL_INTERPRETATION_REQUEST_SCHEMA_VERSION = ( + "loopx_turn_journal_interpretation_request_v0" +) +TURN_JOURNAL_INSPECTION_SCHEMA_VERSION = "loopx_turn_journal_inspection_v0" +_PROJECTION_KEYS = { + "ok", + "schema_version", + "decision", + "journal_status", + "replay_legal", + "goal_matches", + "owner_matches", + "turn_key_matches", + "phases_form_ordered_prefix", + "completed_phases", + "tombstone_retained", + "violations", + "effects", +} +_BOOLEAN_PROJECTION_KEYS = { + "replay_legal", + "goal_matches", + "owner_matches", + "turn_key_matches", + "phases_form_ordered_prefix", + "tombstone_retained", +} + + +def interpret_turn_journal_projection( + journal: Mapping[str, Any], + *, + goal_id: str, + agent_id: str, + turn_key: str, +) -> dict[str, object]: + """Run the TS-owned journal rule through the managed Effect runtime.""" + + request = { + "schema_version": TURN_JOURNAL_INTERPRETATION_REQUEST_SCHEMA_VERSION, + "journal": journal, + "goal_id": goal_id, + "agent_id": agent_id, + "turn_key": turn_key, + } + payload = effect_runtime_result("turn_journal.inspect", request) + if not isinstance(payload, dict) or set(payload) != _PROJECTION_KEYS: + raise RuntimeError( + "TypeScript Turn-journal inspection projection shape mismatch" + ) + if payload.get("schema_version") != TURN_JOURNAL_INSPECTION_SCHEMA_VERSION: + raise RuntimeError( + "TypeScript Turn-journal inspection projection schema mismatch" + ) + if ( + payload.get("ok") is not True + or payload.get("decision") not in {"replay_legal", "replay_blocked"} + or not isinstance(payload.get("journal_status"), str) + or any( + not isinstance(payload.get(key), bool) for key in _BOOLEAN_PROJECTION_KEYS + ) + or not isinstance(payload.get("completed_phases"), list) + or not all(isinstance(phase, str) for phase in payload["completed_phases"]) + or not isinstance(payload.get("violations"), list) + or not all(isinstance(violation, str) for violation in payload["violations"]) + ): + raise RuntimeError( + "TypeScript Turn-journal inspection projection type mismatch" + ) + if payload.get("effects") != []: + raise RuntimeError("Turn-journal inspection must remain effect-free") + return payload + + +def write_turn_journal( + path: str, + journal: Mapping[str, Any], + *, + expected_effect_id: str | None = None, +) -> dict[str, object]: + """Atomically checkpoint a Turn journal in the TS Effect runtime.""" + + journal_path = Path(path) + try: + expected_previous_sha256: str | None = hashlib.sha256( + journal_path.read_bytes() + ).hexdigest() + except FileNotFoundError: + expected_previous_sha256 = None + + payload = effect_runtime_result( + "turn_journal.write", + { + "path": path, + "journal": dict(journal), + "expected_effect_id": expected_effect_id, + "expected_previous_sha256": expected_previous_sha256, + }, + retry_safe=True, + ) + if ( + not isinstance(payload, dict) + or payload.get("ok") is not True + or not isinstance(payload.get("appended"), bool) + or not isinstance(payload.get("replayed"), bool) + or not isinstance(payload.get("operation_id"), str) + ): + raise RuntimeError("TypeScript Effect runtime did not commit the journal") + return payload diff --git a/loopx/control_plane/turn_transaction_contract.json b/loopx/control_plane/turn_transaction_contract.json new file mode 100644 index 000000000..f03716ed9 --- /dev/null +++ b/loopx/control_plane/turn_transaction_contract.json @@ -0,0 +1,12 @@ +{ + "schema_version": "loopx_turn_transaction_contract_v0", + "phases": [ + "host_execute", + "typed_result", + "validation", + "durable_writeback", + "quota_spend", + "scheduler_apply", + "scheduler_ack" + ] +} diff --git a/loopx/doctor.py b/loopx/doctor.py index 9546b6c62..dd6aea94c 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -866,6 +866,7 @@ def collect_doctor( from .control_plane.runtime.runtime_projection_route import ( collect_runtime_projection_route_diagnostics, ) + from .control_plane.effect_runtime import collect_effect_runtime_readiness from .host_loop_activation import ( agent_type_uses_host_managed_skills, @@ -1067,6 +1068,8 @@ def collect_doctor( "items": [], } ) + typescript_control_plane = collect_effect_runtime_readiness(deep=deep) + typescript_runtime_required = True deep_validation = None if deep: from .release_candidate import collect_release_candidate_checks @@ -1221,6 +1224,12 @@ def collect_doctor( sort_keys=True, ), }, + { + "id": "typescript_effect_runtime_ready", + "required": typescript_runtime_required, + "ok": bool(typescript_control_plane.get("ready")), + "detail": str(typescript_control_plane.get("status")), + }, ] if deep_validation: checks.extend(deep_validation["checks"]) @@ -1263,6 +1272,7 @@ def collect_doctor( "release_provenance": release_provenance, "global_registry_writability": global_registry_writability, "runtime_projection_routes": runtime_projection_routes, + "typescript_control_plane": typescript_control_plane, "install_freshness": install_freshness, "upgrade_hint": install_freshness, "skill": { @@ -1317,6 +1327,16 @@ def collect_doctor( def render_doctor_markdown(payload: dict[str, Any]) -> str: release_provenance = payload.get("release_provenance") or {} default_release = release_provenance.get("default_release") or {} + typescript_control_plane = ( + payload.get("typescript_control_plane") + if isinstance(payload.get("typescript_control_plane"), dict) + else {} + ) + typescript_runtime_lifecycle = ( + typescript_control_plane.get("runtime_lifecycle") + if isinstance(typescript_control_plane.get("runtime_lifecycle"), dict) + else {} + ) lines = [ "# LoopX Doctor", "", @@ -1338,6 +1358,9 @@ def render_doctor_markdown(payload: dict[str, Any]) -> str: f"- runtime_projection_routes_healthy: `{(payload.get('runtime_projection_routes') or {}).get('healthy')}`", f"- user_local_bin_on_path: `{(payload.get('path') or {}).get('user_local_bin_on_path')}`", f"- python: `{(payload.get('python') or {}).get('executable')}`", + f"- typescript_control_plane: `{typescript_control_plane.get('status')}`", + f"- typescript_runtime_state: `{typescript_runtime_lifecycle.get('state')}`", + f"- typescript_runtime_diagnostic: `{typescript_runtime_lifecycle.get('diagnostic_code')}`", "", "## Checks", ] @@ -1432,6 +1455,28 @@ def render_doctor_markdown(payload: dict[str, Any]) -> str: "```", ] ) + typescript_control_plane = ( + payload.get("typescript_control_plane") + if isinstance(payload.get("typescript_control_plane"), dict) + else {} + ) + if typescript_control_plane: + lines.extend( + [ + "", + "## TypeScript Control Plane", + f"- status: `{typescript_control_plane.get('status')}`", + f"- ready: `{typescript_control_plane.get('ready')}`", + f"- required_for: `{','.join(typescript_control_plane.get('required_for') or [])}`", + f"- default_cli_blocking: `{typescript_control_plane.get('default_cli_blocking')}`", + f"- minimum_node_version: `{typescript_control_plane.get('minimum_node_version')}`", + f"- detected_node_version: `{typescript_control_plane.get('detected_node_version')}`", + f"- semantic_probe: `{typescript_control_plane.get('semantic_probe')}`", + ] + ) + recommended_action = typescript_control_plane.get("recommended_action") + if recommended_action: + lines.append(f"- recommended_action: {recommended_action}") if not payload.get("ok"): lines.extend(["", "## Fix", str(payload.get("fix"))]) writable = payload.get("global_registry_writability") diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..0122ff56d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "loopx-repository", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "loopx-repository", + "version": "0.0.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^24.3.0", + "typescript": "^6.0.3" + }, + "engines": { + "node": ">=22.6" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..cc567cc0e --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "loopx-repository", + "private": true, + "version": "0.0.0", + "license": "Apache-2.0", + "type": "module", + "engines": { + "node": ">=22.6" + }, + "scripts": { + "test:control-plane": "node --no-warnings --experimental-strip-types --test tests/control_plane_ts/*.test.ts", + "typecheck:control-plane": "tsc --project tsconfig.control-plane.json --noEmit" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "typescript": "^6.0.3" + } +} diff --git a/pyproject.toml b/pyproject.toml index 6ec52fcde..96613f360 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,8 @@ include = ["loopx*"] [tool.setuptools.package-data] "loopx" = ["web/chat/*.html", "web/chat/assets/*", "web/chat/manifest.webmanifest", "web/chat/pwa/*"] +"loopx.control_plane" = ["*.json", "*.ts"] +"loopx.control_plane.turn_driver" = ["*.ts"] "*" = ["README.md", "README.zh-CN.md", "docs/*.md", "docs/protocols/*.md"] "loopx.capabilities.auto_research" = ["worker_skill/SKILL.md"] "loopx.capabilities.content_ops" = ["templates/*.json"] diff --git a/tests/control_plane/test_effect_runtime_integration.py b/tests/control_plane/test_effect_runtime_integration.py new file mode 100644 index 000000000..b8d8da03c --- /dev/null +++ b/tests/control_plane/test_effect_runtime_integration.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import json +import hashlib +import os +import signal +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from loopx.control_plane import effect_runtime + + +def _journal(effect_id: str) -> dict[str, object]: + return { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "fixture-goal", + "turn_key": "sha256:" + "a" * 64, + "status": "in_progress", + "completed_phases": ["host_execute"], + "plan": { + "transaction": {"settlement_plan": {"identity": {"effect_id": effect_id}}} + }, + } + + +def _digest(path: Path) -> str | None: + try: + return hashlib.sha256(path.read_bytes()).hexdigest() + except FileNotFoundError: + return None + + +def test_managed_runtime_is_reused_and_restart_safe_for_typed_write( + tmp_path: Path, + monkeypatch, +) -> None: + runtime_dir = tmp_path / "runtime" + monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: runtime_dir) + monkeypatch.setenv("LOOPX_EFFECT_RUNTIME_IDLE_MS", "1000") + + first = effect_runtime.effect_runtime_result("runtime.ping", {}) + second = effect_runtime.effect_runtime_result("runtime.ping", {}) + assert first["pid"] == second["pid"] + + journal_path = tmp_path / "turn.json" + effect_id = "goal:agent:todo:turn" + payload = _journal(effect_id) + committed = effect_runtime.effect_runtime_result( + "turn_journal.write", + { + "path": str(journal_path), + "journal": payload, + "expected_effect_id": effect_id, + "expected_previous_sha256": None, + }, + ) + assert committed["appended"] is True + assert committed["replayed"] is False + assert committed["effect_id"] == effect_id + assert json.loads(journal_path.read_text(encoding="utf-8")) == payload + + effect_runtime.effect_runtime_result( + "runtime.shutdown", + {}, + retry_safe=False, + ) + deadline = time.monotonic() + 2 + while list(runtime_dir.glob("runtime-*.json")) and time.monotonic() < deadline: + time.sleep(0.025) + + replayed = effect_runtime.effect_runtime_result( + "turn_journal.write", + { + "path": str(journal_path), + "journal": payload, + "expected_effect_id": effect_id, + "expected_previous_sha256": _digest(journal_path), + }, + ) + assert replayed["appended"] is False + assert replayed["replayed"] is True + assert json.loads(journal_path.read_text(encoding="utf-8")) == payload + + effect_runtime.effect_runtime_result( + "runtime.shutdown", + {}, + retry_safe=False, + ) + + +def test_typed_write_rejects_cross_effect_overwrite( + tmp_path: Path, + monkeypatch, +) -> None: + runtime_dir = tmp_path / "runtime" + monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: runtime_dir) + monkeypatch.setenv("LOOPX_EFFECT_RUNTIME_IDLE_MS", "1000") + journal_path = tmp_path / "turn.json" + first = _journal("effect-one") + effect_runtime.effect_runtime_result( + "turn_journal.write", + { + "path": str(journal_path), + "journal": first, + "expected_effect_id": "effect-one", + "expected_previous_sha256": None, + }, + ) + + try: + effect_runtime.effect_runtime_result( + "turn_journal.write", + { + "path": str(journal_path), + "journal": _journal("effect-two"), + "expected_effect_id": "effect-two", + "expected_previous_sha256": _digest(journal_path), + }, + ) + except RuntimeError as exc: + assert "belongs to another settlement effect" in str(exc) + else: + raise AssertionError("cross-effect overwrite must fail closed") + assert json.loads(journal_path.read_text(encoding="utf-8")) == first + + effect_runtime.effect_runtime_result( + "runtime.shutdown", + {}, + retry_safe=False, + ) + + +def test_typed_write_serializes_same_effect_checkpoints_with_cas( + tmp_path: Path, + monkeypatch, +) -> None: + runtime_dir = tmp_path / "runtime" + monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: runtime_dir) + monkeypatch.setenv("LOOPX_EFFECT_RUNTIME_IDLE_MS", "1000") + journal_path = tmp_path / "turn.json" + effect_id = "goal:agent:todo:turn" + initial = _journal(effect_id) + effect_runtime.effect_runtime_result( + "turn_journal.write", + { + "path": str(journal_path), + "journal": initial, + "expected_effect_id": effect_id, + "expected_previous_sha256": None, + }, + ) + expected_previous_sha256 = _digest(journal_path) + first = {**initial, "status": "committed"} + second = {**initial, "status": "failed"} + + def commit(payload: dict[str, object]) -> dict[str, object] | RuntimeError: + try: + return effect_runtime.effect_runtime_result( + "turn_journal.write", + { + "path": str(journal_path), + "journal": payload, + "expected_effect_id": effect_id, + "expected_previous_sha256": expected_previous_sha256, + }, + ) + except RuntimeError as exc: + return exc + + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = list(executor.map(commit, (first, second))) + + assert sum(isinstance(outcome, RuntimeError) for outcome in outcomes) == 1 + failure = next(outcome for outcome in outcomes if isinstance(outcome, RuntimeError)) + assert "compare-and-swap precondition failed" in str(failure) + assert json.loads(journal_path.read_text(encoding="utf-8")) in (first, second) + success = next(outcome for outcome in outcomes if isinstance(outcome, dict)) + assert success["appended"] is True + assert success["replayed"] is False + + effect_runtime.effect_runtime_result("runtime.shutdown", {}, retry_safe=False) + + +def test_successive_same_effect_checkpoints_receive_distinct_operation_ids( + tmp_path: Path, + monkeypatch, +) -> None: + runtime_dir = tmp_path / "runtime" + monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: runtime_dir) + monkeypatch.setenv("LOOPX_EFFECT_RUNTIME_IDLE_MS", "1000") + journal_path = tmp_path / "turn.json" + effect_id = "goal:agent:todo:turn" + initial = _journal(effect_id) + first = effect_runtime.effect_runtime_result( + "turn_journal.write", + { + "path": str(journal_path), + "journal": initial, + "expected_effect_id": effect_id, + "expected_previous_sha256": None, + }, + ) + successor = {**initial, "status": "committed"} + second = effect_runtime.effect_runtime_result( + "turn_journal.write", + { + "path": str(journal_path), + "journal": successor, + "expected_effect_id": effect_id, + "expected_previous_sha256": _digest(journal_path), + }, + ) + + assert first["operation_id"] != second["operation_id"] + assert json.loads(journal_path.read_text(encoding="utf-8")) == successor + effect_runtime.effect_runtime_result("runtime.shutdown", {}, retry_safe=False) + + +def test_retry_safe_typed_write_recovers_after_unexpected_runtime_exit( + tmp_path: Path, + monkeypatch, +) -> None: + runtime_dir = tmp_path / "runtime" + monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: runtime_dir) + monkeypatch.setenv("LOOPX_EFFECT_RUNTIME_IDLE_MS", "1000") + + original = effect_runtime.effect_runtime_result("runtime.ping", {}) + original_pid = int(original["pid"]) + os.kill(original_pid, signal.SIGTERM) + time.sleep(0.1) + + journal_path = tmp_path / "turn.json" + effect_id = "goal:agent:todo:crash-recovery" + payload = _journal(effect_id) + committed = effect_runtime.effect_runtime_result( + "turn_journal.write", + { + "path": str(journal_path), + "journal": payload, + "expected_effect_id": effect_id, + "expected_previous_sha256": None, + }, + retry_safe=True, + ) + replacement = effect_runtime.effect_runtime_result("runtime.ping", {}) + + assert committed["appended"] is True + assert committed["replayed"] is False + assert committed["effect_id"] == effect_id + assert json.loads(journal_path.read_text(encoding="utf-8")) == payload + assert replacement["pid"] != original_pid + + effect_runtime.effect_runtime_result( + "runtime.shutdown", + {}, + retry_safe=False, + ) + + +def test_dead_runtime_metadata_is_replaced_after_host_restart( + tmp_path: Path, + monkeypatch, +) -> None: + runtime_dir = tmp_path / "runtime" + monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: runtime_dir) + monkeypatch.setenv("LOOPX_EFFECT_RUNTIME_IDLE_MS", "1000") + runtime_dir.mkdir() + fingerprint = effect_runtime._runtime_fingerprint() + info_path = effect_runtime._runtime_info_path(fingerprint) + info_path.write_text( + json.dumps( + { + "schema_version": effect_runtime.EFFECT_RUNTIME_INFO_SCHEMA_VERSION, + "fingerprint": fingerprint, + "pid": 99_999_999, + "host": "127.0.0.1", + "port": 9, + "token": "stale-after-reboot", + } + ), + encoding="utf-8", + ) + + replacement = effect_runtime.effect_runtime_result("runtime.ping", {}) + + assert replacement["ready"] is True + assert int(replacement["pid"]) != 99_999_999 + effect_runtime.effect_runtime_result("runtime.shutdown", {}, retry_safe=False) + + +def test_dead_startup_lock_is_reclaimed_without_waiting_for_age_timeout( + tmp_path: Path, + monkeypatch, +) -> None: + runtime_dir = tmp_path / "runtime" + monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: runtime_dir) + monkeypatch.setenv("LOOPX_EFFECT_RUNTIME_IDLE_MS", "1000") + runtime_dir.mkdir() + fingerprint = effect_runtime._runtime_fingerprint() + lock = runtime_dir / f"start-{fingerprint[:16]}.lock" + lock.write_text("99999999\n", encoding="utf-8") + + started_at = time.monotonic() + replacement = effect_runtime.effect_runtime_result("runtime.ping", {}) + + assert replacement["ready"] is True + assert time.monotonic() - started_at < 3 + effect_runtime.effect_runtime_result("runtime.shutdown", {}, retry_safe=False) + + +def test_early_runtime_exit_surfaces_stable_startup_diagnostic( + tmp_path: Path, + monkeypatch, +) -> None: + runtime_dir = tmp_path / "runtime" + monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: runtime_dir) + monkeypatch.setattr(effect_runtime, "_node_executable", lambda: "node") + + class _ExitedProcess: + def poll(self) -> int: + return 23 + + monkeypatch.setattr( + effect_runtime.subprocess, + "Popen", + lambda *_args, **_kwargs: _ExitedProcess(), + ) + + try: + effect_runtime.effect_runtime_result("runtime.ping", {}) + except effect_runtime.EffectRuntimeStartupError as exc: + assert exc.diagnostic_code == "runtime_exited_before_ready" + assert "exit_code=23" in str(exc) + else: + raise AssertionError("an early runtime exit must fail with a stable diagnostic") + + +def test_managed_runtime_releases_memory_after_idle_timeout( + tmp_path: Path, + monkeypatch, +) -> None: + runtime_dir = tmp_path / "runtime" + monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: runtime_dir) + monkeypatch.setenv("LOOPX_EFFECT_RUNTIME_IDLE_MS", "150") + + original = effect_runtime.effect_runtime_result("runtime.ping", {}) + original_pid = int(original["pid"]) + deadline = time.monotonic() + 2 + while list(runtime_dir.glob("runtime-*.json")) and time.monotonic() < deadline: + time.sleep(0.025) + + assert list(runtime_dir.glob("runtime-*.json")) == [] + replacement = effect_runtime.effect_runtime_result("runtime.ping", {}) + assert int(replacement["pid"]) != original_pid + + effect_runtime.effect_runtime_result( + "runtime.shutdown", + {}, + retry_safe=False, + ) + + +def test_oversized_request_is_rejected_before_runtime_dispatch( + tmp_path: Path, + monkeypatch, +) -> None: + runtime_dir = tmp_path / "runtime" + monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: runtime_dir) + monkeypatch.setenv("LOOPX_EFFECT_RUNTIME_IDLE_MS", "1000") + + original = effect_runtime.effect_runtime_result("runtime.ping", {}) + original_pid = int(original["pid"]) + oversized = "x" * (effect_runtime.MAX_REQUEST_BYTES + 1) + + try: + effect_runtime.effect_runtime_result( + "effect.interpret_turn_result", + {"packet": {"oversized": oversized}, "identity": {}}, + ) + except effect_runtime.EffectRuntimeRejected as exc: + assert "request is oversized" in str(exc) + else: + raise AssertionError("oversized request must fail before dispatch") + + assert ( + effect_runtime.effect_runtime_result("runtime.ping", {})["pid"] == original_pid + ) + effect_runtime.effect_runtime_result( + "runtime.shutdown", + {}, + retry_safe=False, + ) diff --git a/tests/control_plane/test_effect_turn_turn_journal.py b/tests/control_plane/test_effect_turn_turn_journal.py deleted file mode 100644 index e34fb202c..000000000 --- a/tests/control_plane/test_effect_turn_turn_journal.py +++ /dev/null @@ -1,238 +0,0 @@ -from __future__ import annotations - -from copy import deepcopy -from typing import Any - -import pytest - -from loopx.control_plane.effect_program import ( - TURN_TRANSACTION_PHASES, - EffectNext, - interpret_turn_journal, -) -from loopx.control_plane.turn_driver.transaction import TRANSACTION_PHASES - - -def _journal(*, status: str = "committed") -> dict[str, Any]: - turn_key = "sha256:fixture-turn" - return { - "schema_version": "loopx_turn_journal_v0", - "goal_id": "fixture-goal", - "turn_key": turn_key, - "status": status, - "completed_phases": [ - "host_execute", - "typed_result", - "validation", - "durable_writeback", - "quota_spend", - "scheduler_apply", - "scheduler_ack", - ], - "plan": { - "turn_envelope": { - "goal_id": "fixture-goal", - "agent_id": "fixture-agent", - }, - "transaction": { - "turn_key": turn_key, - "settlement_plan": { - "identity": { - "goal_id": "fixture-goal", - "agent_id": "fixture-agent", - } - }, - }, - }, - "host_result": {"turn_key": turn_key}, - "receipt": {"turn_key": turn_key}, - } - - -def test_turn_journal_reports_legal_replay_without_mutating_input() -> None: - journal = _journal() - before = deepcopy(journal) - - turn = interpret_turn_journal( - journal, - goal_id="fixture-goal", - agent_id="fixture-agent", - turn_key="sha256:fixture-turn", - capabilities=["filesystem_read"], - ) - - assert turn.request.kind == "turn_journal" - assert turn.request.source == "turn_journal" - assert turn.request.goal_id == "fixture-goal" - assert turn.request.agent_id == "fixture-agent" - assert turn.request.capabilities == ("filesystem_read",) - assert turn.request.context == { - "replay_legal": True, - "goal_matches": True, - "owner_matches": True, - "turn_key_matches": True, - "phases_form_ordered_prefix": True, - "journal_status": "committed", - "tombstone_retained": True, - "completed_phases": ( - "host_execute", - "typed_result", - "validation", - "durable_writeback", - "quota_spend", - "scheduler_apply", - "scheduler_ack", - ), - "violations": (), - } - assert turn.interpretation.route == "turn_journal_replay" - assert turn.interpretation.obligation == "observe_fenced_replay" - assert turn.interpretation.interaction_mode == "read_only" - assert turn.observation.decision == "replay_legal" - assert turn.observation.should_run is False - assert turn.observation.effective_action == "observe_replay" - assert turn.next_effect == EffectNext() - assert journal == before - - -def test_turn_journal_accumulates_identity_and_phase_violations() -> None: - journal = _journal() - journal["goal_id"] = "other-goal" - journal["turn_key"] = "sha256:other-turn" - journal["completed_phases"] = ["host_execute", "validation"] - identity = journal["plan"]["transaction"]["settlement_plan"]["identity"] - identity["agent_id"] = "other-agent" - - turn = interpret_turn_journal( - journal, - goal_id="fixture-goal", - agent_id="fixture-agent", - turn_key="sha256:fixture-turn", - ) - - assert turn.request.context["replay_legal"] is False - assert turn.request.context["goal_matches"] is False - assert turn.request.context["owner_matches"] is False - assert turn.request.context["turn_key_matches"] is False - assert turn.request.context["phases_form_ordered_prefix"] is False - assert turn.request.context["violations"] == ( - "goal_mismatch", - "owner_mismatch", - "turn_key_mismatch", - "completed_phases_not_ordered_prefix", - ) - assert turn.observation.decision == "replay_blocked" - assert turn.observation.should_run is False - assert turn.observation.effective_action == "block_replay" - assert turn.next_effect == EffectNext() - - -@pytest.mark.parametrize("status", ["committed", "stopped", "failed"]) -def test_turn_journal_retains_terminal_tombstones(status: str) -> None: - turn = interpret_turn_journal( - _journal(status=status), - goal_id="fixture-goal", - agent_id="fixture-agent", - turn_key="sha256:fixture-turn", - ) - - assert turn.request.context["tombstone_retained"] is True - assert turn.request.context["journal_status"] == status - assert turn.request.context["replay_legal"] is True - - -def test_turn_journal_blocks_non_terminal_and_malformed_trace() -> None: - journal = {"status": "in_progress", "completed_phases": "host_execute"} - - turn = interpret_turn_journal(journal) - - assert turn.request.context["replay_legal"] is False - assert turn.request.context["goal_matches"] is False - assert turn.request.context["owner_matches"] is False - assert turn.request.context["turn_key_matches"] is False - assert turn.request.context["phases_form_ordered_prefix"] is False - assert turn.request.context["tombstone_retained"] is False - assert turn.request.context["completed_phases"] == () - assert turn.request.context["violations"] == ( - "goal_identity_missing", - "owner_identity_missing", - "turn_key_identity_missing", - "completed_phases_invalid", - "journal_not_terminal", - ) - assert turn.observation.decision == "replay_blocked" - assert turn.observation.should_run is False - - -def test_turn_journal_blocks_unknown_status_as_unsupported() -> None: - turn = interpret_turn_journal( - _journal(status="retired"), - goal_id="fixture-goal", - agent_id="fixture-agent", - turn_key="sha256:fixture-turn", - ) - - assert turn.request.context["replay_legal"] is False - assert turn.request.context["tombstone_retained"] is False - assert turn.request.context["violations"] == ("journal_status_unsupported",) - - -def test_turn_journal_compares_identity_strings_exactly() -> None: - journal = _journal() - journal["goal_id"] = " fixture-goal " - - turn = interpret_turn_journal( - journal, - goal_id="fixture-goal", - agent_id="fixture-agent", - turn_key="sha256:fixture-turn", - ) - - assert turn.request.context["replay_legal"] is False - assert turn.request.context["goal_matches"] is False - assert turn.request.context["violations"] == ("goal_mismatch",) - - -def test_turn_journal_blocks_present_malformed_identity_fields() -> None: - journal = _journal() - journal["plan"]["turn_envelope"]["agent_id"] = 7 - journal["host_result"]["turn_key"] = "" - journal["receipt"]["turn_key"] = object() - - turn = interpret_turn_journal( - journal, - goal_id="fixture-goal", - agent_id="fixture-agent", - turn_key="sha256:fixture-turn", - ) - - assert turn.request.context["replay_legal"] is False - assert turn.request.context["owner_matches"] is False - assert turn.request.context["turn_key_matches"] is False - assert turn.request.context["violations"] == ( - "owner_identity_missing", - "turn_key_identity_missing", - ) - - -def test_turn_journal_blocks_explicit_empty_identity_expectations() -> None: - turn = interpret_turn_journal( - _journal(), - goal_id="", - agent_id="", - turn_key="", - ) - - assert turn.request.context["replay_legal"] is False - assert turn.request.context["goal_matches"] is False - assert turn.request.context["owner_matches"] is False - assert turn.request.context["turn_key_matches"] is False - assert turn.request.context["violations"] == ( - "goal_identity_missing", - "owner_identity_missing", - "turn_key_identity_missing", - ) - - -def test_turn_transaction_keeps_the_canonical_phase_tuple_alias() -> None: - assert TRANSACTION_PHASES is TURN_TRANSACTION_PHASES diff --git a/tests/control_plane/test_turn_journal_characterization.py b/tests/control_plane/test_turn_journal_characterization.py new file mode 100644 index 000000000..4275537c1 --- /dev/null +++ b/tests/control_plane/test_turn_journal_characterization.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import json +import shutil +from copy import deepcopy +from pathlib import Path + +import pytest + +from loopx.control_plane.testing.turn_journal_characterization import ( + TURN_JOURNAL_CHARACTERIZATION_DIFFERENTIAL_VERSION, + TURN_JOURNAL_CHARACTERIZATION_PROBE_VERSION, + compare_turn_journal_characterization_receipts, + evaluate_turn_journal_invariants, + load_turn_journal_characterization_corpus, + run_turn_journal_probe_command, + validate_turn_journal_characterization_corpus, +) + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CORPUS_PATH = ( + REPO_ROOT + / "tests" + / "fixtures" + / "control_plane" + / "turn_journal_characterization_v0.json" +) + + +def _corpus() -> dict[str, object]: + return load_turn_journal_characterization_corpus(CORPUS_PATH) + + +def _probe() -> dict[str, object]: + node = shutil.which("node") + if node is None: + pytest.skip("TypeScript candidate requires Node.js 22.6 or newer") + return run_turn_journal_probe_command( + _corpus(), + command=[ + node, + "--no-warnings", + "--experimental-strip-types", + str( + REPO_ROOT + / "tests" + / "control_plane_ts" + / "turn_journal_characterization_probe.mjs" + ), + ], + cwd=REPO_ROOT, + implementation_id="typescript-head", + ) + + +def test_characterization_corpus_is_public_safe_and_invariant_owned() -> None: + corpus = _corpus() + + assert corpus["schema_version"] == ("loopx_turn_journal_characterization_corpus_v0") + assert len(corpus["cases"]) == 10 + assert {case["invariant_id"] for case in corpus["cases"]} >= { + "terminal-consistent-journal-is-replayable", + "goal-identity-is-fenced", + "settlement-phases-form-an-ordered-prefix", + "phase-elements-are-typed-strings", + } + + +@pytest.mark.parametrize( + ("path", "value", "message"), + ( + (("cases", 0, "request", "journal", "credential"), "value", "banned key"), + (("cases", 0, "rationale"), "/private/run.json", "local path"), + ), +) +def test_characterization_corpus_rejects_private_material( + path: tuple[str | int, ...], + value: str, + message: str, +) -> None: + corpus = deepcopy(_corpus()) + target: object = corpus + for part in path[:-1]: + target = target[part] # type: ignore[index] + target[path[-1]] = value # type: ignore[index] + + with pytest.raises(ValueError, match=message): + validate_turn_journal_characterization_corpus(corpus) + + +def test_typescript_owner_satisfies_the_independent_replay_invariants() -> None: + receipt = _probe() + + assert receipt["schema_version"] == TURN_JOURNAL_CHARACTERIZATION_PROBE_VERSION + assert len(receipt["rows"]) == 10 + assert evaluate_turn_journal_invariants(_corpus(), receipt) == {} + rendered = json.dumps(receipt) + assert "private_detail" not in rendered + assert "receipt.body" not in rendered + + +def test_exact_base_candidate_receipts_pass_without_review() -> None: + base = _probe() + candidate = deepcopy(base) + candidate["implementation_id"] = "candidate" + + result = compare_turn_journal_characterization_receipts( + _corpus(), + base, + candidate, + ) + + assert result == { + "schema_version": TURN_JOURNAL_CHARACTERIZATION_DIFFERENTIAL_VERSION, + "ok": True, + "exact_parity": True, + "base_invariant_failures": {}, + "candidate_invariant_failures": {}, + "failed_case_count": 0, + "review_required_case_count": 0, + "cases": [ + { + "case_id": case["case_id"], + "status": "passed", + "exact_match": True, + "base_invariant_failures": [], + "candidate_invariant_failures": [], + } + for case in _corpus()["cases"] + ], + } + + +def test_semantic_repair_requires_review_without_failing_invariants() -> None: + base = _probe() + candidate = deepcopy(base) + candidate["implementation_id"] = "typed-candidate" + row = next( + row for row in candidate["rows"] if row["case_id"] == "non-string-phase-element" + ) + row["completed_phases"] = [] + row["violations"] = ["completed_phases_invalid"] + + result = compare_turn_journal_characterization_receipts( + _corpus(), + base, + candidate, + ) + + assert result["ok"] is True + assert result["exact_parity"] is False + assert result["failed_case_count"] == 0 + assert result["review_required_case_count"] == 1 + changed = next( + case + for case in result["cases"] + if case["case_id"] == "non-string-phase-element" + ) + assert changed["status"] == "review_required" + assert changed["candidate_invariant_failures"] == [] + + +def test_candidate_invariant_regression_fails_the_differential() -> None: + base = _probe() + candidate = deepcopy(base) + row = next( + row for row in candidate["rows"] if row["case_id"] == "owner-identity-mismatch" + ) + row["decision"] = "replay_legal" + row["replay_legal"] = True + + result = compare_turn_journal_characterization_receipts( + _corpus(), + base, + candidate, + ) + + assert result["ok"] is False + assert result["failed_case_count"] == 1 + assert result["candidate_invariant_failures"] == { + "owner-identity-mismatch": [ + "decision: expected 'replay_blocked', got 'replay_legal'", + "replay_legal: expected False, got True", + ] + } diff --git a/tests/control_plane/test_turn_journal_runtime_readiness.py b/tests/control_plane/test_turn_journal_runtime_readiness.py new file mode 100644 index 000000000..88035fd4f --- /dev/null +++ b/tests/control_plane/test_turn_journal_runtime_readiness.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from loopx.control_plane import effect_runtime +from loopx.doctor import collect_doctor, render_doctor_markdown + + +class _Completed: + def __init__(self, *, stdout: str, returncode: int = 0) -> None: + self.stdout = stdout + self.returncode = returncode + + +def test_runtime_fingerprint_rotates_when_any_owned_source_changes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_root = Path(effect_runtime.__file__).resolve().parent + for relative in effect_runtime._SOURCE_FILES: + source = source_root / relative + target = tmp_path / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(source.read_bytes()) + monkeypatch.setattr(effect_runtime, "_control_plane_root", lambda: tmp_path) + + effect_runtime._runtime_fingerprint.cache_clear() + original = effect_runtime._runtime_fingerprint() + for index, relative in enumerate(effect_runtime._SOURCE_FILES): + target = tmp_path / relative + original_bytes = target.read_bytes() + target.write_bytes(original_bytes + f"\n// fingerprint-{index}\n".encode()) + effect_runtime._runtime_fingerprint.cache_clear() + assert effect_runtime._runtime_fingerprint() != original + target.write_bytes(original_bytes) + effect_runtime._runtime_fingerprint.cache_clear() + assert effect_runtime._runtime_fingerprint() == original + + +def test_missing_node_blocks_the_typescript_control_plane_and_is_actionable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(effect_runtime.shutil, "which", lambda _name: None) + + result = effect_runtime.collect_effect_runtime_readiness() + + assert result["status"] == "missing" + assert result["ready"] is False + assert result["default_cli_blocking"] is True + assert result["required_for"] == ["control_plane"] + assert "Node.js 22.6.0 or newer" in str(result["recommended_action"]) + + +def test_old_node_is_reported_without_running_semantic_probe( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(effect_runtime.shutil, "which", lambda _name: "node") + monkeypatch.setattr( + effect_runtime.subprocess, + "run", + lambda *_args, **_kwargs: _Completed(stdout="v20.19.5\n"), + ) + + result = effect_runtime.collect_effect_runtime_readiness(deep=True) + + assert result["status"] == "unsupported" + assert result["detected_node_version"] == "20.19.5" + assert result["semantic_probe"] == "not_run" + + +def test_current_node_standard_probe_does_not_execute_rule( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: tmp_path) + monkeypatch.setattr(effect_runtime.shutil, "which", lambda _name: "node") + monkeypatch.setattr( + effect_runtime.subprocess, + "run", + lambda *_args, **_kwargs: _Completed(stdout="v22.6.0\n"), + ) + + result = effect_runtime.collect_effect_runtime_readiness() + + assert result["status"] == "ready" + assert result["ready"] is True + assert result["semantic_probe"] == "not_requested" + assert result["runtime_lifecycle"] == { + "schema_version": "loopx_effect_runtime_lifecycle_v0", + "management": "on_demand_managed", + "state": "stopped", + "manual_start_required": False, + "restart_policy": "automatic_on_next_control_plane_request", + "idle_shutdown": True, + "diagnostic_code": None, + } + + +def test_deep_probe_executes_packaged_semantics( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + monkeypatch.setattr(effect_runtime.shutil, "which", lambda _name: "node") + monkeypatch.setattr( + effect_runtime.subprocess, + "run", + lambda *_args, **_kwargs: _Completed(stdout="v24.1.0\n"), + ) + + def request(method: str, params: dict[str, Any]) -> dict[str, object]: + calls.append((method, params)) + if method == "runtime.ping": + return {"ready": True} + return {"effect_id": "doctor-probe:doctor-probe:doctor-probe:doctor-probe"} + + monkeypatch.setattr( + effect_runtime, + "effect_runtime_result", + request, + ) + + result = effect_runtime.collect_effect_runtime_readiness(deep=True) + + assert result["status"] == "ready" + assert result["semantic_probe"] == "passed" + assert result["runtime_lifecycle"]["state"] == "running" + assert [method for method, _params in calls] == [ + "runtime.ping", + "settlement.identity", + ] + assert calls[1][1]["goal_id"] == "doctor-probe" + + +def test_deep_probe_failure_is_public_safe_and_actionable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(effect_runtime.shutil, "which", lambda _name: "node") + monkeypatch.setattr( + effect_runtime.subprocess, + "run", + lambda *_args, **_kwargs: _Completed(stdout="v22.6.0\n"), + ) + + def failed(*_args: object, **_kwargs: object) -> dict[str, object]: + raise effect_runtime.EffectRuntimeStartupError( + "/private/path/worker.mjs failed", + diagnostic_code="runtime_exited_before_ready", + ) + + monkeypatch.setattr( + effect_runtime, + "effect_runtime_result", + failed, + ) + + result = effect_runtime.collect_effect_runtime_readiness(deep=True) + + assert result["status"] == "probe_failed" + assert result["ready"] is False + assert result["semantic_probe"] == "failed" + assert "/private/path" not in str(result) + assert "reinstall LoopX" in str(result["recommended_action"]) + assert result["runtime_lifecycle"]["state"] == "unavailable" + assert ( + result["runtime_lifecycle"]["diagnostic_code"] + == "runtime_exited_before_ready" + ) + + +def test_doctor_markdown_projects_runtime_lifecycle_for_app_health() -> None: + rendered = render_doctor_markdown( + { + "typescript_control_plane": { + "status": "probe_failed", + "runtime_lifecycle": { + "state": "unavailable", + "diagnostic_code": "runtime_exited_before_ready", + }, + }, + "checks": [], + } + ) + + assert "typescript_runtime_state: `unavailable`" in rendered + assert ( + "typescript_runtime_diagnostic: `runtime_exited_before_ready`" in rendered + ) + + +def test_missing_required_runtime_fails_doctor_health( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ready = { + "schema_version": "loopx_effect_runtime_readiness_v0", + "ready": True, + "status": "ready", + "required_for": ["control_plane"], + "default_cli_blocking": True, + "minimum_node_version": "22.6.0", + "detected_node_version": "24.1.0", + "semantic_probe": "not_requested", + "recommended_action": None, + } + missing = { + **ready, + "ready": False, + "status": "missing", + "detected_node_version": None, + "recommended_action": "Install Node.js 22.6.0 or newer.", + } + monkeypatch.setattr( + effect_runtime, + "collect_effect_runtime_readiness", + lambda *, deep=False: ready, + ) + ready_doctor = collect_doctor() + monkeypatch.setattr( + effect_runtime, + "collect_effect_runtime_readiness", + lambda *, deep=False: missing, + ) + missing_doctor = collect_doctor() + + assert ready_doctor["ok"] is True + assert missing_doctor["ok"] is False + runtime_check = next( + check + for check in missing_doctor["checks"] + if check["id"] == "typescript_effect_runtime_ready" + ) + assert runtime_check == { + "id": "typescript_effect_runtime_ready", + "required": True, + "ok": False, + "detail": "missing", + } + assert missing_doctor["typescript_control_plane"] == missing + + +def test_deep_doctor_fails_when_present_runtime_cannot_execute_semantics( + monkeypatch: pytest.MonkeyPatch, +) -> None: + failed = { + "schema_version": "loopx_effect_runtime_readiness_v0", + "ready": False, + "status": "probe_failed", + "required_for": ["control_plane"], + "default_cli_blocking": True, + "minimum_node_version": "22.6.0", + "detected_node_version": "24.1.0", + "semantic_probe": "failed", + "recommended_action": "Reinstall LoopX.", + } + monkeypatch.setattr( + effect_runtime, + "collect_effect_runtime_readiness", + lambda *, deep=False: failed, + ) + monkeypatch.setattr( + "loopx.release_candidate.collect_release_candidate_checks", + lambda **_kwargs: {"ok": True, "checks": []}, + ) + + doctor = collect_doctor(deep=True) + + runtime_check = next( + check + for check in doctor["checks"] + if check["id"] == "typescript_effect_runtime_ready" + ) + assert runtime_check["required"] is True + assert runtime_check["ok"] is False + assert doctor["ok"] is False diff --git a/tests/control_plane_ts/effect_program.test.ts b/tests/control_plane_ts/effect_program.test.ts new file mode 100644 index 000000000..a89f99954 --- /dev/null +++ b/tests/control_plane_ts/effect_program.test.ts @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + commitStepPayload, + effectProgramFromOrderedSteps, + requireMatchingEffectId, + seedCommittedSteps, + settlementBindGate, + settlementBindReduce, + settlementFailed, + settlementIdentity, + settlementIdentityFromPlan, + settlementNextAction, + settlementPure, + settlementResultPayload, +} from "../../loopx/control_plane/effect_program.ts"; +import type { + SettlementResult, +} from "../../loopx/control_plane/effect_program.ts"; + +const identityInput = { + goal_id: "goal", + agent_id: "agent", + todo_id: "todo", + turn_instance_id: "turn", +} as const; + +// The native type contract must reject the previously expressible state in +// which one settlement result carried both a success value and a failure. +// @ts-expect-error successful settlement values cannot carry failures +const invalidSettlementResult: SettlementResult<{ value: number }> = { + value: { value: 1 }, + receipts: [], + failure: { + kind: "permission_denied", + step_kind: "durable_writeback", + reason: "denied", + }, +}; +void invalidSettlementResult; + +test("ordered Effect Program steps preserve data and skip malformed entries", () => { + const program = effectProgramFromOrderedSteps( + [ + { id: "one", kind: "read", command: "loopx status", extra: 1 }, + null, + { id: "two", prompt: "inspect evidence" }, + ], + "bounded", + ); + + assert.equal(program.execution_mode, "bounded"); + assert.deepEqual(program.steps.map((step) => step.step_id), ["one", "two"]); + assert.equal(program.steps[0]?.raw.extra, 1); + assert.equal(program.steps[1]?.command, "inspect evidence"); +}); + +test("settlement identity makes illegal dual bindings unrepresentable", () => { + assert.deepEqual(settlementIdentity(identityInput), { + ...identityInput, + replan_obligation_id: null, + binding_kind: "todo", + binding_id: "todo", + effect_id: "goal:agent:todo:turn", + }); + assert.throws( + () => + settlementIdentity({ + ...identityInput, + replan_obligation_id: "replan", + }), + /cannot bind both/, + ); +}); + +test("bind preserves receipt order and short-circuits typed failures", () => { + const receipt = { + step_kind: "validation" as const, + status: "committed", + effect_id: "goal:agent:todo:turn", + }; + const first = settlementPure({ value: 1 }, [receipt]); + const gate = settlementBindGate(first); + assert.equal(gate.execute, true); + + const second = settlementPure({ value: 2 }, [ + { ...receipt, step_kind: "durable_writeback" }, + ]); + assert.deepEqual(settlementBindReduce(first, second).receipts, [ + receipt, + { ...receipt, step_kind: "durable_writeback" }, + ]); + + const failed = settlementFailed({ + kind: "permission_denied", + step_kind: "durable_writeback", + reason: "denied", + receipts: [receipt], + }); + const stopped = settlementBindGate(failed); + assert.equal(stopped.execute, false); + assert.equal(stopped.result.failure?.kind, "permission_denied"); + assert.deepEqual(stopped.result.receipts, [receipt]); +}); + +test("durable receipts replay only for the same effect and ordered prefix", () => { + const identity = settlementIdentity(identityInput); + const seeded = seedCommittedSteps({ + identity, + ordered_steps: ["validation", "durable_writeback", "quota_spend"], + committed_payloads: { + durable_writeback: { ok: true, appended: true, record: "write" }, + }, + completed_phases: ["validation", "durable_writeback"], + transaction_phases: [ + "validation", + "durable_writeback", + "quota_spend", + ], + }); + assert.equal(seeded.failure, null); + assert.deepEqual( + seeded.receipts.map((receipt) => receipt.step_kind), + ["validation", "durable_writeback"], + ); + assert.equal( + requireMatchingEffectId("other-effect", identity.effect_id).failure?.kind, + "identity_mismatch", + ); +}); + +test("commit reduction advances one phase only after a committed payload", () => { + const committed = commitStepPayload({ + identity: identityInput, + step_kind: "quota_spend", + transaction_phases: [ + "validation", + "durable_writeback", + "quota_spend", + ], + payload: { ok: true, appended: true }, + }); + assert.deepEqual(committed.completed_phases, [ + "validation", + "durable_writeback", + "quota_spend", + ]); + assert.equal(committed.result.receipts[0]?.step_kind, "quota_spend"); + + const rejected = commitStepPayload({ + identity: identityInput, + step_kind: "quota_spend", + transaction_phases: ["validation", "durable_writeback", "quota_spend"], + payload: { ok: false, error: "budget exhausted" }, + }); + assert.equal(rejected.completed_phases, null); + assert.equal(rejected.result.failure?.kind, "budget_rejected"); +}); + +test("runtime selects the next uncommitted effect instead of Python orchestration", () => { + const next = settlementNextAction({ + identity: identityInput, + ordered_steps: ["validation", "durable_writeback", "quota_spend"], + committed_payloads: {}, + completed_phases: ["validation"], + transaction_phases: ["validation", "durable_writeback", "quota_spend"], + }); + assert.equal(next.decision, "execute"); + assert.equal(next.step_kind, "durable_writeback"); + + const complete = settlementNextAction({ + identity: identityInput, + ordered_steps: ["validation", "durable_writeback", "quota_spend"], + committed_payloads: { + durable_writeback: { ok: true, appended: true }, + quota_spend: { ok: true, appended: true }, + }, + completed_phases: ["validation", "durable_writeback", "quota_spend"], + transaction_phases: ["validation", "durable_writeback", "quota_spend"], + }); + assert.equal(complete.decision, "complete"); + assert.equal(complete.step_kind, null); +}); + +test("plan identity and public result payload remain stable at the boundary", () => { + const identity = settlementIdentity(identityInput); + const result = settlementIdentityFromPlan({ + settlement_plan: { identity }, + }); + assert.equal(result.value?.effect_id, identity.effect_id); + assert.deepEqual(settlementResultPayload(result), { + ok: true, + receipts: [], + failure: null, + }); +}); diff --git a/tests/control_plane_ts/effect_runtime_handlers.test.ts b/tests/control_plane_ts/effect_runtime_handlers.test.ts new file mode 100644 index 000000000..d7f520b46 --- /dev/null +++ b/tests/control_plane_ts/effect_runtime_handlers.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createEffectRuntimeHandlers, + dispatchEffectRuntimeMethod, +} from "../../loopx/control_plane/effect_runtime_handlers.ts"; + +const handlers = createEffectRuntimeHandlers({ + fingerprint: "test-fingerprint", + requestShutdown: () => undefined, +}); + +test("runtime boundary rejects incomplete settlement identity", async () => { + await assert.rejects( + dispatchEffectRuntimeMethod(handlers, "settlement.identity", { + goal_id: "goal", + turn_instance_id: "turn", + }), + /identity\.agent_id must be a non-empty string/, + ); +}); + +test("runtime boundary rejects a result carrying value and failure", async () => { + await assert.rejects( + dispatchEffectRuntimeMethod(handlers, "settlement.bind_gate", { + result: { + value: { impossible: true }, + receipts: [], + failure: { + kind: "permission_denied", + step_kind: "durable_writeback", + reason: "denied", + }, + }, + }), + /cannot carry both a value and a failure/, + ); +}); + +test("runtime boundary rejects malformed journal inspection request", async () => { + await assert.rejects( + dispatchEffectRuntimeMethod(handlers, "turn_journal.inspect", { + schema_version: "unsupported", + journal: {}, + goal_id: "goal", + agent_id: "agent", + turn_key: "turn", + }), + /request schema mismatch/, + ); +}); diff --git a/tests/control_plane_ts/turn_journal.test.ts b/tests/control_plane_ts/turn_journal.test.ts new file mode 100644 index 000000000..1feda31ea --- /dev/null +++ b/tests/control_plane_ts/turn_journal.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + interpretTurnJournal, + interpretTurnJournalEffect, + type TurnJournalInspectionRequest, +} from "../../loopx/control_plane/turn_driver/turn_journal.ts"; + +const turnKey = `sha256:${"a".repeat(64)}`; + +function request(status = "committed"): TurnJournalInspectionRequest { + return { + schema_version: "loopx_turn_journal_interpretation_request_v0", + goal_id: "fixture-goal", + agent_id: "fixture-agent", + turn_key: turnKey, + journal: { + schema_version: "loopx_turn_journal_v0", + goal_id: "fixture-goal", + turn_key: turnKey, + status, + completed_phases: [ + "host_execute", + "typed_result", + "validation", + "durable_writeback", + "quota_spend", + "scheduler_apply", + "scheduler_ack", + ], + plan: { + turn_envelope: { goal_id: "fixture-goal", agent_id: "fixture-agent" }, + transaction: { + turn_key: turnKey, + settlement_plan: { + identity: { goal_id: "fixture-goal", agent_id: "fixture-agent" }, + }, + }, + }, + host_result: { turn_key: turnKey, private_detail: "do-not-expose" }, + receipt: { turn_key: turnKey, body: "do-not-expose" }, + }, + }; +} + +test("legal terminal replay is projected without effects or private fields", () => { + const input = request(); + const before = structuredClone(input); + const result = interpretTurnJournal(input); + + assert.equal(result.decision, "replay_legal"); + assert.equal(result.replay_legal, true); + assert.deepEqual(result.violations, []); + assert.deepEqual(result.effects, []); + assert.equal(JSON.stringify(result).includes("do-not-expose"), false); + assert.deepEqual(input, before); +}); + +test("journal interpretation preserves the canonical Effect Program slots", () => { + const turn = interpretTurnJournalEffect(request()); + + assert.equal(turn.request.kind, "turn_journal"); + assert.equal(turn.interpretation.route, "turn_journal_replay"); + assert.equal(turn.observation.decision, "replay_legal"); + assert.equal(turn.observation.should_run, false); + assert.deepEqual(turn.next_effect.cli_actions, []); + assert.deepEqual(interpretTurnJournal(request()), { + ok: true, + schema_version: "loopx_turn_journal_inspection_v0", + decision: "replay_legal", + journal_status: "committed", + replay_legal: true, + goal_matches: true, + owner_matches: true, + turn_key_matches: true, + phases_form_ordered_prefix: true, + completed_phases: [ + "host_execute", + "typed_result", + "validation", + "durable_writeback", + "quota_spend", + "scheduler_apply", + "scheduler_ack", + ], + tombstone_retained: true, + violations: [], + effects: [], + }); +}); + +test("identity and phase violations accumulate in stable order", () => { + const input = request(); + input.journal.goal_id = "other-goal"; + input.journal.turn_key = "sha256:other-turn"; + input.journal.completed_phases = ["host_execute", "validation"]; + const plan = input.journal.plan as Record; + const transaction = (plan.transaction ?? {}) as Record; + const settlement = (transaction.settlement_plan ?? {}) as Record; + const identity = (settlement.identity ?? {}) as Record; + identity.agent_id = "other-agent"; + + const result = interpretTurnJournal(input); + + assert.equal(result.decision, "replay_blocked"); + assert.deepEqual(result.violations, [ + "goal_mismatch", + "owner_mismatch", + "turn_key_mismatch", + "completed_phases_not_ordered_prefix", + ]); +}); + +test("non-terminal and malformed state cannot replay", () => { + const input = request("in_progress"); + input.journal.completed_phases = "host_execute"; + + const result = interpretTurnJournal(input); + + assert.equal(result.replay_legal, false); + assert.equal(result.phases_form_ordered_prefix, false); + assert.deepEqual(result.completed_phases, []); + assert.deepEqual(result.violations, [ + "completed_phases_invalid", + "journal_not_terminal", + ]); +}); + +test("present malformed optional identities block replay", () => { + const input = request(); + const hostResult = input.journal.host_result as Record; + const receipt = input.journal.receipt as Record; + hostResult.turn_key = ""; + receipt.turn_key = 7; + + const result = interpretTurnJournal(input); + + assert.equal(result.turn_key_matches, false); + assert.deepEqual(result.violations, ["turn_key_identity_missing"]); +}); + +test("JSON non-string phases keep the Python boundary normalization", () => { + const input = request(); + input.journal.completed_phases = ["host_execute", null]; + + const result = interpretTurnJournal(input); + + assert.deepEqual(result.completed_phases, ["host_execute", "None"]); + assert.equal(result.phases_form_ordered_prefix, false); + assert.equal(result.replay_legal, false); +}); diff --git a/tests/control_plane_ts/turn_journal_characterization_probe.mjs b/tests/control_plane_ts/turn_journal_characterization_probe.mjs new file mode 100644 index 000000000..69218d86f --- /dev/null +++ b/tests/control_plane_ts/turn_journal_characterization_probe.mjs @@ -0,0 +1,32 @@ +import { interpretTurnJournal } from "../../loopx/control_plane/turn_driver/turn_journal.ts"; + +const request = JSON.parse(await readStdin()); +const corpus = request.corpus; +const rows = corpus.cases.map((testCase) => { + const probe = testCase.request; + const result = interpretTurnJournal({ + schema_version: "loopx_turn_journal_interpretation_request_v0", + journal: probe.journal, + goal_id: probe.goal_id, + agent_id: probe.agent_id, + turn_key: probe.turn_key, + }); + const { ok: _ok, schema_version: _schemaVersion, ...row } = result; + return { case_id: testCase.case_id, ...row }; +}); + +process.stdout.write( + JSON.stringify({ + schema_version: "loopx_turn_journal_characterization_probe_v0", + corpus_schema_version: corpus.schema_version, + implementation_id: request.implementation_id, + rows, + }), +); + +async function readStdin() { + let raw = ""; + process.stdin.setEncoding("utf8"); + for await (const chunk of process.stdin) raw += chunk; + return raw; +} diff --git a/tests/control_plane_ts/turn_journal_effects.test.ts b/tests/control_plane_ts/turn_journal_effects.test.ts new file mode 100644 index 000000000..713d7f2e4 --- /dev/null +++ b/tests/control_plane_ts/turn_journal_effects.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { commitTurnJournal } from "../../loopx/control_plane/turn_driver/turn_journal_effects.ts"; + +function journal(status: string): Record { + return { + schema_version: "loopx_turn_journal_v0", + goal_id: "fixture-goal", + turn_key: `sha256:${"a".repeat(64)}`, + status, + completed_phases: ["host_execute"], + plan: { + transaction: { + settlement_plan: { identity: { effect_id: "fixture-effect" } }, + }, + }, + }; +} + +test("journal checkpoint retry is idempotent and operation-scoped", async () => { + const directory = await mkdtemp(join(tmpdir(), "loopx-ts-journal-")); + const path = join(directory, "turn.json"); + try { + const first = await commitTurnJournal({ + path, + journal: journal("in_progress"), + expected_effect_id: "fixture-effect", + expected_previous_sha256: null, + }); + const replay = await commitTurnJournal({ + path, + journal: journal("in_progress"), + expected_effect_id: "fixture-effect", + expected_previous_sha256: null, + }); + assert.equal(first.appended, true); + assert.equal(first.replayed, false); + assert.equal(replay.appended, false); + assert.equal(replay.replayed, true); + assert.equal(first.operation_id, replay.operation_id); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("journal checkpoint rejects a stale compare-and-swap precondition", async () => { + const directory = await mkdtemp(join(tmpdir(), "loopx-ts-journal-")); + const path = join(directory, "turn.json"); + try { + await commitTurnJournal({ + path, + journal: journal("in_progress"), + expected_effect_id: "fixture-effect", + expected_previous_sha256: null, + }); + await assert.rejects( + commitTurnJournal({ + path, + journal: journal("committed"), + expected_effect_id: "fixture-effect", + expected_previous_sha256: null, + }), + /compare-and-swap precondition failed/, + ); + assert.equal(JSON.parse(await readFile(path, "utf8")).status, "in_progress"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/fixtures/control_plane/turn_journal_characterization_v0.json b/tests/fixtures/control_plane/turn_journal_characterization_v0.json new file mode 100644 index 000000000..5362f4cd5 --- /dev/null +++ b/tests/fixtures/control_plane/turn_journal_characterization_v0.json @@ -0,0 +1,261 @@ +{ + "schema_version": "loopx_turn_journal_characterization_corpus_v0", + "description": "Public-safe, implementation-neutral Turn-journal replay invariants for pinned-base versus candidate qualification.", + "cases": [ + { + "case_id": "committed-complete-prefix", + "invariant_id": "terminal-consistent-journal-is-replayable", + "rationale": "A committed journal with consistent identities and an ordered phase prefix is a retained, effect-free replay tombstone.", + "request": { + "goal_id": "fixture-goal", + "agent_id": "fixture-agent", + "turn_key": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "journal": { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "fixture-goal", + "turn_key": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "status": "committed", + "completed_phases": ["host_execute", "typed_result", "validation", "durable_writeback", "quota_spend", "scheduler_apply", "scheduler_ack"], + "plan": { + "turn_envelope": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}, + "transaction": { + "turn_key": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "settlement_plan": {"identity": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}} + } + }, + "host_result": {"turn_key": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + "receipt": {"turn_key": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + } + }, + "invariant": {"decision": "replay_legal", "replay_legal": true, "goal_matches": true, "owner_matches": true, "turn_key_matches": true, "phases_form_ordered_prefix": true, "tombstone_retained": true} + }, + { + "case_id": "stopped-partial-prefix", + "invariant_id": "terminal-partial-prefix-is-replayable", + "rationale": "A stopped journal may retain a valid partial transaction prefix without reopening execution.", + "request": { + "goal_id": "fixture-goal", + "agent_id": "fixture-agent", + "turn_key": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "journal": { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "fixture-goal", + "turn_key": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "status": "stopped", + "completed_phases": ["host_execute", "typed_result"], + "plan": { + "turn_envelope": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}, + "transaction": { + "turn_key": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "settlement_plan": {"identity": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}} + } + }, + "host_result": {"turn_key": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, + "receipt": {"turn_key": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + } + }, + "invariant": {"decision": "replay_legal", "replay_legal": true, "phases_form_ordered_prefix": true, "tombstone_retained": true} + }, + { + "case_id": "failed-empty-prefix", + "invariant_id": "failed-terminal-tombstone-is-retained", + "rationale": "A failed journal with an empty ordered prefix is terminal evidence, not an instruction to repeat work.", + "request": { + "goal_id": "fixture-goal", + "agent_id": "fixture-agent", + "turn_key": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "journal": { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "fixture-goal", + "turn_key": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "status": "failed", + "completed_phases": [], + "plan": { + "turn_envelope": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}, + "transaction": { + "turn_key": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "settlement_plan": {"identity": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}} + } + } + } + }, + "invariant": {"decision": "replay_legal", "replay_legal": true, "phases_form_ordered_prefix": true, "tombstone_retained": true} + }, + { + "case_id": "goal-identity-mismatch", + "invariant_id": "goal-identity-is-fenced", + "rationale": "A terminal journal from another goal cannot be replayed through the selected goal path.", + "request": { + "goal_id": "fixture-goal", + "agent_id": "fixture-agent", + "turn_key": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "journal": { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "other-goal", + "turn_key": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "status": "committed", + "completed_phases": ["host_execute"], + "plan": { + "turn_envelope": {"goal_id": "other-goal", "agent_id": "fixture-agent"}, + "transaction": { + "turn_key": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "settlement_plan": {"identity": {"goal_id": "other-goal", "agent_id": "fixture-agent"}} + } + } + } + }, + "invariant": {"decision": "replay_blocked", "replay_legal": false, "goal_matches": false, "owner_matches": true, "turn_key_matches": true} + }, + { + "case_id": "owner-identity-mismatch", + "invariant_id": "owner-identity-is-fenced", + "rationale": "A journal owned by another agent cannot authorize replay for the selected agent.", + "request": { + "goal_id": "fixture-goal", + "agent_id": "fixture-agent", + "turn_key": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "journal": { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "fixture-goal", + "turn_key": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "status": "committed", + "completed_phases": ["host_execute"], + "plan": { + "turn_envelope": {"goal_id": "fixture-goal", "agent_id": "other-agent"}, + "transaction": { + "turn_key": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "settlement_plan": {"identity": {"goal_id": "fixture-goal", "agent_id": "other-agent"}} + } + } + } + }, + "invariant": {"decision": "replay_blocked", "replay_legal": false, "goal_matches": true, "owner_matches": false, "turn_key_matches": true} + }, + { + "case_id": "turn-key-mismatch", + "invariant_id": "turn-key-is-fenced", + "rationale": "A journal whose embedded transaction identity differs from the selected path must not replay.", + "request": { + "goal_id": "fixture-goal", + "agent_id": "fixture-agent", + "turn_key": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "journal": { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "fixture-goal", + "turn_key": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "status": "committed", + "completed_phases": ["host_execute"], + "plan": { + "turn_envelope": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}, + "transaction": { + "turn_key": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "settlement_plan": {"identity": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}} + } + }, + "host_result": {"turn_key": "sha256:1111111111111111111111111111111111111111111111111111111111111111"} + } + }, + "invariant": {"decision": "replay_blocked", "replay_legal": false, "goal_matches": true, "owner_matches": true, "turn_key_matches": false} + }, + { + "case_id": "unordered-phase-prefix", + "invariant_id": "settlement-phases-form-an-ordered-prefix", + "rationale": "Skipping a transaction phase makes the journal structurally unsafe even when its identities and terminal status match.", + "request": { + "goal_id": "fixture-goal", + "agent_id": "fixture-agent", + "turn_key": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "journal": { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "fixture-goal", + "turn_key": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "status": "committed", + "completed_phases": ["host_execute", "validation"], + "plan": { + "turn_envelope": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}, + "transaction": { + "turn_key": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "settlement_plan": {"identity": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}} + } + } + } + }, + "invariant": {"decision": "replay_blocked", "replay_legal": false, "phases_form_ordered_prefix": false, "tombstone_retained": true} + }, + { + "case_id": "non-terminal-journal", + "invariant_id": "only-terminal-journals-are-replayable", + "rationale": "An in-progress journal remains live settlement state and cannot be consumed as a replay tombstone.", + "request": { + "goal_id": "fixture-goal", + "agent_id": "fixture-agent", + "turn_key": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "journal": { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "fixture-goal", + "turn_key": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "status": "in_progress", + "completed_phases": ["host_execute"], + "plan": { + "turn_envelope": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}, + "transaction": { + "turn_key": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "settlement_plan": {"identity": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}} + } + } + } + }, + "invariant": {"decision": "replay_blocked", "replay_legal": false, "phases_form_ordered_prefix": true, "tombstone_retained": false} + }, + { + "case_id": "unsupported-terminal-label", + "invariant_id": "terminal-status-vocabulary-is-closed", + "rationale": "An unknown status label cannot silently acquire terminal replay semantics.", + "request": { + "goal_id": "fixture-goal", + "agent_id": "fixture-agent", + "turn_key": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "journal": { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "fixture-goal", + "turn_key": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "status": "retired", + "completed_phases": ["host_execute"], + "plan": { + "turn_envelope": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}, + "transaction": { + "turn_key": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "settlement_plan": {"identity": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}} + } + } + } + }, + "invariant": {"decision": "replay_blocked", "replay_legal": false, "tombstone_retained": false} + }, + { + "case_id": "non-string-phase-element", + "invariant_id": "phase-elements-are-typed-strings", + "rationale": "A non-string phase must remain blocked across runtimes even if implementations differ in their diagnostic normalization.", + "request": { + "goal_id": "fixture-goal", + "agent_id": "fixture-agent", + "turn_key": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "journal": { + "schema_version": "loopx_turn_journal_v0", + "goal_id": "fixture-goal", + "turn_key": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "status": "committed", + "completed_phases": ["host_execute", null], + "plan": { + "turn_envelope": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}, + "transaction": { + "turn_key": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "settlement_plan": {"identity": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}} + } + } + } + }, + "invariant": {"decision": "replay_blocked", "replay_legal": false, "phases_form_ordered_prefix": false, "tombstone_retained": true} + } + ] +} diff --git a/tests/test_loopx_turn_executor.py b/tests/test_loopx_turn_executor.py index 23f245aca..aba47666e 100644 --- a/tests/test_loopx_turn_executor.py +++ b/tests/test_loopx_turn_executor.py @@ -16,6 +16,7 @@ ) from loopx.control_plane.turn_driver.executor import ( BuiltInHostError, + LOOPX_TURN_JOURNAL_SCHEMA_VERSION, _task_validation_stage, ) from loopx.control_plane.turn_driver.settlement import execute_turn_driver_settlement @@ -127,6 +128,7 @@ def test_task_validation_stage_reads_result_kind_through_effect_turn( plan = _plan() result = _host_result(plan, kind="wait") journal = { + "schema_version": LOOPX_TURN_JOURNAL_SCHEMA_VERSION, "status": "in_progress", "completed_phases": list(TRANSACTION_PHASES[:2]), } diff --git a/tests/test_loopx_turn_journal_inspection.py b/tests/test_loopx_turn_journal_inspection.py index deace0a71..d9527d241 100644 --- a/tests/test_loopx_turn_journal_inspection.py +++ b/tests/test_loopx_turn_journal_inspection.py @@ -11,6 +11,7 @@ from loopx.cli import main as cli_main from loopx.cli_commands import turn as turn_command from loopx.control_plane.turn_driver import executor +from loopx.control_plane.turn_driver import turn_journal_runtime TURN_KEY = "sha256:" + "a" * 64 @@ -357,3 +358,115 @@ def denied_read(path: Path) -> None: assert exit_code == 1 assert payload["error"] == "LoopX Turn journal could not be read" assert str(journal_path) not in raw_output + + +def test_inspection_has_no_python_fallback_when_typescript_runtime_is_missing( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_journal(tmp_path, _journal()) + + def missing_node(*_args: object, **_kwargs: object) -> object: + raise RuntimeError("Turn-journal inspection requires Node.js 22.6 or newer") + + monkeypatch.setattr(turn_journal_runtime, "effect_runtime_result", missing_node) + + exit_code, raw_output = _run_inspection_cli(tmp_path, output_format="json") + + payload = json.loads(raw_output) + assert exit_code == 1 + assert payload == { + "ok": False, + "schema_version": "loopx_turn_journal_inspection_v0", + "error": "Turn-journal inspection requires Node.js 22.6 or newer", + "effects": [], + } + + +def test_typescript_runtime_uses_one_typed_rpc_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, dict[str, object]]] = [] + + def rpc(method: str, params: dict[str, object]) -> dict[str, object]: + calls.append((method, params)) + return { + "ok": True, + "schema_version": "loopx_turn_journal_inspection_v0", + "decision": "replay_legal", + "journal_status": "committed", + "replay_legal": True, + "goal_matches": True, + "owner_matches": True, + "turn_key_matches": True, + "phases_form_ordered_prefix": True, + "completed_phases": COMPLETED_PHASES, + "tombstone_retained": True, + "violations": [], + "effects": [], + } + + monkeypatch.setattr(turn_journal_runtime, "effect_runtime_result", rpc) + + result = turn_journal_runtime.interpret_turn_journal_projection( + _journal(), + goal_id="fixture-goal", + agent_id="fixture-agent", + turn_key=TURN_KEY, + ) + + assert result["decision"] == "replay_legal" + assert len(calls) == 1 + assert calls[0][0] == "turn_journal.inspect" + assert calls[0][1]["turn_key"] == TURN_KEY + + +def test_typescript_runtime_fails_closed_when_process_cannot_start( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def cannot_start(*args: object, **kwargs: object) -> object: + raise RuntimeError("TypeScript Effect runtime request failed") + + monkeypatch.setattr(turn_journal_runtime, "effect_runtime_result", cannot_start) + + with pytest.raises(RuntimeError, match="runtime request failed"): + turn_journal_runtime.interpret_turn_journal_projection( + _journal(), + goal_id="fixture-goal", + agent_id="fixture-agent", + turn_key=TURN_KEY, + ) + + +def test_typescript_runtime_rejects_malformed_projection_types( + monkeypatch: pytest.MonkeyPatch, +) -> None: + payload = { + "ok": True, + "schema_version": "loopx_turn_journal_inspection_v0", + "decision": "replay_legal", + "journal_status": "committed", + "replay_legal": "true", + "goal_matches": True, + "owner_matches": True, + "turn_key_matches": True, + "phases_form_ordered_prefix": True, + "completed_phases": COMPLETED_PHASES, + "tombstone_retained": True, + "violations": [], + "effects": [], + } + + monkeypatch.setattr( + turn_journal_runtime, + "effect_runtime_result", + lambda *_args, **_kwargs: payload, + ) + + with pytest.raises(RuntimeError, match="projection type mismatch"): + turn_journal_runtime.interpret_turn_journal_projection( + _journal(), + goal_id="fixture-goal", + agent_id="fixture-agent", + turn_key=TURN_KEY, + ) diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json new file mode 100644 index 000000000..a64af80e1 --- /dev/null +++ b/tsconfig.control-plane.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "forceConsistentCasingInFileNames": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": [ + "loopx/control_plane/effect_program.ts", + "loopx/control_plane/effect_runtime_handlers.ts", + "loopx/control_plane/effect_runtime_io.ts", + "loopx/control_plane/effect_runtime_server.ts", + "loopx/control_plane/turn_driver/turn_journal.ts", + "loopx/control_plane/turn_driver/turn_journal_effects.ts", + "tests/control_plane_ts/effect_program.test.ts", + "tests/control_plane_ts/turn_journal.test.ts", + "tests/control_plane_ts/turn_journal_effects.test.ts" + ] +}