Skip to content

feat(turn): add pure Turn Loop Controller transition contract#2433

Open
Claisenn wants to merge 3 commits into
huangruiteng:mainfrom
Claisenn:codex/gh-c72-loop-controller-transition
Open

feat(turn): add pure Turn Loop Controller transition contract#2433
Claisenn wants to merge 3 commits into
huangruiteng:mainfrom
Claisenn:codex/gh-c72-loop-controller-transition

Conversation

@Claisenn

Copy link
Copy Markdown
Contributor

Implements the P0 pure transition contract from the Turn Loop Controller plan (closes #2432).

Summary

Add loopx/control_plane/turn_driver/loop_controller.py exposing one pure function:

decide_loop_disposition(turn_receipt=..., quota_decision=..., bounded_turn_budget=...) -> loop_turn_loop_disposition_v0

Given one Turn receipt (result_kind + optional lineage) and a fresh loopx_turn_envelope_v0 decision, it returns exactly one typed disposition — run_now / wait / user_action_required / repair / replan / terminal — with a reason, lineage, and spends_quota=false, launches_host=false, writes_state=false markers.

Boundary held deliberately narrow per the plan: the function never invokes a model, sleeps, mutates a host scheduler, writes state, or spends quota. Scheduler process management, host wake adapters, and operator presentation are untouched (P1/P2 slices). run-once remains the only delivery transaction.

Decision semantics

  • validated_completion -> terminal; validated_progress + budget remaining + delivery decision -> run_now; budget exhausted -> terminal; no delivery decision -> wait.
  • receipt/decision replan-class actions -> replan with a replan_continuation payload: requires_bounded_delta=true, fresh_envelope_required=true, stale_todo_rerun_allowed=false — a resumable host session never reruns a stale todo without a bounded todo/vision delta.
  • repair-class actions and typed failures (host_failure, validation_failed, writeback_failed, quota_spend_failed) -> repair.
  • user action projected by receipt or decision -> user_action_required; quiet/cadence-only decision -> wait (no spend).
  • Fail-closed cases: stale receipt lineage (goal/agent mismatch with the fresh decision), malformed envelope, broken action signature, or unknown receipt kind -> wait with an explicit stale_receipt / contract_error reason. Nothing is guessed or fabricated. (This answers the open question from GH-C72: P0 pure Turn Loop Controller transition contract #2432 by keeping six dispositions with typed reasons; easy to promote to a seventh disposition if preferred.)

Also adds the contract doc docs/reference/protocols/turn-loop-controller-v0.md (decision table + replan continuation boundary) and links it from the protocols index.

Validation

  • tests/test_loop_turn_loop_controller.py: 22-row decision table covering every disposition plus boundary rows (progress at budget limit, replan without delta, stale lineage, malformed inputs, all four failure kinds)
  • pytest -q tests/test_loop_turn_loop_controller.py tests/test_loopx_turn_driver.py tests/test_loopx_turn_executor.py tests/test_loopx_turn_transaction.py (86 passed)
  • python examples/autonomous-replan-obligation-smoke.py
  • loopx check --scan-path docs/reference/protocols/turn-loop-controller-v0.md --scan-path docs/reference/protocols/loopx-turn-v0.md --scan-path CONTRIBUTOR_TASKS.md --scan-path CONTRIBUTING.md (public boundary clean)
  • git diff --check

@huangruiteng

Copy link
Copy Markdown
Owner

Thanks for keeping this P0 slice pure. The overall boundary is right: this PR should define the transition contract, while run-once, scheduler ownership, host wakeup, and state mutation remain outside it.

Before merge, I would like two things closed.

1. Qualify the capability in one real long-horizon run

The 22-row decision table proves the local mapping, but it does not yet show that this transition contract is useful and stable across a real multi-Turn trajectory. Please run one explicit opt-in, non-toy workload on this exact branch, placing decide_loop_disposition between each committed Turn receipt and the next fresh TurnEnvelope.

This is not a request to add the P1 scheduler/host adapter to this PR. A small external or manually driven harness is sufficient and may remain uncommitted. The workload should be long enough to exercise multiple consecutive validated_progress transitions and at least one real replan boundary. If a natural wait/resume or repair path occurs, include it; please do not manufacture a large fake runtime merely to hit every enum value.

Please check these invariants during the run:

  • every run_now is backed by a fresh envelope and the bounded Turn budget is respected;
  • after replan, a bounded todo/vision delta is written and a fresh envelope is obtained before the successor Turn;
  • the same stale todo is never rerun merely because the host session is resumable;
  • wait does not launch or spend, terminal actually stops the outer loop, and quota is spent only by the committed run-once transaction;
  • the trajectory does not oscillate indefinitely between dispositions without material state change.

Please report a compact qualification note with: reviewed head SHA, workload class, Turn count, disposition sequence/counts, the replan delta and resume behavior, final terminal condition, and any surprises that caused you to tune the table. Public-safe evidence can be posted here. If the useful trace contains private task material, raw prompts/transcripts, credentials, or host-local paths, send the detailed evidence through the private Lark channel and leave a redacted conclusion on GitHub.

If this run exposes a decision-table issue, please refine the pure contract and focused tests. Please do not grow this PR into a second scheduler, resident loop, or broad integration framework.

2. Resolve one precedence contradiction, narrowly

At loop_controller.py:136-149, the fresh-decision user_action_required route returns before the receipt is parsed. Consequently:

receipt = validated_completion
fresh decision = user.action_required=true
actual disposition = user_action_required

That contradicts the documented row validated_completion + any -> terminal in turn-loop-controller-v0.md:44 and the PR description. I reproduced this on 47069baf.

Please make the precedence explicit and add one cross-product regression for this case. If the intended invariant is the one currently documented, validated completion should win before decision-only user action is returned. If user action is intentionally stronger, update the contract/decision table and explain why a completed loop still remains non-terminal. This should be a small ordering/test change; no generic policy engine or extra abstraction is needed.

Validation I ran

  • 86 passed: controller table plus Turn driver/executor/transaction tests
  • python examples/autonomous-replan-obligation-smoke.py passed under Python 3.11
  • focused runtime reproduction confirmed the completion/user-action conflict above
  • GitHub pytest check is green

Decision: hold pending the real-run qualification note and the precedence fix/clarification. The implementation scope should otherwise remain as small as it is now.

@Claisenn

Copy link
Copy Markdown
Contributor Author

Both items closed at 8de8660b.

1. Precedence fix (validated_completion outranks decision user action)

The documented row is the intended invariant: once the terminal postcondition is independently proven, the loop is terminal even if the fresh decision still projects a user gate — a completed loop must not stay non-terminal behind a stale gate projection. The receipt's result kind is now evaluated before the decision-only user_action_required early return. Added the cross-product regression (validated_completion receipt + decision user.action_required=true -> terminal), a short Precedence section in turn-loop-controller-v0.md, and the matching decision-table row. Decision user action still precedes every non-terminal disposition. 23 controller rows + 87 total pytest pass.

2. Real-run qualification note

  • Reviewed head SHA: 47069baf (qualification harness run twice; the precedence commit after it does not touch any exercised path — trajectory uses progress/replan/terminal dispositions, not the completion-vs-user-action row)
  • Workload class: opt-in, non-toy bounded ladder on this exact branch — 3 consecutive independently validated Codex CLI host Turns on one opaque session, plus one real autonomous-replan boundary triggered from the goal's Operating Lessons signals. Manually driven harness, uncommitted (.local/gh-c72/), public fixture only — no private task material, prompts, transcripts, credentials, or host-local paths.
  • Turn count: 3 committed run-once transactions (fixture host advancing a public marker one step per Turn; each step independently validated; budget max_turns=3).
  • Disposition sequence: run_now -> replan -> run_now -> run_now -> terminal (counts: run_now=3, replan=1, terminal=1).
  • Replan boundary: after Turn 1, no-progress/repeated-action signals produced a real autonomous_replan_required route in the fresh envelope (verified via quota should-run). The controller returned replan with requires_bounded_delta / stale_todo_rerun_allowed=false; a bounded delta was then written through the durable refresh-state path (--autonomous-replan-recorded --repair-delta-kind runnable_todo_set) before any successor Turn, and the next Turn resumed from a fresh envelope.
  • Invariants checked, all green:
    • every run_now was backed by a fresh loopx turn plan envelope and the bounded budget was respected (3/3 turns, terminal on exhaustion);
    • after replan, the bounded delta was written and a fresh envelope obtained before the successor Turn;
    • no stale todo was rerun (stale_todo_rerun_observed=false);
    • quota was spent only by the committed run-once transactions (3 spends == 3 turns); wait/replan/terminal launched nothing and spent nothing;
    • terminal stopped the outer loop; trajectory oscillation-free.
  • Surprises: none in the decision table. Tuning was confined to the harness (e.g. fixture trigger re-insertion after refresh-state rewrites the state file, and using the canonical runnable_todo_set delta kind), not to the pure contract.

Validation re-run at 8de8660b: 87 pytest (controller table + Turn driver/executor/transaction), examples/autonomous-replan-obligation-smoke.py, loopx check on the contract docs (public boundary clean), git diff --check — all pass. Scope unchanged: pure transition contract only; no scheduler, resident loop, or adapter added.

@huangruiteng huangruiteng left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact-head re-review of 8de8660. Requesting changes for three fail-closed contract gaps.

  1. [P0] Validated completion bypasses receipt validation and freshness checks.

loop_controller.py:144-154 returns terminal from any mapping whose result_kind string is validated_completion, before the receipt is parsed and before lineage is compared at lines 184-208. On this exact head, both a bare {"result_kind": "validated_completion"} mapping and a completion receipt with agent-2 against an agent-1 envelope return terminal.

Completion should outrank a decision-only user action only after the receipt is proven valid and fresh. Please make the controller consume or require the existing loopx_turn_receipt_validation_v0 result, reject invalid or stale lineage first, and add cross-product tests for stale completion and an unvalidated completion-shaped mapping.

  1. [P0] The controller accepts a forged or truncated TurnEnvelope as executable.

_envelope_route only checks schema_version and action_signature.matches. A marker-only action_signature with no source_hash, envelope_hash, or compaction contract returns run_now. The existing Turn driver validator requires non-empty equal hashes and compaction.within_budget=true. Please reuse one shared envelope-validation/route contract instead of introducing a weaker second parser, and cover missing hashes plus over-budget envelopes.

  1. [P1] Two owner-contract decisions are still encoded as permissive fallbacks.

Malformed/unknown inputs return disposition=wait with a contract_error reason, although the projected #2432 maintainer contract places contract_error outside the six legal dispositions. Also, validated progress with bounded_turn_budget omitted returns run_now; the optional mapping has no schema, cursor, lineage, or freshness proof. Please either obtain the explicit owner decision before merge or implement the projected contract: a distinct contract-error result and a provider-neutral validated cursor/budget context that fails closed when continuation cannot prove a bound.

Independent validation:

  • 87 focused controller/Turn tests passed
  • autonomous-replan-obligation smoke passed
  • public boundary and git diff checks passed
  • minimal exact-head reproductions confirmed stale completion -> terminal, bare completion -> terminal, marker-only signature -> run_now, missing budget progress -> run_now, and malformed envelope -> wait(contract_error)

The submitted real-run note is useful evidence for the ordinary progress/replan path, but it does not close these negative cross-product boundaries. Please keep the fix inside the pure contract and focused tests; no scheduler or resident-loop expansion is needed.

@Claisenn

Copy link
Copy Markdown
Contributor Author

All three fail-closed gaps closed at e64de042.

1. Completion no longer bypasses receipt validation and freshness. The pre-parse result_kind shortcut is removed. Material receipts (validated_completion / validated_progress) must now carry full (goal_id, agent_id, todo_id) lineage, and a goal/agent mismatch with the fresh decision returns contract_error — never terminal. A bare {"result_kind": "validated_completion"} mapping and a stale completion both fail closed. Only after that proof does completion outrank a decision-only user action (the precedence invariant from the previous round is preserved).

2. Envelope validation now reuses the shared typed route. _envelope_route delegates to the Turn plan driver's _typed_route, which requires loopx_turn_envelope_v0 schema, non-empty equal source_hash/envelope_hash, and compaction.within_budget=true. A marker-only action_signature, mismatched hashes, or an over-budget envelope now returns contract_error, never run_now. There is no second weaker parser.

3. Contract errors are a typed disposition, and progress requires a proven budget. Added contract_error to the disposition enum (replacing the earlier wait + reason-string fallback) for malformed/truncated envelopes, unknown receipt kinds, missing/stale lineage, and unbounded progress. validated_progress now continues only when max_turns + completed_turns are present; omitted budget fails closed to contract_error instead of guessing an unbounded continuation.

Cross-product negative coverage added: stale completion -> contract_error, bare completion-shaped mapping -> contract_error, marker-only signature -> contract_error, mismatched signature hashes -> contract_error, over-budget compaction -> contract_error, progress without bounded budget -> contract_error. The contract doc's decision table and a new "Precedence And Fail-Closed Rules" section document all of this.

Validation at e64de042:

  • 93 pytest (29-row controller decision table + Turn driver/executor/transaction)
  • examples/autonomous-replan-obligation-smoke.py
  • the same real-run harness trajectory re-run green at the new head: run_now -> replan -> run_now -> run_now -> terminal, bounded delta written before the successor Turn, quota spent only by committed run-once transactions, oscillation-free
  • loopx check on contract docs (public boundary clean), git diff --check

Scope unchanged: pure transition contract + focused tests only; no scheduler, resident loop, or adapter added.

@huangruiteng huangruiteng left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact-head re-review of e64de042. The envelope hardening and typed contract_error changes are good, but the receipt and bounded-context blockers are not closed yet.

Findings

  1. [P0] The controller accepts a synthetic completion that the existing receipt validator never proved, while rejecting the real validator output.

    loop_controller.py:152-201 reads only result_kind plus caller-supplied lineage. It does not require schema_version=loopx_turn_receipt_validation_v0, ok=true, an acceptable validation/commit status, or the validated turn_key. Therefore this mapping returns terminal:

    {
        "result_kind": "validated_completion",
        "lineage": {"goal_id": "goal-1", "agent_id": "agent-1", "todo_id": "todo-1"},
    }

    Conversely, validate_loopx_turn_receipt(...) and run-once emit loopx_turn_receipt_validation_v0 without a lineage field, so an actual ok=true, status=committed progress receipt is rejected as contract_error: ... missing goal/agent/todo lineage. The hand-built test helper at tests/test_loop_turn_loop_controller.py:53-63 hides this producer/consumer mismatch.

    Please make the controller consume the existing validated receipt contract (or add one explicit adapter that preserves its validation proof and exact lineage), then add an integration-shaped test using real validate_loopx_turn_receipt output. A caller-authored result_kind + lineage mapping must not be enough to prove completion.

  2. [P0] Todo freshness is collected but never compared.

    loop_controller.py:182-187 compares only goal_id and agent_id. A validated_completion receipt for todo-old against a fresh envelope selecting todo-new returns terminal. That can terminate a newly selected todo with a stale predecessor receipt, despite the contract claiming full (goal_id, agent_id, todo_id) freshness.

    Include todo_id in the exact lineage comparison and add the stale-todo completion/progress cross-product tests.

  3. [P1] Two integers are still treated as a “proven bounded turn budget.”

    loop_controller.py:203-218 uses isinstance(value, int), so Python booleans pass (max_turns=True, completed_turns=False yields run_now) and negative bounds pass (max_turns=-1, completed_turns=0 yields terminal). The mapping still has no schema or identity tying the cursor to the current goal/todo/turn.

    Please reject booleans and invalid ranges at minimum (max_turns > 0, 0 <= completed_turns <= max_turns) and make the bounded context carry enough identity/freshness proof to prevent a cursor from another loop being reused. Cover the invalid-domain and stale-context cases.

Validation

  • 93 passed: controller plus Turn driver/executor/transaction tests
  • Exact-head public-safe reproductions confirmed:
    • real committed validator receipt -> contract_error
    • synthetic matching-lineage completion -> terminal
    • stale-todo completion -> terminal
    • negative budget -> terminal
    • boolean budget -> run_now
  • GitHub pytest is green

Decision: request changes. Keep the fix in the pure contract, its producer/adapter boundary, and focused semantic tests; no scheduler or resident-loop expansion is needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GH-C72: P0 pure Turn Loop Controller transition contract

2 participants