Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions loopx/control_plane/quota/effect_program.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
settlement_result_payload,
)
from ..goals.goal_ref_validation import exact_goal_ref
from ..work_items.delivery_batch_scale import DELIVERY_BATCH_SCALE_INPUT_CHOICES

__all__ = [
"SETTLEMENT_IDENTITY_SCHEMA_VERSION",
Expand Down Expand Up @@ -198,6 +199,7 @@ def build_turn_scoped_cli_settlement_plan(
)
payload = effect_runtime_result("settlement.turn_scoped_cli_plan", {
"identity": identity.as_dict(), "delivery_boundary": delivery_boundary,
"delivery_batch_scale_choices": list(DELIVERY_BATCH_SCALE_INPUT_CHOICES),
**({"goal_ref": normalized_goal_ref} if normalized_goal_ref is not None else {}),
"command_templates": {
"todo_completion": ordinary_completion, "durable_writeback": writeback,
Expand Down
23 changes: 21 additions & 2 deletions loopx/control_plane/quota/settlement_plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {
} from "../effect_program.ts";
import {VISION_MATERIAL_CLOSEOUT_HINT, visionAuthoringContract} from "../goals/vision_checkpoint.ts";
import {parseExactGoalRef} from "../goals/goal_instance_identity.ts";
import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts";
import {requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts";
import {DELIVERY_OUTCOMES} from "../work_items/delivery_outcome.ts";
import {DELIVERY_BOUNDARIES} from "../turn_driver/delivery_continuity.ts";
import {PROGRESS_RESULT_CLASSES} from "../work_items/replan_history.ts";

export function turnScopedCliSettlementPlan(params: JsonObject): SettlementPlan {
const identity = settlementIdentity(
Expand All @@ -15,6 +18,22 @@ export function turnScopedCliSettlementPlan(params: JsonObject): SettlementPlan
const commands = requireJsonObject(params.command_templates, "command_templates");
const writeback = requireNonEmptyString(commands.durable_writeback, "durable_writeback");
const spend = requireNonEmptyString(commands.quota_spend, "quota_spend");
// Scale remains CLI-owned. Project its actual accepted inputs rather than
// copying another enum or asking callers to load the entire command help.
const scales = requireStringArray(params.delivery_batch_scale_choices, "delivery_batch_scale_choices");
requireNonEmptyString(scales.map(scale => requireNonEmptyString(scale, "delivery batch scale")).join("|"),
"delivery batch scale choices");
const inputHint = " Fill placeholders from the actual result: classification is a descriptive label; " +
"scale=" + scales.join("|") + "; outcome=" + DELIVERY_OUTCOMES.join("|") + ". " +
"Set --delivery-boundary " + DELIVERY_BOUNDARIES.join("|") + " for the applicable case above. " +
"Typed evidence: --progress-result-class " + PROGRESS_RESULT_CLASSES.join("|") +
". For blocked outcome_gap, --progress-work-item-id must match this plan's Todo/obligation id " +
"and --progress-blocker-id names the blocker. Repeat --progress-evidence-id for evidence. " +
"IDs are 1-128 characters, start with an ASCII letter/digit, then letters/digits or ._:/- " +
"(e.g. evidence:validation-1); an ID does not upload a file. " +
"Use --agent-vision-json <file> for the material vision packet; " +
"--vision-unchanged-reason <reason> only reuses an already evidence-linked vision. " +
"These are authoring choices, not defaults, proof of progress or new requirements.";
const parsedGoalRef = params.goal_ref === undefined
? null
: parseExactGoalRef(params.goal_ref);
Expand Down Expand Up @@ -58,7 +77,7 @@ export function turnScopedCliSettlementPlan(params: JsonObject): SettlementPlan
"For an admitted open continuous Monitor whose observation is unavailable, " +
"use this original identity for typed blocked outcome_gap; it settles without " +
"poll or spend and preserves observation clocks/hash/generation. " +
"An already committed Monitor poll needs neither refresh nor spend.",
"An already committed Monitor poll needs neither refresh nor spend." + inputHint,
idempotency_key_ref: "$.identity.effect_id", expected_receipt: "durable_writeback_receipt",
command_template: writeback,
// In-flight continuation uses its projected flags without a material vision packet.
Expand Down
16 changes: 16 additions & 0 deletions tests/control_plane/test_quota_settlement.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@
TURN_ID = "turn-settlement-1"


def test_writeback_guidance_uses_current_cli_scale_vocabulary():
from loopx.control_plane.work_items.delivery_batch_scale import (
DELIVERY_BATCH_SCALE_INPUT_CHOICES, DELIVERY_BATCH_SCALE_ALIASES,
)

plan = quota_effect_program.build_turn_scoped_cli_settlement_plan(
goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID,
turn_instance_id=TURN_ID, scoped_cli_args="", lifecycle_actor_args="",
)
hint = plan.as_dict()["ordered_steps"][1]["precondition"]
for scale in DELIVERY_BATCH_SCALE_INPUT_CHOICES:
assert scale in hint
for alias in DELIVERY_BATCH_SCALE_ALIASES:
assert alias not in hint # Historical aliases remain read-only.


@pytest.mark.parametrize("replan", [False, True])
def test_command_plan_supplies_actor_from_identity(replan):
plan = quota_effect_program.build_turn_scoped_cli_settlement_plan(
Expand Down
71 changes: 71 additions & 0 deletions tests/control_plane/test_settlement_command_guidance_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Ordinary callers can fill the returned writeback without global CLI help."""
from __future__ import annotations

import shlex
from pathlib import Path

import pytest

from loopx.control_plane.quota.turn_envelope import build_turn_envelope
from test_quota_settlement_cli import (
AGENT_ID, GOAL_ID, TODO_ID, _initialize_fixture_authority,
_run_cli, _run_generated_cli, _write_fixture,
)


@pytest.mark.parametrize("provider", ["file", "sqlite"])
@pytest.mark.parametrize("profile", ["codex_app", "generic_cli"])
def test_returned_writeback_can_record_progress_and_settle_once(
tmp_path: Path, provider: str, profile: str,
) -> None:
project, runtime, registry = _write_fixture(tmp_path)
_initialize_fixture_authority(project, runtime, provider=provider)
host = ["--codex-app"] if profile == "codex_app" else ["--runtime-profile", profile]
rc, guard = _run_cli(registry, runtime, "quota", "should-run", *host,
"--goal-id", GOAL_ID, "--agent-id", AGENT_ID,
"--todo-id", TODO_ID, "--turn-instance-id", "command-guidance")
assert rc == 0, guard
plan = guard["interaction_contract"]["cli_channel"]["settlement_plan"]
assert build_turn_envelope(guard)["writeback"]["settlement_plan"] == plan
step = next(s for s in plan["ordered_steps"] if s["kind"] == "durable_writeback")
guidance = step["precondition"]
# These are the inputs the caller needs for this actual bounded result.
for value in ("test_only", "outcome_progress", "in_flight_continuation",
"--progress-result-class", "advanced", "--progress-evidence-id"):
assert value in guidance
assert "classification" in guidance
command = step["command_template"]
for placeholder, value in {
"<validated_progress>": "focused_validation",
"<scale>": "test_only",
"<outcome>": "outcome_progress",
"<boundary>": "in_flight_continuation",
}.items():
command = command.replace(placeholder, value)
if "--delivery-boundary" not in shlex.split(command):
command += " --delivery-boundary in_flight_continuation"
command += " " + shlex.join([
"--delivery-workspace-path", str(project),
"--progress-result-class", "advanced",
"--progress-evidence-id", "evidence:focused-validation",
"--no-global-sync", "--suppress-external-sinks",
])
# Discoverability must not weaken evidence or exactly-once settlement gates.
rc, refused = _run_generated_cli(
command.replace("evidence:focused-validation", ".local/not-an-evidence-id"),
registry_path=registry,
)
assert rc != 0, refused
spend = next(s["command_template"] for s in plan["ordered_steps"] if s["kind"] == "quota_spend")
rc, premature = _run_generated_cli(spend, registry_path=registry)
assert rc != 0, premature
rc, writeback = _run_generated_cli(command, registry_path=registry)
assert rc == 0, writeback
assert writeback["progress_observation"]["result_class"] == "advanced"
rc, spent = _run_generated_cli(spend, registry_path=registry)
assert rc == 0, spent
rc, replay = _run_generated_cli(spend, registry_path=registry)
assert rc == 0 and replay["idempotent_replay"] is True, replay
rc, todo = _run_cli(registry, runtime, "todo", "list", "--goal-id", GOAL_ID,
"--todo-id", TODO_ID)
assert rc == 0 and todo["todo"]["status"] == "open", todo
5 changes: 5 additions & 0 deletions tests/control_plane_ts/quota_settlement_plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {turnScopedCliSettlementPlan} from "../../loopx/control_plane/quota/settl

const input = {
identity: {goal_id: "goal", agent_id: "agent", todo_id: "todo", turn_instance_id: "turn"},
delivery_batch_scale_choices: ["test_only", "single_surface", "multi_surface", "implementation"],
command_templates: {
todo_completion: "ordinary completion", durable_writeback: "writeback",
quota_spend: "spend", terminal_closeout: "terminal completion",
Expand Down Expand Up @@ -80,6 +81,10 @@ test("autonomous replan never manufactures a Todo completion step", () => {
});

test("incomplete command facts or ambiguous identity fail closed", () => {
for (const choices of [undefined, [], [""], [1]]) {
assert.throws(() => turnScopedCliSettlementPlan({...input, delivery_batch_scale_choices: choices}),
/must be an array of strings|must be a non-empty string/);
}
for (const key of ["todo_completion", "durable_writeback", "quota_spend", "terminal_closeout"]) {
assert.throws(() => turnScopedCliSettlementPlan({...input,
command_templates: {...input.command_templates, [key]: ""},
Expand Down
Loading