From 76833730ef720aea90c0f38123af7f9c024cd4d2 Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 5 Oct 2026 19:30:27 +0800 Subject: [PATCH 01/18] feat(control-plane): protect first delivery result and direction stages Signed-off-by: Tartar --- .../project_lifecycle_refresh_state.py | 9 + loopx/cli_commands/status.py | 13 ++ loopx/cli_commands/todo.py | 3 + .../cli_commands/todo_argument_validation.py | 8 + loopx/cli_commands/todo_registration.py | 2 + loopx/cli_commands/turn_registration.py | 2 + loopx/cli_commands/turn_run_once.py | 25 ++- loopx/cli_commands/turn_todo_writeback.py | 8 + .../coordination/local_authority_runtime.ts | 17 +- .../coordination/todo_delivery_context.ts | 115 +++++++++++ .../coordination/todo_terminal_lifecycle.ts | 35 +++- .../control_plane/effect_runtime_handlers.ts | 2 + .../goals/checkpoint_authority.ts | 45 +++-- .../control_plane/goals/checkpoint_commit.ts | 106 +++++++++- .../goals/checkpoint_context_io.py | 185 ++++++++++++++++-- .../goals/checkpoint_read_context.ts | 91 +++++++-- .../control_plane/host_adapter_settlement.py | 15 +- loopx/control_plane/quota/refresh_recovery.ts | 10 + loopx/control_plane/quota/settlement.py | 12 ++ loopx/control_plane/status/collection.py | 3 + loopx/control_plane/status/first_delivery.py | 25 +++ .../todos/provider_terminal_lifecycle.py | 53 +++-- loopx/control_plane/turn_driver/codex_cli.py | 23 ++- .../turn_driver/execution_readback.py | 12 ++ loopx/control_plane/turn_driver/executor.py | 75 ++++++- .../turn_driver/first_delivery.py | 101 ++++++++++ .../turn_driver/first_delivery.ts | 40 ++++ .../turn_driver/host_todo_completion.ts | 56 +++++- .../turn_driver/turn_contract_generated.py | 2 +- .../turn_driver/turn_contract_generated.ts | 2 +- loopx/goal_mode_mcp.py | 24 ++- loopx/quota.py | 15 +- .../project_registry_io_manifest_v1.json | 22 ++- loopx/semantics/vocabulary_v0.json | 1 + loopx/state_refresh.py | 35 +++- loopx/todos.py | 3 + 36 files changed, 1108 insertions(+), 87 deletions(-) create mode 100644 loopx/control_plane/coordination/todo_delivery_context.ts create mode 100644 loopx/control_plane/status/first_delivery.py create mode 100644 loopx/control_plane/turn_driver/first_delivery.py create mode 100644 loopx/control_plane/turn_driver/first_delivery.ts diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index 7abc32980b..c221b53676 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -97,6 +97,11 @@ def register_refresh_state_command( binding.add_argument("--todo-id") binding.add_argument("--replan-obligation-id") context_parser.add_argument("--goal-instance-id", help=argparse.SUPPRESS) + context_parser.add_argument("--purpose", choices=("supplement_checkpoint", "first_delivery", "delivery_result"), + default="supplement_checkpoint", + help="Explicitly enroll this original Turn in protected first delivery, or repair its missing checkpoint.") + context_parser.add_argument("--decision-scope", choices=("agent_lane", "goal"), default="agent_lane", + help="Complete work collection used by the first direction judgment; reading grants no write authority.") context_parser.add_argument("--project") context_parser.add_argument("--state-file") context_parser.add_argument("--dependency-todo-id", action="append", default=[], @@ -105,6 +110,8 @@ def register_refresh_state_command( "refresh-state", help="Append a read-only run from active goal state after state-only updates.", ) + refresh_state_parser.add_argument("--first-delivery", action="store_true", + help="Commit an explicitly enrolled first direction using its fresh checkpoint-context receipt.") add_subcommand_format(refresh_state_parser) refresh_state_parser.add_argument( "--goal-id", @@ -447,6 +454,7 @@ def handle_refresh_state_command( project=Path(args.project).expanduser() if args.project else None, state_file=Path(args.state_file).expanduser() if args.state_file else None, dependency_todo_ids=args.dependency_todo_id, + purpose=args.purpose, decision_scope=args.decision_scope, goal_ref=goal_ref, ) except Exception as exc: @@ -564,6 +572,7 @@ def handle_refresh_state_command( merge_agent_vision_patch=merge_agent_vision_patch, vision_unchanged_reason=args.vision_unchanged_reason, checkpoint_read_context_id=getattr(args, "checkpoint_read_context", None), + first_delivery=bool(getattr(args, "first_delivery", False)), progress_observation=progress_observation, usage_measurement=usage_measurement, usage_codex_session=( diff --git a/loopx/cli_commands/status.py b/loopx/cli_commands/status.py index 7e47e647c2..844aed955e 100644 --- a/loopx/cli_commands/status.py +++ b/loopx/cli_commands/status.py @@ -735,6 +735,19 @@ def attach_agent_lane_next_actions( guard=guard, ) latest_action = guard.get("latest_run_recommended_action") + delivery_progress = guard.get("first_delivery_progress") + if isinstance(delivery_progress, dict): + item["first_delivery_progress"] = delivery_progress + if ( + isinstance(delivery_progress, dict) + and guard.get("recommended_action") == delivery_progress["next_action"] + ): + item["recommended_action"] = delivery_progress["next_action"] + if isinstance(project_asset, dict): + project_asset["next_action"] = delivery_progress["next_action"] + goal_channel = item.get("goal_channel_projection") + if isinstance(goal_channel, dict): + goal_channel["next_action"] = delivery_progress["next_action"] for target in (item, project_asset): if not isinstance(target, dict): continue diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 9902baf6ca..e964f1ee75 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -631,6 +631,9 @@ def handle_todo_command( evidence=args.evidence, completion_result_file=Path(args.result_file).expanduser() if args.result_file else None, completion_turn_key=completion_turn_key, + delivery_read_context_id=getattr(args, "delivery_read_context", None), + delivery_direction_context_id=getattr(args, "direction_read_context", None), + delivery_settlement_identity=settlement_identity.as_dict() if settlement_identity else None, completion_identity_source=completion_identity_source, completion_delivery_workspace=completion_delivery_workspace, completion_validation_workspace_path=Path.cwd(), diff --git a/loopx/cli_commands/todo_argument_validation.py b/loopx/cli_commands/todo_argument_validation.py index 78ca61b31d..0d4b826a0c 100644 --- a/loopx/cli_commands/todo_argument_validation.py +++ b/loopx/cli_commands/todo_argument_validation.py @@ -479,6 +479,10 @@ def validate_todo_update_options(args: argparse.Namespace) -> None: def validate_todo_complete_options(args: argparse.Namespace) -> None: + if getattr(args, "direction_read_context", None) and (not args.no_follow_up or not args.turn_instance_id): + raise ValueError("--direction-read-context requires Turn-scoped --no-follow-up") + if getattr(args, "delivery_read_context", None) and not args.turn_instance_id: + raise ValueError("--delivery-read-context requires the original --turn-instance-id") if not args.todo_id: raise ValueError("todo complete requires --todo-id") if args.result_file and args.role == "user": @@ -564,6 +568,10 @@ def validate_todo_archive_completed_options(args: argparse.Namespace) -> None: def validate_shared_todo_options(args: argparse.Namespace) -> None: if getattr(args, "operation_id", None) and args.todo_command not in {"receipt", "add"}: raise ValueError("--operation-id is supported only by todo receipt and canonical todo add") + if getattr(args, "delivery_read_context", None) and args.todo_command != "complete": + raise ValueError("--delivery-read-context is supported only by todo complete") + if getattr(args, "direction_read_context", None) and args.todo_command != "complete": + raise ValueError("--direction-read-context is supported only by todo complete") if args.result_file and args.todo_command != "complete": raise ValueError("--result-file is supported only by todo complete") agent_id_allowed_for_user_authoring = ( diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index 0ed9f32ed3..faed026d47 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -107,6 +107,8 @@ def register_todo_command( todo_parser.add_argument("--note", help="Public-safe note to attach to a lifecycle transition.") todo_parser.add_argument("--evidence", help="Public-safe evidence pointer or short result for complete/update.") todo_parser.add_argument("--result-file", help="For todo complete with bound Goal acceptance criteria, bind a bounded local .json, .md or .txt result. A Todo validator alone is insufficient; use --evidence for a local artifact pointer.") + todo_parser.add_argument("--delivery-read-context", help="Original delivery_result read identity for protected Turn completion.") + todo_parser.add_argument("--direction-read-context", help="Committed Goal-scope direction read identity for protected --no-follow-up closeout.") todo_parser.add_argument( "--validation-command", help=( diff --git a/loopx/cli_commands/turn_registration.py b/loopx/cli_commands/turn_registration.py index ff582534b2..70555e5258 100644 --- a/loopx/cli_commands/turn_registration.py +++ b/loopx/cli_commands/turn_registration.py @@ -313,6 +313,8 @@ def register_turn_commands( ) run_once.add_argument("--timeout-seconds", type=float, default=None, help="Optional execution deadline; by default wait for host completion or cancellation.") + run_once.add_argument("--first-delivery", action="store_true", + help="Opt into File/SQLite result freshness and a separately metered direction review for this Turn.") run_once.add_argument( "--retry-failed-turn", action="store_true", diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index 5cb123a72c..a56018efeb 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -79,6 +79,13 @@ def execute_turn_run_once( execution_started = False try: project = Path(args.project).expanduser().resolve() + protected_delivery = bool(getattr(args, "first_delivery", False) or payload.get("first_delivery_freshness")) + if protected_delivery: + if args.host not in {"generic-cli", "codex-cli"} or getattr(args, "codex_operation_tools", False): + raise ValueError("First delivery currently supports generic-cli and ordinary codex-cli hosts.") + if goal_admission is not None and goal_admission.enabled: + raise ValueError("First delivery managed inference is not yet supported by the source-session effect profile.") + payload["first_delivery_freshness"] = True planned_host = ( payload.get("host") if isinstance(payload.get("host"), dict) else {} ) @@ -179,6 +186,13 @@ def require_effect_ref( f"{step_kind.value} effect ref does not match Turn identity" ) + def first_delivery_context(purpose: str) -> dict[str, Any]: + from ..control_plane.goals.checkpoint_context_io import read_checkpoint_context + return read_checkpoint_context(registry_path=registry_path, runtime_root_override=runtime_root_arg, + goal_id=settlement_identity.goal_id, agent_id=settlement_identity.agent_id, + todo_id=settlement_identity.todo_id, turn_instance_id=settlement_identity.turn_instance_id, + purpose=purpose, decision_scope="goal", goal_ref=goal_ref) + def append_settlement_event( effect_payload: Mapping[str, object], *, @@ -277,6 +291,8 @@ def writeback( project=state_project, state_file=None, classification=str(result["classification"]), + first_delivery=bool(result.get("first_delivery")), + checkpoint_read_context_id=result.get("checkpoint_read_context_id"), recommended_action=str(result["recommended_action"]), # A host's next_action is follow-up guidance, not refresh-state's # explicit within-task step edit (which requires a runnable Todo). @@ -358,6 +374,11 @@ def todo_completion( goal_id=args.goal_id, todo_id=todo_id, completion_turn_key=settlement_identity.turn_instance_id, + delivery_read_context_id=result.get("delivery_read_context_id"), + delivery_direction_context_id=(result.get("checkpoint_read_context_id") + if protected_delivery and effect_ref.endswith("#terminal_closeout") else None), + no_followup=protected_delivery and effect_ref.endswith("#terminal_closeout"), + delivery_settlement_identity=settlement_identity.as_dict() if protected_delivery else None, evidence=( "LoopX Turn validated completion: " + str(result.get("summary") or result["classification"]) @@ -433,7 +454,7 @@ def completion_writeback( *, effect_ref: str, ) -> dict[str, object]: - completion = todo_completion(result, effect_ref=effect_ref) + completion = result.get("_delivery_completion") or todo_completion(result, effect_ref=effect_ref) if not completion.get("ok"): return completion todo_id = str(selected_todo.get("todo_id") or "") @@ -894,6 +915,8 @@ def on_managed_start_admitted() -> None: admit_start=managed_cadence.admit if args.execute else None, confirm_start=managed_cadence.confirm if args.execute else None, goal_admission=goal_admission, + first_delivery_context=first_delivery_context if protected_delivery else None, + first_delivery_completion=todo_completion if protected_delivery else None, ) except Exception as exc: # noqa: BLE001 - CLI boundary renders typed JSON failure from ..usage_ping import capture_failure diff --git a/loopx/cli_commands/turn_todo_writeback.py b/loopx/cli_commands/turn_todo_writeback.py index 975fff2544..4c9a455dbc 100644 --- a/loopx/cli_commands/turn_todo_writeback.py +++ b/loopx/cli_commands/turn_todo_writeback.py @@ -51,6 +51,10 @@ def write_turn_validated_completion( agent_id: str | None, completion_delivery_workspace: Mapping[str, Any] | None = None, completion_validation_workspace_path: Path | None = None, + delivery_read_context_id: str | None = None, + delivery_direction_context_id: str | None = None, + no_followup: bool = False, + delivery_settlement_identity: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Complete one validated Todo under the effective runtime root.""" @@ -61,6 +65,10 @@ def write_turn_validated_completion( role="agent", completion_turn_key=completion_turn_key, completion_identity_source="turn_settlement", + delivery_read_context_id=delivery_read_context_id, + delivery_direction_context_id=delivery_direction_context_id, + no_followup=no_followup, + delivery_settlement_identity=delivery_settlement_identity, completion_delivery_workspace=completion_delivery_workspace, completion_validation_workspace_path=completion_validation_workspace_path, evidence=evidence, diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 66b36e1d67..16a9e2e68e 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1,4 +1,5 @@ import {requirePromotionRegisteredAgents} from "./shadow_registry_source.ts"; +import {withTerminalDeliverySources, terminalDeliveryBasisCheck} from "./todo_delivery_context.ts"; import {readPromotionReceipt, commitPromotionAndReadBack} from './promotion_receipt.ts'; import {reviewedPromotionPlan, promotionPlanDigest, decodeReviewedPromotionOperation, REVIEWED_PROMOTION_OPERATION_RESULT_SCHEMA} from './reviewed_promotion_plan.ts'; import {registryAuthoritySourceCheck} from "./authority_source.ts"; @@ -1295,11 +1296,15 @@ export async function terminalLifecycleLocalCoordinationTodo( requireAuthorityStoreId(todoId, "linked successor Todo id")); const successorIntents = input.successor_intents.map((intent, index) => requireJsonObject(intent, `successor_intents[${index}]`)); - return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => { + return await withTerminalDeliverySources(root, goalId, input, async () => { const store = await openRuntimeStore(root, goalId, dependencies); sourceAuthority = sourceAuthorityFor(store); providerEvidence.source_authority = sourceAuthority; - return {...await executeCoordinationTodoTerminalLifecycle(store, { + const execute = () => executeCoordinationTodoTerminalLifecycle(store, { + ...(input.delivery_context == null ? {} : {delivery_read_context_id: + requireAuthorityStoreId(requireJsonObject(input.delivery_context, "delivery context").read_context_id, "delivery read context id")}), + ...(input.delivery_context == null || requireJsonObject(input.delivery_context, "delivery context").direction_read_context_id == null ? {} : { + delivery_direction_context_id: requireAuthorityStoreId(requireJsonObject(input.delivery_context, "delivery context").direction_read_context_id, "direction read context id")}), validation_source_provider_revision: input.validation_source_provider_revision == null ? null : requireAuthorityStoreId(input.validation_source_provider_revision, "validation source provider revision"), validation_declaration_sha256: input.validation_declaration_sha256 == null @@ -1357,7 +1362,13 @@ export async function terminalLifecycleLocalCoordinationTodo( ? null : requireJsonObject(input.completion_policy_request, "completion_policy_request"), dry_run: input.dry_run as boolean, now: claimObservedAt(input.observed_at), - }, authoritySourcesCurrent), ...providerEvidence}; + }, authoritySourcesCurrent, terminalDeliveryBasisCheck(root, input, store)); + let result = await execute(); + // Retry only an explicit CAS conflict. An ambiguous result retains its + // operation id and is resolved by the existing command receipt owner. + for (let attempt = 1; input.delivery_context != null && attempt < 3 && + result.conflict_kind === "provider_revision_mismatch"; attempt++) result = await execute(); + return {...result, ...providerEvidence}; }); } catch (error) { return {schema_version: COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA, diff --git a/loopx/control_plane/coordination/todo_delivery_context.ts b/loopx/control_plane/coordination/todo_delivery_context.ts new file mode 100644 index 0000000000..b5c4471064 --- /dev/null +++ b/loopx/control_plane/coordination/todo_delivery_context.ts @@ -0,0 +1,115 @@ +/** Source locks and same-head basis check for Turn result commits. + * Validation runs between invocations. Provider CAS, not these locks, owns the + * atomic Todo delta and receipt. No checkpoint provider transaction is nested. */ +import {createHash} from "node:crypto"; +import {readFileSync} from "node:fs"; +import {join, resolve} from "node:path"; +import type {JsonObject} from "../effect_program.ts"; +import {settlementIdentity, settlementIdentityPayload} from "../effect_program.ts"; +import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; +import {withFileMutationLock} from "../effect_runtime_io.ts"; +import type {AuthorityStore, AuthorityStoreHead} from "./authority_store.ts"; +import {authorityStoreSourceAuthority} from "./authority_store.ts"; +import {canonicalAuthoritySha256} from "./authority_store_codec.ts"; +import {withCanonicalWriter} from "./local_authority_write.ts"; +import {legacyCoordinationTodoLockPath} from "./legacy_writer_lock_paths.ts"; +import {checkpointProviderFacts} from "../goals/checkpoint_authority.ts"; +import {evaluateCheckpointReadContext} from "../goals/checkpoint_read_context.ts"; +import {inspectCheckpointReplay} from "../goals/checkpoint_commit.ts"; + +const digest = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); +function bytes(path: string): Buffer { + try { return readFileSync(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return Buffer.alloc(0); + } +} + +/** Index -> registry -> maintenance -> Todo projection -> state -> provider CAS. */ +export async function withTerminalDeliverySources( + root: string, goalId: string, input: JsonObject, write: () => Promise, +): Promise { + if (input.delivery_context == null) return withCanonicalWriter(root, goalId, input.dry_run === true, write); + const context = requireJsonObject(input.delivery_context, "delivery context"); + const registry = requireJsonObject(input.registry_source, "registry source"); + const state = resolve(requireNonEmptyString(context.state_file, "delivery state file")); + return withFileMutationLock(join(root, "goals", goalId, "runs", "index.jsonl"), () => + withFileMutationLock(requireNonEmptyString(registry.path, "registry path"), () => + withCanonicalWriter(root, goalId, false, () => + withFileMutationLock(legacyCoordinationTodoLockPath(root, goalId), () => + withFileMutationLock(state, write, 30_000), 30_000)), 30_000), 30_000); +} + +/** Called only after durable receipt recovery, against the head used for CAS. */ +export function terminalDeliveryBasisCheck(root: string, input: JsonObject, store: AuthorityStore): + ((head: AuthorityStoreHead) => Promise) | undefined { + if (input.delivery_context == null) return undefined; + const context = requireJsonObject(input.delivery_context, "delivery context"); + const binding = requireJsonObject(context.identity, "delivery identity"); + const identity = settlementIdentity({goal_id: String(binding.goal_id), agent_id: String(binding.agent_id), + todo_id: String(binding.todo_id), turn_instance_id: String(binding.turn_instance_id), + replan_obligation_id: binding.replan_obligation_id == null ? null : String(binding.replan_obligation_id)}); + const rejected = (code: string, error: string): JsonObject => ({ok: false, error_code: code, + error, reread_required: true, next_action: "Read delivery_result context for the original Turn; recheck the candidate and validation before retrying."}); + return async head => { + if (context.capture_error != null) return rejected("delivery_source_unavailable", String(context.capture_error)); + const facts = requireJsonObject(context.facts, "delivery source facts"); + if (canonicalAuthoritySha256(binding) !== canonicalAuthoritySha256(settlementIdentityPayload(identity)) || + identity.goal_id !== input.goal_id || identity.todo_id !== input.todo_id || identity.agent_id !== input.actor_agent_id || + ![identity.effect_id, identity.turn_instance_id].includes(String(input.requested_completion_turn_key))) { + return rejected("delivery_identity_mismatch", "Delivery basis belongs to another Goal, Agent, Todo or Turn."); + } + const authority = authorityStoreSourceAuthority(store); + if (authority !== "file_v0" && authority !== "sqlite_v0") { + return rejected("delivery_provider_unsupported", "Delivery freshness requires File or SQLite authority."); + } + const source = requireJsonObject(facts.source, "delivery source"); + const state = resolve(requireNonEmptyString(context.state_file, "delivery state file")); + if (resolve(String(source.state_file)) !== state || + digest(bytes(state)) !== context.state_sha256 || + digest(bytes(join(root, "goals", identity.goal_id, "runs", "index.jsonl"))) !== context.index_sha256) { + return rejected("delivery_source_capture_changed", "Source changed while capturing the completion request; retry the same read identity."); + } + const storeId = await store.storeIdentity(); + if (storeId.status !== "available") return rejected("delivery_store_unavailable", "Cannot establish the current authority store identity."); + const receiptPath = join(root, "goals", identity.goal_id, "checkpoint-contexts", + `${createHash("sha256").update(identity.effect_id + ":delivery_result").digest("hex")}.json`); + let receipt: JsonObject | null = null; + try { receipt = jsonObject(JSON.parse(readFileSync(receiptPath, "utf8"))); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const current = checkpointProviderFacts(identity.goal_id, facts, head, storeId.store_identity, authority, true); + if (input.requested_no_followup === true) { + const directionId = context.direction_read_context_id; + if (typeof directionId !== "string") return rejected("delivery_direction_receipt_required", "Terminal closeout requires this Turn's committed direction read identity."); + const directionPath = join(root, "goals", identity.goal_id, "checkpoint-contexts", + `${createHash("sha256").update(identity.effect_id).digest("hex")}.json`); + const direction = requireJsonObject(JSON.parse(readFileSync(directionPath, "utf8")), "direction receipt"); + const prior = requireJsonObject(requireJsonObject(direction.commit_attempt, "committed attempt").index_record, "direction run"); + inspectCheckpointReplay({runtime_root: root, goal_id: identity.goal_id, prior}); + const committedContext = requireJsonObject(requireJsonObject(prior.vision_checkpoint, "direction checkpoint").read_context, "committed direction context"); + if (direction.read_context_id !== directionId || committedContext.read_context_id !== directionId || direction.decision_scope !== "goal") { + return rejected("delivery_direction_receipt_mismatch", "Terminal closeout requires the exact committed Goal-scope direction."); + } + const check = evaluateCheckpointReadContext({phase: "check", purpose: "first_delivery", identity: binding, + read_context_id: directionId, decision_scope: "goal", facts: current, + receipt: {...direction, commit_attempt: null, versions: committedContext.terminal_versions}}); + if (check.ok !== true) return check; + // Deferred terminal paths have not committed the result yet. Preserve its + // original candidate basis as well, allowing only this Turn's own Vision + // which was just proven from its indexed direction receipt. + const selected = (current.todos as JsonObject[]).find(todo => todo.todo_id === identity.todo_id); + if (selected?.status !== "done") { + return evaluateCheckpointReadContext({phase: "check", purpose: "delivery_result", identity: binding, + read_context_id: context.read_context_id, facts: current, receipt: receipt === null ? null : { + ...receipt, versions: {...requireJsonObject(receipt.versions, "result basis versions"), + agent_vision: canonicalAuthoritySha256(current.agent_vision)}, + }}); + } + return check; + } + return evaluateCheckpointReadContext({phase: "check", purpose: "delivery_result", identity: binding, + read_context_id: context.read_context_id, receipt, + facts: current}); + }; +} diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 757a438c99..ac98a00d1b 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -11,6 +11,7 @@ import type { JsonObject } from "../effect_program.ts"; import type { AuthorityStore, AuthorityStoreCommit, + AuthorityStoreHead, } from "./authority_store.ts"; import { AuthorityStoreProtocolError, @@ -77,6 +78,9 @@ type TodoRole = typeof TODO_ROLES[number]; type CompletionIdentitySource = typeof COMPLETION_IDENTITY_SOURCES[number]; interface CoordinationTodoTerminalLifecycleBaseInput { + /** Immutable read identity, included in the operation payload commitment. */ + readonly delivery_read_context_id?: string; + readonly delivery_direction_context_id?: string; readonly review_basis?: {readonly provider_revision: string; readonly registry_sha256: string}; /** Presence selects source-bound validation; null means no effect issued yet. */ readonly validation_source_provider_revision?: string | null; @@ -349,6 +353,18 @@ function validateSuccessorSemantics( function normalizeTerminalInput( raw: CoordinationTodoTerminalLifecycleInput, ): CoordinationTodoTerminalLifecycleInput { + if (raw.delivery_read_context_id !== undefined) { + requireAuthorityStoreId(raw.delivery_read_context_id, "delivery read context id"); + if (raw.operation_identity.kind !== "completion_turn" || raw.user_update !== undefined || raw.review_basis !== undefined) { + throw new AuthorityStoreProtocolError("delivery basis requires an ordinary Turn completion"); + } + } + if (raw.delivery_direction_context_id !== undefined) { + requireAuthorityStoreId(raw.delivery_direction_context_id, "delivery direction context id"); + if (raw.delivery_read_context_id === undefined || !raw.requested_no_followup) { + throw new AuthorityStoreProtocolError("direction receipt is only valid for protected terminal closeout"); + } + } if (raw.review_basis !== undefined) { const basis = canonicalAuthorityObject(raw.review_basis, "terminal review basis"); if (Object.keys(basis).some(key => !["provider_revision", "registry_sha256"].includes(key)) || @@ -471,6 +487,11 @@ function terminalRequestSha(input: CoordinationTodoTerminalLifecycleInput): stri todo_id: input.todo_id, expected_role: input.expected_role, command: input.command, + ...(input.delivery_read_context_id === undefined ? {} : { + delivery_read_context_id: input.delivery_read_context_id, + note: input.note, evidence: input.evidence, reason: input.reason, + ...(input.delivery_direction_context_id === undefined ? {} : {delivery_direction_context_id: input.delivery_direction_context_id}), + }), // Older receipts deliberately retain their original fingerprint. A reviewed // command binds both its approved snapshot and its complete prose intent. ...(input.review_basis === undefined ? {} : {review_basis: input.review_basis, @@ -1024,6 +1045,7 @@ export async function executeCoordinationTodoTerminalLifecycle( store: AuthorityStore, rawInput: CoordinationTodoTerminalLifecycleInput, authoritySourcesCurrent: AuthoritySourceCheck = uncheckedAuthoritySource, + deliveryBasisCheck?: (head: AuthorityStoreHead) => Promise, ): Promise { let normalized: CoordinationTodoTerminalLifecycleInput; try { @@ -1078,6 +1100,13 @@ export async function executeCoordinationTodoTerminalLifecycle( } head = observation.authority; } + if (input.delivery_read_context_id !== undefined) { + if (deliveryBasisCheck === undefined) return terminalFailure("delivery_basis_check_required", + "Protected completion requires its source and provider basis check.", {}, "decision_rejection"); + const basis = await deliveryBasisCheck(head); + if (basis.ok !== true) return terminalFailure(String(basis.error_code), String(basis.error), + {delivery_read_context: basis}, "decision_rejection"); + } let update: CoordinationTodoUpdateInput | undefined; if (input.user_update !== undefined) { try { @@ -1104,7 +1133,7 @@ export async function executeCoordinationTodoTerminalLifecycle( const reviewedRevision = input.review_basis?.provider_revision ?? update?.expected_provider_revision; const validationRevision = input.user_update?.validation_source_provider_revision ?? input.validation_source_provider_revision; if ((reviewedRevision !== undefined && reviewedRevision !== head.provider_revision) || - (validationRevision != null && validationRevision !== head.provider_revision)) { + (input.delivery_read_context_id === undefined && validationRevision != null && validationRevision !== head.provider_revision)) { return terminalFailure("provider_revision_mismatch", "Todo changed during completion review or validation; reread and retry"); } if ((update !== undefined || input.validation_source_provider_revision !== undefined) && @@ -1237,7 +1266,8 @@ export async function executeCoordinationTodoTerminalLifecycle( "decision_rejection"); } const acceptanceBinding = acceptanceRequirements === null ? null - : acceptanceSourceBinding(input, acceptanceRequirements, head.provider_revision); + : acceptanceSourceBinding(input, acceptanceRequirements, + input.delivery_read_context_id !== undefined && validationRevision != null ? validationRevision : head.provider_revision); let acceptanceEvidence: JsonObject | null = null; if (acceptanceRequirements !== null && acceptanceBinding !== null && input.goal_acceptance_validation_receipts != null) { @@ -1290,6 +1320,7 @@ export async function executeCoordinationTodoTerminalLifecycle( } if (fence.outcome === "continue" && fence.reason === "same_turn_terminal_upgrade") { const originalInput = {...input, requested_no_followup: false, + delivery_direction_context_id: undefined, operation_id: completionTurnOperationId(input, false)}; const original = await terminalReceipt(originalInput, terminalRequestSha(originalInput)).read(store); if (original === null) return terminalFailure("terminal_completion_receipt_required", diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 3a54171505..74c89c2c56 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -352,6 +352,7 @@ export function createEffectRuntimeHandlers( ["goal.checkpoint_read_context.resolve", lazyHandler(() => import("./goals/checkpoint_authority.ts"), ({resolveCheckpointReadContext}) => resolveCheckpointReadContext)], ["goal.checkpoint_read_context.commit", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({commitCheckpoint}) => commitCheckpoint)], ["goal.checkpoint_read_context.inspect_replay", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({inspectCheckpointReplay}) => inspectCheckpointReplay)], + ["goal.checkpoint_read_context.inspect_attempt", lazyHandler(() => import("./goals/checkpoint_commit.ts"), ({inspectCheckpointAttempt}) => inspectCheckpointAttempt)], ["goal.vision_wait.coverage", lazyHandler(() => import("./goals/vision_wait_coverage.ts"), ({projectVisionWaitCoverage}) => projectVisionWaitCoverage)], ["goal.shared_goal_alignment.project", lazyHandler(() => import("./goals/shared_goal_alignment.ts"), ({projectSharedGoalAlignment}) => projectSharedGoalAlignment)], ["goal.operator_actions.project", lazyHandler(() => import("./goals/operator_actions.ts"), ({projectGoalOperatorActions}) => projectGoalOperatorActions)], @@ -419,6 +420,7 @@ export function createEffectRuntimeHandlers( ["coordination.local_authority.handoff_mode_set", lazyHandler(() => import("./coordination/handoff_mode_runtime.ts"), ({setLocalHandoffMode}) => setLocalHandoffMode)], ["coordination.local_authority.handoff_mode_migrate", lazyHandler(() => import("./coordination/handoff_mode_runtime.ts"), ({migrateLocalHandoffMode}) => migrateLocalHandoffMode)], ["coordination.local_authority.todo_terminal", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({terminalLifecycleLocalCoordinationTodo}) => terminalLifecycleLocalCoordinationTodo)], + ["turn.first_delivery.evaluate", lazyHandler(() => import("./turn_driver/first_delivery.ts"), ({evaluateFirstDelivery}) => evaluateFirstDelivery)], ["coordination.local_authority.todo_archive", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({archiveLocalCoordinationTodos}) => archiveLocalCoordinationTodos)], ["coordination.local_authority.todo_archive_ack", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({acknowledgeLocalCoordinationTodoArchive}) => acknowledgeLocalCoordinationTodoArchive)], ["coordination.local_authority.todo_read", lazyHandler(() => import("./coordination/local_authority_read.ts"), ({readLocalCoordinationTodo}) => readLocalCoordinationTodo)], diff --git a/loopx/control_plane/goals/checkpoint_authority.ts b/loopx/control_plane/goals/checkpoint_authority.ts index fc5274654b..c0d39e1c16 100644 --- a/loopx/control_plane/goals/checkpoint_authority.ts +++ b/loopx/control_plane/goals/checkpoint_authority.ts @@ -1,24 +1,51 @@ /** Same-head checkpoint facts and the two shipped local provider fences. * Not an AuthorityStore extension contract or a checkpoint authority migration. */ import type {JsonObject} from "../effect_program.ts"; -import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; +import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {authorityStoreSourceAuthority} from "../coordination/authority_store.ts"; import {goalPathSegment} from "../rollout_receipt_log.ts"; import {FileAuthorityStore} from "../coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../coordination/sqlite_authority_store.ts"; import {openRuntimeAuthorityStore, requireLocalAuthorityRuntimeRoot} from "../coordination/local_authority_provider.ts"; import {loadLegacyCoordinationWriterFence} from "../coordination/legacy_writer_fence.ts"; -import {indexCoordinationProjectionTodos, validateCoordinationTodoReadModel} from "../coordination/coordination_projection.ts"; +import {indexCoordinationProjection, validateCoordinationTodoReadModel} from "../coordination/coordination_projection.ts"; +import {parseIsoTimestamp} from "../runtime_timestamp.ts"; import {readGoalAcceptance} from "./acceptance_contract.ts"; import {evaluateCheckpointReadContext} from "./checkpoint_read_context.ts"; +import type {AuthorityStoreHead} from "../coordination/authority_store.ts"; + +/** The same provider facts feed checkpoint append and Todo result CAS checks. */ +export function checkpointProviderFacts( + goalId: string, facts: JsonObject, head: AuthorityStoreHead, storeIdentity: string, + sourceAuthority: string, protectedDelivery: boolean, +): JsonObject { + const source = requireJsonObject(facts.source, "checkpoint source"); + const projection = indexCoordinationProjection(head.head, goalId); + validateCoordinationTodoReadModel(head.head, goalId); + const acceptance = readGoalAcceptance(head.head, goalId); + const todoId = jsonObject(facts.checkpoint_identity)?.todo_id; + const lease = protectedDelivery && typeof todoId === "string" ? projection.leases.get(todoId) : null; + const expiry = typeof lease?.expires_at === "string" ? parseIsoTimestamp(lease.expires_at) : null; + return {...facts, + ...(protectedDelivery ? {execution_lease: lease ? {...lease, + active: lease.status === "active" && expiry !== null && expiry > new Date()} : null} : {}), + todos: projection.todo_ids.map(id => projection.todos.get(id)!), + acceptance: {revision: acceptance?.revision ?? null, contract_digest: acceptance?.digest ?? null, + contract: acceptance?.enabled ? acceptance.document : null}, + provider_revision: head.provider_revision, + source: {...source, authority: sourceAuthority, store_identity: storeIdentity}, + }; +} export async function withCheckpointAuthority( root: string, goalId: string, facts: JsonObject, save: (facts: JsonObject) => JsonObject, + requireCanonical = false, ): Promise { const fence = await loadLegacyCoordinationWriterFence(root, goalId); if (fence.status === "failed") throw new Error(fence.reason); const source = requireJsonObject(facts.source, "checkpoint source"); if (fence.status === "missing") { + if (requireCanonical) throw new Error("first delivery freshness requires File or SQLite canonical authority"); return save({...facts, source: {...source, authority: "legacy_markdown", store_identity: null}}); } const store = await openRuntimeAuthorityStore(root, goalId, {}); @@ -26,16 +53,7 @@ export async function withCheckpointAuthority( throw new Error("checkpoint supplement requires a supported local provider fence"); } return await store.withCheckpointHead((head, identity) => { - const projection = indexCoordinationProjectionTodos(head.head, goalId); - validateCoordinationTodoReadModel(head.head, goalId); - const acceptance = readGoalAcceptance(head.head, goalId); - return save({...facts, - todos: projection.todo_ids.map(id => projection.todos.get(id)!), - acceptance: {revision: acceptance?.revision ?? null, contract_digest: acceptance?.digest ?? null, - contract: acceptance?.enabled ? acceptance.document : null}, - provider_revision: head.provider_revision, - source: {...source, authority: authorityStoreSourceAuthority(store), store_identity: identity}, - }); + return save(checkpointProviderFacts(goalId, facts, head, identity, authorityStoreSourceAuthority(store), requireCanonical)); }); } @@ -48,7 +66,8 @@ export async function resolveCheckpointReadContext(value: unknown): Promise current); + const current = await withCheckpointAuthority(root, goalId, facts, current => current, + request.purpose === "first_delivery" || request.purpose === "delivery_result"); // Reduce the captured snapshot after releasing the read fence, as before. // Only commitCheckpoint holds the provider fence through its final check/save. return evaluateCheckpointReadContext({...request, facts: current}); diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts index b70fbf4fb7..29afea38de 100644 --- a/loopx/control_plane/goals/checkpoint_commit.ts +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -1,7 +1,7 @@ /** Missing-checkpoint commit. Index/source claims survive the requesting CLI; * the real provider fence lasts through the synchronous durable append. */ import {createHash} from "node:crypto"; -import {closeSync, fsyncSync, lstatSync, openSync, readFileSync, writeFileSync} from "node:fs"; +import {closeSync, fsyncSync, lstatSync, openSync, readFileSync, readdirSync, renameSync, writeFileSync} from "node:fs"; import {dirname, join, resolve} from "node:path"; import type {JsonObject} from "../effect_program.ts"; import {settlementIdentity, settlementIdentityPayload} from "../effect_program.ts"; @@ -24,9 +24,10 @@ import { quotaAccountingOwnerLocks, requireCurrentQuotaAccountingOwner, } from "../quota/source_admission.ts"; -import {evaluateCheckpointReadContext} from "./checkpoint_read_context.ts"; +import {checkpointBasisSnapshot, evaluateCheckpointReadContext} from "./checkpoint_read_context.ts"; import {withCheckpointAuthority} from "./checkpoint_authority.ts"; import {goalPathSegment} from "../rollout_receipt_log.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; const digest = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); function unknown(message: string): never { @@ -34,7 +35,12 @@ function unknown(message: string): never { } function indexBytes(path: string): Buffer { - const bytes = readFileSync(path); + let bytes: Buffer; + try { bytes = readFileSync(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return Buffer.alloc(0); + } // The ordinary history reader tolerates damaged rows. A commit cannot infer // absence from that reader or append behind a torn (even JSON-valid) tail. if (bytes.length && bytes[bytes.length - 1] !== 10) unknown("checkpoint index has an incomplete tail"); @@ -106,6 +112,71 @@ function writeSynced(path: string, text: string, append = false): void { finally { closeSync(fd); } } +function writeReceipt(path: string, receipt: JsonObject): void { + const temporary = `${path}.${process.pid}.tmp`; + writeSynced(temporary, JSON.stringify(receipt) + "\n"); + renameSync(temporary, path); +} + +/** Caller holds the index lock. Unindexed artifacts never prove a committed + * direction. A completely absent attempt may be retried; torn writes stay held. */ +export function inspectCheckpointAttempt(value: unknown): JsonObject { + const request = requireJsonObject(value, "checkpoint attempt inspection"); + const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); + const identity = requireJsonObject(request.identity, "settlement identity"); + const runsDir = join(root, "goals", goalPathSegment(identity.goal_id), "runs"); + if (request.check_other_attempts === true) { + const contexts = join(root, "goals", goalPathSegment(identity.goal_id), "checkpoint-contexts"); + let names: string[] = []; + try { names = readdirSync(contexts); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const indexed = new Set(indexBytes(join(runsDir, "index.jsonl")).toString("utf8").split(/\r?\n/) + .filter(line => line.trim()).map(line => jsonObject(JSON.parse(line).settlement_identity)?.effect_id)); + for (const name of names.filter(name => name.endsWith(".json") && BARE_SHA256_PATTERN.test(name.slice(0, -5)))) { + const other = requireJsonObject(JSON.parse(readFileSync(join(contexts, name), "utf8")), "read receipt"); + const owner = jsonObject(other.identity); + if (other.purpose === "first_delivery" && other.commit_attempt != null && owner?.effect_id !== identity.effect_id && + owner?.agent_id === identity.agent_id && owner?.todo_id === identity.todo_id && !indexed.has(owner?.effect_id)) { + unknown("another original Turn has an unresolved direction append for this Todo"); + } + } + } + const path = join(root, "goals", goalPathSegment(identity.goal_id), "checkpoint-contexts", + `${createHash("sha256").update(requireNonEmptyString(identity.effect_id, "effect_id")).digest("hex")}.json`); + let receipt: JsonObject; + try { receipt = requireJsonObject(JSON.parse(readFileSync(path, "utf8")), "read receipt"); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {ok: true, status: "absent"}; + unknown("checkpoint read receipt is unavailable"); + } + if (canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(identity)) unknown("checkpoint receipt identity differs"); + const attempt = jsonObject(receipt.commit_attempt); + if (attempt == null) return {ok: true, status: "not_started"}; + const rows = indexBytes(join(runsDir, "index.jsonl")).toString("utf8").split(/\r?\n/) + .filter(line => line.trim()).map(line => requireJsonObject(JSON.parse(line), "run index row")); + const matching = rows.filter(row => jsonObject(row.settlement_identity)?.effect_id === identity.effect_id && + jsonObject(row.vision_checkpoint)?.read_context != null); + if (matching.length) { + const row = matching.find(row => canonicalAuthoritySha256(row) === canonicalAuthoritySha256(attempt.index_record)); + if (!row || matching.length !== 1) unknown("checkpoint attempt conflicts with its index"); + return {...committedArtifacts(row, runsDir), status: "committed"}; + } + for (const [field, extension] of [["json_path", ".json"], ["markdown_path", ".md"]]) { + const artifact = runPath(attempt[field], runsDir, extension); + try { + const stat = lstatSync(artifact); + // The caller reserves empty paths before persisting the attempt marker. + // Both empty regular files are an absent effect; any bytes remain unknown. + if (!stat.isFile() || stat.size !== 0) unknown("checkpoint append has unindexed artifacts"); + } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + } + // No content and no row: there was no visible effect. Clear only the marker, + // preserving the original read identity; a subsequent basis check still runs. + const {commit_attempt: _attempt, ...unstarted} = receipt; + return {ok: true, status: "absent", read_context_id: receipt.read_context_id, receipt: unstarted}; +} + export async function commitCheckpoint(value: unknown): Promise { const request = requireJsonObject(value, "checkpoint commit"); const root = requireLocalAuthorityRuntimeRoot(request.runtime_root); @@ -120,7 +191,9 @@ export async function commitCheckpoint(value: unknown): Promise { const runsDir = join(root, "goals", identity.goal_id, "runs"); const indexPath = join(runsDir, "index.jsonl"); const statePath = resolve(requireNonEmptyString(request.state_file, "state_file")); - const targets = [indexPath, shadowMaintenanceLockPath(root, identity.goal_id), + const firstDelivery = request.purpose === "first_delivery"; + const registryPath = firstDelivery ? resolve(requireNonEmptyString(request.registry_path, "registry_path")) : null; + const targets = [indexPath, ...(registryPath ? [registryPath] : []), shadowMaintenanceLockPath(root, identity.goal_id), legacyCoordinationTodoLockPath(root, identity.goal_id), statePath].map(path => resolve(path)); const owner = parseQuotaAccountingOwner({ goalRefValue: request.goal_ref, @@ -148,7 +221,7 @@ export async function commitCheckpoint(value: unknown): Promise { } function replay(readback: JsonObject): JsonObject | null { const recovery = requireJsonObject(readback.refresh_recovery, "refresh recovery"); - if (recovery.decision !== "replay") return null; + if (recovery.decision !== "replay" && recovery.decision !== "repair_receipt") return null; const prior = requireJsonObject(readback.writeback_run, "committed checkpoint"); if (jsonObject(prior.vision_checkpoint)?.satisfied !== true || jsonObject(prior.refresh_recovery)?.vision_request_digest !== recovery.vision_request_digest) { @@ -240,7 +313,7 @@ export async function commitCheckpoint(value: unknown): Promise { const repeated = replay(readback); if (repeated) return repeated; const recovery = requireJsonObject(readback.refresh_recovery, "refresh recovery"); - if (recovery.decision !== "supplement_checkpoint") { + if (recovery.decision !== (firstDelivery ? "append" : "supplement_checkpoint")) { throw new EffectRuntimeRequestError(String(recovery.reason ?? "checkpoint supplement rejected"), "checkpoint_commit_rejected"); } await requireShadowPrimaryWriteAllowed(root, identity.goal_id); @@ -256,12 +329,23 @@ export async function commitCheckpoint(value: unknown): Promise { if (digest(indexBytes(indexPath)) !== expectedIndex || digest(readFileSync(statePath)) !== expectedState) { unknown("checkpoint sources changed during lock handoff"); } + if (registryPath && digest(readFileSync(registryPath)) !== request.registry_sha256) { + unknown("checkpoint registry changed during lock handoff"); + } let receipt: unknown = null; try { receipt = JSON.parse(readFileSync(receiptPath, "utf8")); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } const context = evaluateCheckpointReadContext({phase: "check", identity: binding, + purpose: request.purpose, decision_scope: request.decision_scope, read_context_id: retry.checkpoint_read_context_id, receipt, facts: current}); if (context.ok !== true) return context; + if (firstDelivery) { + context.terminal_versions = checkpointBasisSnapshot({identity: binding, + purpose: "first_delivery", decision_scope: request.decision_scope, + facts: {...current, agent_vision: request.committed_agent_vision ?? current.agent_vision}, + dependency_todo_ids: requireJsonObject(receipt, "read receipt").dependency_todo_ids, + }).versions; + } const record = requireJsonObject(request.record, "checkpoint record"); const row = requireJsonObject(request.index_record, "checkpoint index record"); for (const projected of [record, row]) { @@ -275,12 +359,20 @@ export async function commitCheckpoint(value: unknown): Promise { const jsonPath = runPath(row.json_path, runsDir, ".json"); const markdownPath = runPath(row.markdown_path, runsDir, ".md"); try { + if (firstDelivery) { + // Persist uncertainty before the first run artifact. A replacement + // read cannot discard this attempt after a crash or a lost response. + writeReceipt(receiptPath, {...requireJsonObject(receipt, "first delivery receipt"), + commit_attempt: {vision_request_digest: recovery.vision_request_digest, + mutation_digest: recovery.mutation_digest, json_path: jsonPath, markdown_path: markdownPath, + index_record: row}}); + } writeSynced(jsonPath, JSON.stringify(record, null, 2) + "\n"); writeSynced(markdownPath, requireNonEmptyString(request.markdown, "checkpoint Markdown")); writeSynced(indexPath, JSON.stringify(row) + "\n", true); } catch { unknown("checkpoint append outcome is uncertain"); } return {ok: true, replayed: false, context, json_path: jsonPath, markdown_path: markdownPath}; - }); + }, firstDelivery); } finally { // The effect owns the end of the handed-off critical section. Releasing the // markers here also handles a caller that timed out but is still alive. diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index d63a223209..0d7968b166 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -37,7 +37,8 @@ def _checkpoint_effect(method: str, request: dict[str, Any]) -> Any: # The complete Goal prose and archived Todo basis can exceed the 2 MiB # RPC wire. Only this locked local checkpoint path opts into the exact, # digest-bound same-UID snapshot transport; default effects stay bounded. - return effect_runtime_result(method, request, large_local_snapshot=True) + return effect_runtime_result(method, request, + large_local_snapshot=method != "goal.checkpoint_read_context.inspect_attempt") except EffectRuntimeRejected as error: raise CheckpointReadContextRejected({"ok": False, "error": str(error), "error_code": error.diagnostic_code, "reread_required": False}) from error @@ -54,11 +55,117 @@ def _resolve(runtime_root: Path, **request: Any) -> dict[str, Any]: return result -def _receipt_path(root: Path, identity: SettlementIdentity) -> Path: - digest = hashlib.sha256(identity.effect_id.encode()).hexdigest() +def _receipt_path(root: Path, identity: SettlementIdentity, purpose: str = "supplement_checkpoint") -> Path: + key = identity.effect_id + (":delivery_result" if purpose == "delivery_result" else "") + digest = hashlib.sha256(key.encode()).hexdigest() return root / "goals" / identity.goal_id / "checkpoint-contexts" / f"{digest}.json" +def first_delivery_context_enrolled(root: Path, identity: SettlementIdentity) -> bool: + """Read the persisted protocol selection; enrollment survives caller restart.""" + for purpose in ("first_delivery", "delivery_result"): + try: + receipt = json.loads(_receipt_path(root, identity, purpose).read_text(encoding="utf-8")) + except FileNotFoundError: + continue + if receipt.get("purpose") == purpose: + return True + return False + + +def _inspect_attempt(root: Path, identity: SettlementIdentity) -> dict[str, Any]: + # Called with the original index and source locks held. The TS inspector is + # read-only; only this locked IO adapter can clear a proved absent marker. + result = _checkpoint_effect("goal.checkpoint_read_context.inspect_attempt", { + "runtime_root": str(root.resolve()), "identity": identity.as_dict(), "check_other_attempts": True}) + receipt = result.pop("receipt", None) + if receipt is not None: + atomic_write_json(_receipt_path(root, identity), receipt) + return result + + +def first_delivery_progress(root: Path, readback: Any) -> dict[str, Any] | None: + """Observe protocol receipts for quota consumers; this grants no write authority. + + A snapshot may lag a concurrent writer. Ambiguity is displayed as unknown; + only the locked replay path can resolve it or admit another operation. + """ + identity = readback.identity.value + if identity is None or not first_delivery_context_enrolled(root, identity): + return None + from ..coordination.local_authority import read_canonical_todos_if_promoted + + committed = False + unknown = False + try: + canonical = read_canonical_todos_if_promoted(runtime_root=root, goal_id=identity.goal_id) + selected = next((todo for todo in (canonical or {}).get("todos", []) if todo["todo_id"] == identity.todo_id), {}) + committed = selected.get("status") == "done" and selected.get("completion_turn_key") in {identity.effect_id, identity.turn_instance_id} + direction_path = _receipt_path(root, identity) + direction = json.loads(direction_path.read_text(encoding="utf-8")) if direction_path.exists() else {} + checkpoint = (readback.writeback_run or {}).get("vision_checkpoint", {}) + direction_committed = checkpoint.get("satisfied") is True and checkpoint.get("read_context", {}).get("purpose") == "first_delivery" + unknown = bool(direction.get("commit_attempt")) and not direction_committed + except (ValueError, OSError): + direction_committed = False + unknown = True + return effect_runtime_result("turn.first_delivery.evaluate", { + "phase": "project", "result_committed": committed, + "direction_committed": direction_committed, "unknown": unknown, + "quota_spent": readback.spend_run is not None, + "settlement_complete": readback.terminal_settlement.failure is None, + }) + + +def pending_first_delivery_progress(root: Path, goal_id: str, agent_id: str | None = None) -> dict[str, Any] | None: + """Bounded to this Agent's enrolled local Turns; readback, never admission.""" + identities = {} + for path in (root / "goals" / goal_id / "checkpoint-contexts").glob("*.json"): + receipt = json.loads(path.read_text(encoding="utf-8")) + binding = receipt.get("identity", {}) + if receipt.get("purpose") in {"first_delivery", "delivery_result"} and (agent_id is None or binding.get("agent_id") == agent_id): + identities[binding["effect_id"]] = binding + for binding in reversed(list(identities.values())): + readback = read_heartbeat_settlement(root, goal_id=goal_id, agent_id=binding["agent_id"], + todo_id=binding.get("todo_id"), replan_obligation_id=binding.get("replan_obligation_id"), + turn_instance_id=binding["turn_instance_id"]) + if readback is not None: + progress = first_delivery_progress(root, readback) + if progress is not None and progress["stage"] != "settled": + return {**progress, "settlement_identity": binding} + return None + + +def delivery_result_context_input( + *, runtime_root: Path, registry_path: Path, state_file: Path, + identity: SettlementIdentity, read_context_id: str | None, + direction_read_context_id: str | None = None, +) -> dict[str, Any] | None: + """Capture IO for the native Todo owner; never refresh the Agent's token. + + The native owner recovers a historical receipt before checking capture + errors. Every validation continuation recaptures sources under short locks. + """ + if not read_context_id and not direction_read_context_id and not first_delivery_context_enrolled(runtime_root, identity): + return None + result: dict[str, Any] = {"identity": identity.as_dict(), + "read_context_id": read_context_id or "missing", "state_file": str(state_file.resolve())} + if direction_read_context_id: + result["direction_read_context_id"] = direction_read_context_id + index = runtime_root / "goals" / identity.goal_id / "runs" / "index.jsonl" + try: + with exclusive_cross_runtime_file_lock(index, operation="delivery-result-capture"): + with _source_guard(runtime_root, identity.goal_id, state_file, registry_path): + result.update( + facts=_local_source_facts(runtime_root, registry_path, state_file, identity, first_delivery=True), + state_sha256=hashlib.sha256(state_file.read_bytes()).hexdigest(), + index_sha256=hashlib.sha256(index.read_bytes() if index.exists() else b"").hexdigest(), + ) + except (ValueError, OSError) as error: + result["capture_error"] = str(error) + return result + + def require_complete_checkpoint_index(index: Path) -> None: """Framing check before replay too; typed settlement validates the rows.""" try: @@ -73,7 +180,7 @@ def require_complete_checkpoint_index(index: Path) -> None: @contextmanager -def _source_guard(root: Path, goal_id: str, state_file: Path) -> Iterator[None]: +def _source_guard(root: Path, goal_id: str, state_file: Path, registry_path: Path | None = None) -> Iterator[None]: """Caller holds runs/index first. Match promotion's M -> Todo -> state order. M prevents source cutover; it does not exclude canonical provider commits. @@ -81,6 +188,8 @@ def _source_guard(root: Path, goal_id: str, state_file: Path) -> Iterator[None]: Do not run projection sync or a new state mutation inside this guard. """ with ExitStack() as locks: + if registry_path is not None: + locks.enter_context(exclusive_cross_runtime_file_lock(registry_path, operation="checkpoint-first-delivery")) for target in ( shadow_maintenance_lock_target(root, goal_id), legacy_coordination_todo_lock_path(runtime_root=root, goal_id=goal_id), @@ -94,6 +203,7 @@ def _source_guard(root: Path, goal_id: str, state_file: Path) -> Iterator[None]: def _local_source_facts( root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, goal_ref: Mapping[str, Any] | None = None, + *, first_delivery: bool = False, ) -> dict[str, Any]: text = state_file.read_text(encoding="utf-8") metadata, body = split_state_frontmatter(text) @@ -115,12 +225,24 @@ def _local_source_facts( ] newest = [run for _, run in sorted(enumerate(runs), key=lambda pair: (str(pair[1].get("generated_at") or ""), pair[0]), reverse=True)] - return { + facts = { "todos": todos, "frontmatter": metadata, "goal_prose": prose, "acceptance": None, "agent_vision": latest_agent_vision_from_runs(newest, goal_id=identity.goal_id, agent_id=identity.agent_id), "source": {"state_file": str(state_file.resolve()), "runtime_root": str(root.resolve()), "authority": "legacy_markdown"}, } + if first_delivery: + from ...state_refresh import resolve_goal_state, registered_agents_for_goal + + registry = load_registry(registry_path) + goal, _, current_path = resolve_goal_state(registry=registry, goal_id=identity.goal_id, + project_override=None, state_file_override=None) + if current_path.resolve() != state_file.resolve() or identity.agent_id not in registered_agents_for_goal(goal): + raise ValueError("first delivery source or Agent registration changed; reread the original Turn") + facts["source"].update(registry_path=str(registry_path.resolve()), goal_ref=goal_ref, + registry_goal=goal) + facts["checkpoint_identity"] = identity.as_dict() + return facts def read_checkpoint_context( @@ -129,10 +251,13 @@ def read_checkpoint_context( replan_obligation_id: str | None = None, project: Path | None = None, state_file: Path | None = None, dependency_todo_ids: list[str] | None = None, goal_ref: Mapping[str, Any] | None = None, + purpose: str = "supplement_checkpoint", decision_scope: str = "agent_lane", ) -> dict[str, Any]: # Local import avoids a cycle with refresh-state's persistence adapter. from ...state_refresh import resolve_goal_state, registered_agents_for_goal + if purpose != "supplement_checkpoint" and goal_ref is not None: + raise ValueError("First delivery freshness currently supports the local-registry Goal profile; source-session GoalRef admission is not qualified.") goal_id = validate_goal_id_path_segment(goal_id) registry = load_registry(registry_path) root = resolve_runtime_root(registry, runtime_root_override, registry_path=registry_path) @@ -155,18 +280,36 @@ def read_checkpoint_context( registry_path=registry_path, goal_ref=goal_ref, source_admission=source_admission, borrow_source_admission=source_admission is not None) - if readback is None or readback.identity.value is None or readback.writeback_run is None: + first_delivery = purpose != "supplement_checkpoint" + if readback is None or readback.identity.value is None or ( + not first_delivery and readback.writeback_run is None + ): raise ValueError("checkpoint-context requires the original committed Turn writeback") identity = readback.identity.value - with _source_guard(root, goal_id, path): + with _source_guard(root, goal_id, path, registry_path if first_delivery else None): + if purpose == "first_delivery": + attempt = _inspect_attempt(root, identity) + if attempt.get("status") == "committed": + return {**attempt, "settlement_identity": identity.as_dict(), "purpose": purpose, + "instructions": "Direction already committed. Replay the original refresh or resume this Turn's remaining settlement; do not request another direction."} + previous_receipt = None + if first_delivery: + try: + previous_receipt = json.loads(_receipt_path(root, identity, purpose).read_text(encoding="utf-8")) + except FileNotFoundError: + pass result = _resolve(root, phase="read", identity=identity.as_dict(), prior=readback.writeback_run, + purpose=purpose, decision_scope=decision_scope, + receipt=previous_receipt, + admitted_turn=readback.heartbeat_receipt is not None, read_context_id=uuid4().hex, dependency_todo_ids=dependency_todo_ids or [], - facts=_local_source_facts(root, registry_path, path, identity, goal_ref)) + facts=_local_source_facts(root, registry_path, path, identity, goal_ref, first_delivery=first_delivery)) receipt = result.pop("receipt") - atomic_write_json(_receipt_path(root, identity), receipt) + atomic_write_json(_receipt_path(root, identity, purpose), receipt) return {**result, "read_context_id": receipt["read_context_id"], "settlement_identity": identity.as_dict(), - "instructions": "Read this basis and judge the direction again. Echo read_context_id as " - "--checkpoint-read-context in the checkpoint-only refresh for this exact Turn. " + "purpose": purpose, + "instructions": ("Read this basis and validate the candidate. Echo read_context_id as --delivery-read-context in todo complete. " + if purpose == "delivery_result" else "Read this basis and judge the direction again. Echo read_context_id as --checkpoint-read-context in the direction refresh for this exact Turn. ") + "A new checkpoint-context read replaces this receipt; do not run parallel confirmations " "for the same Turn. On stale/replaced context, reread and rejudge; do not repeat task mutations or spend."} @@ -176,21 +319,27 @@ def checkpoint_commit_guard( *, runtime_root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, read_context_id: str | None, goal_ref: Mapping[str, Any] | None = None, + purpose: str = "supplement_checkpoint", decision_scope: str | None = None, ) -> Iterator[dict[str, Any]]: """Capture/preview under source locks. The native save repeats the check under the real provider fence; this preliminary check is not the commit.""" - with _source_guard(runtime_root, identity.goal_id, state_file): + first_delivery = purpose == "first_delivery" + with _source_guard(runtime_root, identity.goal_id, state_file, registry_path if first_delivery else None): + if first_delivery: + _inspect_attempt(runtime_root, identity) try: receipt = json.loads(_receipt_path(runtime_root, identity).read_text(encoding="utf-8")) except FileNotFoundError: receipt = None result = _resolve(runtime_root, phase="check", identity=identity.as_dict(), read_context_id=read_context_id, + purpose=purpose, decision_scope=decision_scope, receipt=receipt, facts=_local_source_facts( runtime_root, registry_path, state_file, identity, goal_ref, + first_delivery=first_delivery, )) yield result @@ -200,25 +349,33 @@ def commit_checkpoint_run( refresh_retry: dict[str, Any], record: dict[str, Any], index_record: dict[str, Any], markdown: str, goal_ref: Mapping[str, Any] | None = None, source_admission: Mapping[str, Any] | None = None, + purpose: str = "supplement_checkpoint", decision_scope: str | None = None, ) -> dict[str, Any]: """Handoff the held locks and parsed bytes to one native save operation.""" root = runtime_root.resolve() index = root / "goals" / identity.goal_id / "runs" / "index.jsonl" - targets = (index, shadow_maintenance_lock_target(root, identity.goal_id), + targets = (index, *((registry_path,) if purpose == "first_delivery" else ()), + shadow_maintenance_lock_target(root, identity.goal_id), legacy_coordination_todo_lock_path(runtime_root=root, goal_id=identity.goal_id), state_file) result = _checkpoint_effect("goal.checkpoint_read_context.commit", { "runtime_root": str(root), "state_file": str(state_file.resolve()), "identity": identity.as_dict(), + "purpose": purpose, "decision_scope": decision_scope, + **({"registry_path": str(registry_path.resolve()), + "registry_sha256": hashlib.sha256(registry_path.read_bytes()).hexdigest()} + if purpose == "first_delivery" else {}), "locks": [cross_runtime_lock_witness(target) for target in targets], "state_sha256": hashlib.sha256(state_file.read_bytes()).hexdigest(), - "index_sha256": hashlib.sha256(index.read_bytes()).hexdigest(), + "index_sha256": hashlib.sha256(index.read_bytes() if index.exists() else b"").hexdigest(), "facts": _local_source_facts( root, registry_path, state_file, identity, goal_ref, + first_delivery=purpose == "first_delivery", ), "refresh_retry": refresh_retry, "record": record, "index_record": index_record, "markdown": markdown, + "committed_agent_vision": latest_agent_vision_from_runs([index_record], goal_id=identity.goal_id, agent_id=identity.agent_id), **({"goal_ref": dict(goal_ref)} if goal_ref is not None else {}), **( {"source_admission": dict(source_admission)} diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index ebaaad1478..8921be00d8 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -4,8 +4,20 @@ import type {JsonObject} from "../effect_program.ts"; import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; +import {claimAllowsAgent} from "../todos/agent_scope.ts"; const RECEIPT_SCHEMA = "checkpoint_read_context_v1"; +const FIRST_RECEIPT_SCHEMA = "checkpoint_read_context_v2"; +export type CheckpointReadPurpose = "supplement_checkpoint" | "first_delivery" | "delivery_result"; +export type CheckpointDecisionScope = "agent_lane" | "goal"; + +function purpose(request: JsonObject): CheckpointReadPurpose { + const value = request.purpose ?? "supplement_checkpoint"; + if (value !== "supplement_checkpoint" && value !== "first_delivery" && value !== "delivery_result") { + throw new EffectRuntimeRequestError("unsupported checkpoint read purpose"); + } + return value; +} // Exact presentation fields only. Unknown future fields remain part of the basis. const DISPLAY_FIELDS = new Set(["index", "source_section", "schema_version"]); const todoFacts = (todo: JsonObject): JsonObject => Object.fromEntries( @@ -29,7 +41,7 @@ function dependencies(todo: JsonObject): string[] { return [...new Set(result)].sort(); } -function snapshot(request: JsonObject): JsonObject { +export function checkpointBasisSnapshot(request: JsonObject): JsonObject { const identity = requireJsonObject(request.identity, "identity"); const facts = requireJsonObject(request.facts, "facts"); if (!Array.isArray(facts.todos)) throw new EffectRuntimeRequestError("todos must be complete source records"); @@ -45,8 +57,23 @@ function snapshot(request: JsonObject): JsonObject { const todoId = identity.todo_id; const task = typeof todoId === "string" ? byId.get(todoId) : null; if (typeof todoId === "string" && !task) throw new EffectRuntimeRequestError("checkpoint Todo is absent; restore its authoritative record before rereading"); + let frontier: JsonObject[] | null = null; + if (purpose(request) === "first_delivery") { + const scope = request.decision_scope; + if (scope !== "agent_lane" && scope !== "goal") { + throw new EffectRuntimeRequestError("first delivery requires agent_lane or goal decision scope"); + } + const agent = requireNonEmptyString(identity.agent_id, "agent_id"); + // Recompute the complete membership. The caller cannot omit new work. + frontier = records.filter(todo => scope === "goal" || todo.role === "user" || + claimAllowsAgent({claim: typeof todo.claimed_by === "string" ? todo.claimed_by : null, + excluded: ids(todo.excluded_agents, "excluded_agents")}, agent)); + frontier = frontier.map(todo => ({todo, key: String(todo.todo_id ?? canonicalAuthoritySha256(todo))})) + .sort((a, b) => a.key.localeCompare(b.key)).map(({todo}) => todo); + } const selected = new Map(); - const pending = [...ids(request.dependency_todo_ids, "dependency_todo_ids"), ...(task ? dependencies(task) : [])]; + const pending = [...ids(request.dependency_todo_ids, "dependency_todo_ids"), + ...(task ? dependencies(task) : []), ...(frontier ?? []).flatMap(dependencies)]; while (pending.length) { const id = pending.shift()!; if (selected.has(id) || id === todoId) continue; @@ -69,6 +96,12 @@ function snapshot(request: JsonObject): JsonObject { agent_vision: facts.agent_vision, source: facts.source, }; + if (frontier !== null) { + basis.frontier = {scope: request.decision_scope, todos: frontier}; + } + if (purpose(request) !== "supplement_checkpoint") { + basis.execution_lease = facts.execution_lease ?? null; + } return {basis, provider_revision: facts.provider_revision ?? null, versions: Object.fromEntries(Object.entries(basis).map(([key, value]) => [key, canonicalAuthoritySha256(value)]))}; @@ -77,23 +110,43 @@ function snapshot(request: JsonObject): JsonObject { const rejected = (code: string, changed: string[] = []): JsonObject => ({ ok: false, error_code: code, reread_required: true, changed_components: changed, error: `${code}: run checkpoint-context for the same Goal/Agent/Todo or obligation/Turn, ` + - "read the returned state and judge again; submit its --checkpoint-read-context with only the vision decision. " + - "Do not repeat implementation, state mutations, or quota spend.", + "using the original purpose. Recheck the candidate for delivery_result, or judge the direction for first_delivery/supplement_checkpoint. " + + "Use the returned identity only with that new decision. Retain successful result and quota receipts.", }); export function evaluateCheckpointReadContext(value: unknown): JsonObject { const request = requireJsonObject(value, "checkpoint read context"); const identity = requireJsonObject(request.identity, "identity"); const token = request.read_context_id; + const use = purpose(request); if (request.phase === "read") { - const prior = requireJsonObject(request.prior, "committed writeback"); - const checkpoint = jsonObject(prior.vision_checkpoint); - if (checkpoint?.decision !== "missing_required" || checkpoint.satisfied !== false) { - return rejected("checkpoint_context_not_missing"); + if (use !== "supplement_checkpoint") { + if (jsonObject(request.receipt)?.commit_attempt != null) { + return {...rejected("checkpoint_commit_unknown"), reread_required: false, + error: "A first delivery append may have started; inspect the original Turn and artifacts before another judgment."}; + } + if (request.prior != null || request.admitted_turn !== true) { + return rejected("checkpoint_first_delivery_not_admitted"); + } + } else { + const prior = requireJsonObject(request.prior, "committed writeback"); + const checkpoint = jsonObject(prior.vision_checkpoint); + if (checkpoint?.decision !== "missing_required" || checkpoint.satisfied !== false) { + return rejected("checkpoint_context_not_missing"); + } + } + const projected = checkpointBasisSnapshot(request); + const task = jsonObject(requireJsonObject(projected.basis, "basis").todo); + if (use === "delivery_result" && task?.status === "done" && + [identity.effect_id, identity.turn_instance_id].includes(task.completion_turn_key)) { + return {...rejected("delivery_result_already_committed"), reread_required: false, + original_read_context_id: jsonObject(request.receipt)?.read_context_id ?? null, + error: "This Turn's result is already committed. Keep its original read identity for replay and read first_delivery for the pending direction."}; } - const projected = snapshot(request); return {ok: true, ...projected, receipt: { - schema_version: RECEIPT_SCHEMA, read_context_id: requireNonEmptyString(token, "read_context_id"), + schema_version: use !== "supplement_checkpoint" ? FIRST_RECEIPT_SCHEMA : RECEIPT_SCHEMA, + ...(use !== "supplement_checkpoint" ? {purpose: use, decision_scope: request.decision_scope} : {}), + read_context_id: requireNonEmptyString(token, "read_context_id"), identity, dependency_todo_ids: ids(request.dependency_todo_ids, "dependency_todo_ids"), versions: projected.versions, provider_revision: projected.provider_revision, }}; @@ -101,16 +154,28 @@ export function evaluateCheckpointReadContext(value: unknown): JsonObject { if (request.phase !== "check") throw new EffectRuntimeRequestError("unknown checkpoint read context phase"); if (typeof token !== "string" || !token.trim()) return rejected("checkpoint_read_context_required"); const receipt = jsonObject(request.receipt); - if (receipt?.schema_version !== RECEIPT_SCHEMA || receipt.read_context_id !== token) { + if (receipt?.schema_version !== (use !== "supplement_checkpoint" ? FIRST_RECEIPT_SCHEMA : RECEIPT_SCHEMA) || + receipt.read_context_id !== token || + (use !== "supplement_checkpoint" && receipt.purpose !== use)) { return rejected("checkpoint_read_context_unknown_or_replaced"); } if (canonicalAuthoritySha256(receipt.identity) !== canonicalAuthoritySha256(identity)) { return rejected("checkpoint_read_context_identity_mismatch"); } - const projected = snapshot({...request, dependency_todo_ids: receipt.dependency_todo_ids}); + if (use === "first_delivery" && request.decision_scope != null && + request.decision_scope !== receipt.decision_scope) { + return rejected("checkpoint_read_context_scope_mismatch"); + } + if (use === "first_delivery" && receipt.commit_attempt != null) { + return {...rejected("checkpoint_commit_unknown"), reread_required: false, + error: "The original direction append is uncertain; read back its original Turn before retrying."}; + } + const projected = checkpointBasisSnapshot({...request, dependency_todo_ids: receipt.dependency_todo_ids, + ...(use === "first_delivery" ? {decision_scope: receipt.decision_scope} : {})}); const expected = requireJsonObject(receipt.versions, "receipt versions"); const current = requireJsonObject(projected.versions, "current versions"); const changed = Object.keys(current).filter(key => current[key] !== expected[key]); if (changed.length) return rejected("checkpoint_read_context_stale", changed); - return {ok: true, read_context_id: token, versions: current}; + return {ok: true, read_context_id: token, versions: current, + ...(use !== "supplement_checkpoint" ? {purpose: use, decision_scope: receipt.decision_scope} : {})}; } diff --git a/loopx/control_plane/host_adapter_settlement.py b/loopx/control_plane/host_adapter_settlement.py index df57273faf..cd0c58e16c 100644 --- a/loopx/control_plane/host_adapter_settlement.py +++ b/loopx/control_plane/host_adapter_settlement.py @@ -30,6 +30,8 @@ "durable_writeback", "quota_spend", "terminal_closeout", + "delivery_context", + "direction_context", ) _MISSING = object() @@ -62,6 +64,8 @@ class HostTodoSettlementRequest: vision_path: str | None = None vision_unchanged_reason: str | None = None checkpoint_read_context_id: str | None = None + first_delivery: bool = False + delivery_read_context_id: str | None = None goal_ref: Mapping[str, str] | None = None @@ -105,13 +109,16 @@ def _request_payload( payload["provider_outcomes"] = provider_outcomes if request.goal_ref is not None: payload["goal_ref"] = dict(request.goal_ref) - if request.vision_path or request.vision_unchanged_reason or phase in {"vision_refresh", "vision_context"}: + if request.vision_path or request.vision_unchanged_reason or phase in {"vision_refresh", "vision_context"} or request.first_delivery: payload.update( schema_version="loopx_host_todo_completion_transaction_v1", vision_path=request.vision_path, vision_unchanged_reason=request.vision_unchanged_reason, checkpoint_read_context_id=request.checkpoint_read_context_id, ) + if request.first_delivery: + payload.update(schema_version="loopx_host_todo_completion_transaction_v2", first_delivery=True, + delivery_read_context_id=request.delivery_read_context_id) return payload @@ -272,10 +279,12 @@ def _decode_provider_steps(value: Any) -> tuple[_ProviderStep, ...]: value.get("provider_id") != "loopx_cli" or value.get("kind") != "ordered_cli_sequence" or not isinstance(raw_steps, list) - or len(raw_steps) not in {4, 5} + or len(raw_steps) not in {2, 3, 4, 5} ): raise RuntimeError("TypeScript host provider plan shape mismatch") - expected = _STEP_KINDS[: len(raw_steps)] + expected = (("guard", "delivery_context") if len(raw_steps) == 2 else + ("guard", "lifecycle_completion", "direction_context") if len(raw_steps) == 3 else + _STEP_KINDS[: len(raw_steps)]) steps: list[_ProviderStep] = [] for index, raw in enumerate(raw_steps): if not isinstance(raw, Mapping) or raw.get("step_kind") != expected[index]: diff --git a/loopx/control_plane/quota/refresh_recovery.ts b/loopx/control_plane/quota/refresh_recovery.ts index b78e6f27b1..a29aecf960 100644 --- a/loopx/control_plane/quota/refresh_recovery.ts +++ b/loopx/control_plane/quota/refresh_recovery.ts @@ -8,6 +8,7 @@ import { normalizeDeliveryWorkspaceSnapshot } from "../agents/delivery_workspace import { decodeExternalDelivery, type ExternalDeliveryRequest } from "./refresh_external_delivery.ts"; export interface RefreshRetryRequest { + first_delivery_request?: JsonObject | null; checkpoint_read_context_id?: string | null; external_delivery?: ExternalDeliveryRequest | null; vision: JsonObject | null; @@ -38,6 +39,8 @@ export function decodeRefreshRetry(value: unknown): RefreshRetryRequest | null { return value; }; return { + first_delivery_request: input.first_delivery_request == null ? null + : requireJsonObject(input.first_delivery_request, "first_delivery_request"), checkpoint_read_context_id: input.checkpoint_read_context_id == null ? null : nullableString("checkpoint_read_context_id"), external_delivery: decodeExternalDelivery(input.external_delivery), vision: input.vision === null ? null : requireJsonObject(input.vision, "refresh_retry.vision"), @@ -82,17 +85,24 @@ export function refreshRecovery( ...(request.checkpoint_read_context_id ? {checkpoint_read_context_id: request.checkpoint_read_context_id} : {}), })).digest("hex") : null; const mutationDigest = createHash("sha256").update(canonical(request.mutation)).digest("hex"); + const firstDigest = request.first_delivery_request == null ? null + : createHash("sha256").update(canonical(request.first_delivery_request)).digest("hex"); const changesMutation = Object.values(request.mutation).some((value) => value !== null && value !== false && (!Array.isArray(value) || value.length > 0)); const result = (decision: Decision, reason: string): JsonObject => ({ schema_version: "refresh_recovery_v0", decision, reason, vision_request_digest: digest, mutation_digest: mutationDigest, + ...(firstDigest ? {first_delivery_request_digest: firstDigest} : {}), ...(request.checkpoint_read_context_id ? {checkpoint_read_context_id: request.checkpoint_read_context_id} : {}), original_generated_at: jsonObject(prior?.refresh_recovery)?.original_generated_at ?? prior?.generated_at ?? null, }); if (!prior) return result("append", "first_writeback"); + const priorFirstDigest = jsonObject(prior.refresh_recovery)?.first_delivery_request_digest ?? null; + if ((firstDigest !== null || priorFirstDigest !== null) && firstDigest !== priorFirstDigest) { + return result("reject", "committed_first_delivery_request_conflict"); + } const checkpoint = jsonObject(prior.vision_checkpoint); const priorDigest = jsonObject(prior.refresh_recovery)?.vision_request_digest; // Repeated CLI annotations are not mutations. Semantic delivery changes are. diff --git a/loopx/control_plane/quota/settlement.py b/loopx/control_plane/quota/settlement.py index fb63112ab8..da324c4c2c 100644 --- a/loopx/control_plane/quota/settlement.py +++ b/loopx/control_plane/quota/settlement.py @@ -180,6 +180,14 @@ def attach_settlement_progress( if not isinstance(progress, dict) or progress.get("schema_version") != "quota_settlement_progress_v0": raise RuntimeError("TypeScript quota settlement progress missing or invalid") payload["settlement_progress"] = dict(progress) + if runtime_root is not None: + from ..goals.checkpoint_context_io import first_delivery_progress + + delivery = first_delivery_progress(runtime_root, readback) + if delivery is not None: + payload["first_delivery_progress"] = delivery + if delivery["stage"] in {"direction_pending", "operation_unknown"}: + payload["recommended_action"] = delivery["next_action"] payload.pop("settlement_owed", None) identity = readback.identity.value if payload.get("ok") is not True or progress.get("next_step") != "quota_spend" or identity is None: @@ -218,6 +226,10 @@ def render_settlement_progress_markdown(payload: dict[str, Any]) -> list[str]: if not isinstance(progress, dict): return [] lines = [f"- settlement: `{progress.get('state')}`"] + delivery = payload.get("first_delivery_progress") + if isinstance(delivery, dict): + lines.extend([f"- first delivery: `{delivery['stage']}`; result committed: {delivery['result_committed']}", + f"- next: {delivery['next_action']}"]) if progress.get("closeout_kind") == "typed_blocked_writeback_no_spend": lines.append("- closeout: typed blocked writeback; no quota slot spent") owed = payload.get("settlement_owed") diff --git a/loopx/control_plane/status/collection.py b/loopx/control_plane/status/collection.py index da2bfe5240..6336470e30 100644 --- a/loopx/control_plane/status/collection.py +++ b/loopx/control_plane/status/collection.py @@ -287,6 +287,9 @@ def collect_status( ) attach_goal_acceptance_observations(payload, history=history) attach_goal_artifact_lifecycle_projections(payload, history=history) + from .first_delivery import attach_first_delivery_status + + attach_first_delivery_status(payload, runtime_root=runtime_root, agent_id=agent_lane_id) payload["projection_envelope"] = seal_projection_envelope( projection="status", observed_at=now_utc_iso(), diff --git a/loopx/control_plane/status/first_delivery.py b/loopx/control_plane/status/first_delivery.py new file mode 100644 index 0000000000..76b6f0c15f --- /dev/null +++ b/loopx/control_plane/status/first_delivery.py @@ -0,0 +1,25 @@ +"""Expose enrolled Turn progress through existing status consumer fields.""" +from pathlib import Path +from typing import Any + +from ..goals.checkpoint_context_io import pending_first_delivery_progress +from ..quota.states import quota_item_is_paused + + +def attach_first_delivery_status(payload: dict[str, Any], *, runtime_root: Path, agent_id: str | None) -> None: + for item in payload.get("attention_queue", {}).get("items", []): + goal_id = item.get("goal_id") + if not goal_id: + continue + progress = pending_first_delivery_progress(runtime_root, goal_id, agent_id) + if progress is None: + continue + item["first_delivery_progress"] = progress + if quota_item_is_paused(item) or item.get("requires_user_action") is True: + continue + action = progress["next_action"] + item["recommended_action"] = action + for field in ("project_asset", "goal_channel_projection"): + projection = item.get(field) + if isinstance(projection, dict): + projection["next_action"] = action diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index fb70bc5f08..2f6288c611 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -129,6 +129,9 @@ def _route_terminal_call(command: str, call: Mapping[str, Any]) -> dict[str, Any call.get("completion_identity_source") if complete else None ), review_basis=call.get("terminal_review_basis"), + delivery_read_context_id=call.get("delivery_read_context_id"), + delivery_direction_context_id=call.get("delivery_direction_context_id"), + delivery_settlement_identity=call.get("delivery_settlement_identity"), completion_delivery_workspace=( call.get("completion_delivery_workspace") if complete else None ), @@ -300,6 +303,9 @@ def terminal_canonical_todo_if_promoted( self_merged: bool, dry_run: bool, review_basis: Mapping[str, Any] | None = None, + delivery_read_context_id: str | None = None, + delivery_direction_context_id: str | None = None, + delivery_settlement_identity: Mapping[str, Any] | None = None, project: Path | None = None, state_file: Path | None = None, ) -> dict[str, Any] | None: @@ -323,6 +329,8 @@ def terminal_canonical_todo_if_promoted( str(exc), code=exc.code, payload=payload ) from exc if canonical is None: + if delivery_read_context_id is not None: + raise ValueError("delivery freshness requires File or SQLite canonical authority") return None todos = [dict(todo) for todo in canonical["todos"]] # The canonical transaction owns missing/role/archive lifecycle decisions. @@ -431,10 +439,37 @@ def terminal_canonical_todo_if_promoted( "dry_run": dry_run, "observed_at": now_local(), } - result = effect_runtime_result( - "coordination.local_authority.todo_terminal", request, - timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS, - ) + def invoke() -> Any: + if (delivery_read_context_id is not None or delivery_direction_context_id is not None) and delivery_settlement_identity is None: + raise ValueError("delivery read context requires the original settlement identity") + if delivery_settlement_identity is not None: + from ..goals.checkpoint_context_io import delivery_result_context_input + from ..quota.settlement import SettlementIdentity + + if state_file is None: + raise ValueError("delivery freshness requires the registered state file") + context = delivery_result_context_input( + runtime_root=runtime_root, registry_path=registry_path, state_file=state_file, + identity=SettlementIdentity.from_runtime_payload(delivery_settlement_identity), + read_context_id=delivery_read_context_id, + direction_read_context_id=delivery_direction_context_id, + ) + if context is not None: + request["delivery_context"] = context + for capture_attempt in range(3): + outcome = effect_runtime_result("coordination.local_authority.todo_terminal", request, + timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS) + if not isinstance(outcome, Mapping) or outcome.get("reason_code") != "delivery_source_capture_changed" or capture_attempt == 2: + return outcome + # Recapture IO, retaining the original Agent decision and read ID. + request["delivery_context"] = delivery_result_context_input( + runtime_root=runtime_root, registry_path=registry_path, state_file=state_file, + identity=SettlementIdentity.from_runtime_payload(delivery_settlement_identity), + read_context_id=delivery_read_context_id, + direction_read_context_id=delivery_direction_context_id, + ) + + result = invoke() if isinstance(result, Mapping) and result.get("status") == "resolve_validation": # Admission and receipt recovery precede host-local declaration IO. # Resolving private argv grants no authority to run it; re-enter the @@ -447,10 +482,7 @@ def terminal_canonical_todo_if_promoted( registry_path=registry_path, goal_id=goal_id, todo_id=todo_id, role=role, persist_if_resolved=not dry_run, ) - result = effect_runtime_result( - "coordination.local_authority.todo_terminal", request, - timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS, - ) + result = invoke() completion_validation_executed = False if isinstance(result, Mapping) and result.get("status") == "execute_validation": request["validation_source_provider_revision"] = result["provider_revision"] @@ -466,10 +498,7 @@ def terminal_canonical_todo_if_promoted( ) completion_validation_executed = True request["observed_at"] = now_local() - result = effect_runtime_result( - "coordination.local_authority.todo_terminal", request, - timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS, - ) + result = invoke() if not isinstance(result, Mapping): raise LocalCoordinationAuthorityUnavailable( "canonical Todo terminal transaction returned an invalid result", diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index 5442e89dd5..1ed1e74e4c 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -192,6 +192,14 @@ def _has_subagent_topology(request: Mapping[str, Any] | None) -> bool: def codex_cli_result_schema( request: Mapping[str, Any] | None = None, ) -> dict[str, Any]: + if request is not None and request.get("direction_review") is not None: + properties = { + "read_context_id": {"type": "string"}, + "decision": {"type": "string", "enum": ["continue", "revalidate_result", "terminal_ready"]}, + "agent_vision_json": {"type": "string", "maxLength": HOST_AGENT_VISION_JSON_MAX_CHARS}, + "vision_unchanged_reason": {"type": "string", "maxLength": 240}, + } + return {"type": "object", "properties": properties, "required": list(properties), "additionalProperties": False} text_limits = dict(HOST_RESULT_TEXT_LIMITS) properties: dict[str, Any] = { "schema_version": { @@ -276,6 +284,12 @@ def _prompt(request: Mapping[str, Any]) -> str: request_json = json.dumps( request, ensure_ascii=False, sort_keys=True, separators=(",", ":") ) + if request.get("direction_review") is not None: + return ("Review only the current direction basis in direction_review. Implementation has already run. " + "Do not execute work, write files, call external effects, or settle quota. Return only the direction schema. " + "Echo the exact read_context_id. Use revalidate_result if current requirements invalidate the prepared result. " + "Otherwise author a Vision in agent_vision_json or an unchanged reason, leaving the other string empty. " + "Pending tasks remain pending even when a selected result was committed.\n" + request_json) instructions = [ "Execute exactly one bounded LoopX Turn in the current workspace.", "Use the TurnEnvelope as the source of truth. Perform work only when its contract allows it.", @@ -652,6 +666,9 @@ def run_codex_cli_host( ) -> dict[str, Any]: if request.get("schema_version") != LOOPX_TURN_HOST_REQUEST_SCHEMA_VERSION: raise ValueError("unsupported LoopX Turn host request schema") + direction_only = request.get("direction_review") is not None + if direction_only: + sandbox, mcp_server = "read-only", None if sandbox not in CODEX_CLI_SANDBOXES: raise ValueError(f"Codex CLI sandbox must be one of {CODEX_CLI_SANDBOXES}") if reasoning_effort is not None: @@ -662,11 +679,11 @@ def run_codex_cli_host( raise ValueError("Codex CLI executable is unavailable") lineage = _lineage(request) planned_session = _mapping(request.get("session")) - planned_action = str(planned_session.get("action") or "") + planned_action = "start_new" if direction_only else str(planned_session.get("action") or "") context_policy = _mapping(planned_session.get("context_policy")) if context_policy.get("mode") is not None and context_policy["mode"] not in SUPPORTED_ITERATION_CONTEXT_POLICIES: raise ValueError("iteration context policy must be fresh or resume") - fresh_iteration = context_policy.get("mode") == "fresh" + fresh_iteration = direction_only or context_policy.get("mode") == "fresh" session_scope = str(context_policy.get("binding_scope") or "todo") if fresh_iteration and goal_admission is not None: goal_admission.require_current() @@ -709,6 +726,8 @@ def run_codex_cli_host( exact_goal_ref = dict(goal_ref) if isinstance(goal_ref, Mapping) else None def store_session(observed_session_id: str) -> None: + if direction_only: + return def commit() -> None: _store_codex_cli_session( runtime_root, diff --git a/loopx/control_plane/turn_driver/execution_readback.py b/loopx/control_plane/turn_driver/execution_readback.py index 34b35338ca..a1df802e36 100644 --- a/loopx/control_plane/turn_driver/execution_readback.py +++ b/loopx/control_plane/turn_driver/execution_readback.py @@ -9,6 +9,7 @@ from .host_failure import project_host_failure from .lane_fence import turn_lane_in_flight_projection from .transaction import LOOPX_TURN_EXECUTION_SCHEMA_VERSION +from ..effect_runtime import effect_runtime_result def _mapping(value: Any) -> dict[str, Any]: @@ -34,6 +35,16 @@ def execution_payload( journal.get("completed_phases") or [] ) recovery = journal.get("recovery_audit") + delivery = {} + if plan.get("first_delivery_freshness"): + phases = list(journal.get("completed_phases") or []) + write_attempt = _mapping(_mapping(journal.get("effect_attempts")).get("durable_writeback")) + delivery["first_delivery_progress"] = effect_runtime_result("turn.first_delivery.evaluate", { + "phase": "project", "result_committed": _mapping(journal.get("delivery_completion")).get("ok") is True or bool(todo_completion.get("completed")), + "direction_committed": "durable_writeback" in phases, + "unknown": write_attempt.get("status") == "prepared" and "durable_writeback" not in phases, + "quota_spent": quota_spent, "settlement_complete": journal.get("status") == "committed", + }) return { "ok": journal.get("status") in { @@ -64,6 +75,7 @@ def execution_payload( "scheduler": journal.get("scheduler"), **subagent.subagent_execution_payload_projection(journal), "effects": dict(effects), + **delivery, **({"admission": dict(journal["admission"])} if isinstance(journal.get("admission"), Mapping) else {}), "quota_slot_spend_count": 1 if quota_spent else 0, diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index f70f313efc..2636681076 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -791,6 +791,7 @@ def _host_result_stage( confirm_start: Callable[[], None] | None = None, usage_runtime_root: Path | None = None, usage_goal_id: str = "", + first_delivery_context: Callable[[str], dict[str, Any]] | None = None, ) -> tuple[dict[str, Any] | None, list[str], dict[str, Any] | None]: completed_phases = list(journal.get("completed_phases") or []) result = ( @@ -799,6 +800,10 @@ def _host_result_stage( else None ) if "typed_result" not in completed_phases: + if first_delivery_context is not None: + context = first_delivery_context("delivery_result") + journal["delivery_result_context"] = {key: context[key] for key in ("read_context_id", "versions", "settlement_identity")} + request = {**request, "delivery_result_context": context} journal["host_attempt_count"] = int(journal.get("host_attempt_count") or 0) + 1 persist_journal(journal) from ...extensions.codex_native_child import configured_native_child_limit @@ -1088,7 +1093,7 @@ def _typed_settlement_stage( ) ) - def writeback_effect(effect_ref: str) -> Mapping[str, Any]: + def perform_writeback(effect_ref: str) -> Mapping[str, Any]: if completion_intent_error: return { "ok": False, @@ -1106,6 +1111,18 @@ def writeback_effect(effect_ref: str) -> Mapping[str, Any]: return invoke_result_effect(completion_writeback, result, effect_ref) return invoke_result_effect(writeback, result, effect_ref) + def writeback_effect(effect_ref: str) -> Mapping[str, Any]: + from ..goals.checkpoint_context_io import CheckpointReadContextRejected + try: + return perform_writeback(effect_ref) + except CheckpointReadContextRejected as error: + if error.code in {"checkpoint_read_context_stale", "checkpoint_read_context_unknown_or_replaced"}: + reviews = journal.get("direction_reviews") + if isinstance(reviews, list) and reviews: + reviews[-1].update(decision=None, rejected=error.code) + persist_journal(journal) + return {"ok": False, "appended": False, "reason": str(error), **error.payload} + journal_adapter = TurnSettlementJournalAdapter( journal=journal, effects=effects, @@ -1266,6 +1283,45 @@ def persist_tail_checkpoint(*, release: bool) -> None: ) +def _first_delivery_stage( + *, plan: Mapping[str, Any], request: Mapping[str, Any], result: dict[str, Any], + journal: dict[str, Any], completed_phases: list[str], persist_journal: Callable[..., None], + effects: Mapping[str, Any], first_delivery_context: Callable[[str], dict[str, Any]] | None, + first_delivery_completion: CompletionWriteback | None, completion_intent: CompletionIntent | None, + runtime_root: Path, goal_id: str, host_runner: HostRunner | None, + argv: Sequence[str] | None, project: Path, timeout_seconds: float, +) -> tuple[dict[str, Any], dict[str, Any] | None]: + """Run the protected result/direction stage, preserving settlement ordering.""" + if first_delivery_context is not None and "durable_writeback" not in completed_phases: + from .first_delivery import prepare_first_delivery + from ...usage_goal import observe_goal_execution + + try: + def invoke_direction(host_request: Mapping[str, Any]) -> dict[str, Any]: + with observe_goal_execution(runtime_root, goal_id, + host=str((plan.get("host") or {}).get("kind") or "unknown")): + return (_run_host_runner(host_request, runner=host_runner) if host_runner is not None + else _run_host(host_request, argv=argv or [], project=project, timeout_seconds=timeout_seconds)) + + result = prepare_first_delivery(plan=plan, request=request, result=result, journal=journal, + persist=persist_journal, read_context=first_delivery_context, invoke_host=invoke_direction, + completion_intent=completion_intent, commit_result=first_delivery_completion) + except (ValueError, OSError) as error: + failure = _host_failure(plan, kind=LoopXTurnResultKind.WRITEBACK_FAILED, + completed_phases=completed_phases, failed_phase="durable_writeback", reason=str(error)) + journal.update(status="failed", reason=str(error), result_kind=LoopXTurnResultKind.WRITEBACK_FAILED.value, + receipt=failure["receipt"], completed_phases=completed_phases, + validation_stage="first_delivery_direction") + persist_journal(journal) + return result, execution_payload(plan, journal, execute=True, replayed=False, effects=effects) + + if first_delivery_context is not None and "durable_writeback" in completed_phases: + result = {**result, "delivery_read_context_id": journal["delivery_result_context"]["read_context_id"], + "checkpoint_read_context_id": journal["direction_reviews"][-1]["read_context_id"], "first_delivery": True} + + return result, None + + @single_executor_per_turn_lane(execution_payload) def run_loopx_turn_once( plan: Mapping[str, Any], @@ -1293,7 +1349,13 @@ def run_loopx_turn_once( admit_start: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, confirm_start: Callable[[], None] | None = None, goal_admission: FirstPartyHostGoalAdmission | None = None, + first_delivery_context: Callable[[str], dict[str, Any]] | None = None, + first_delivery_completion: CompletionWriteback | None = None, ) -> dict[str, Any]: + if plan.get("first_delivery_freshness") and first_delivery_context is None: + raise ValueError("Protected Turn recovery requires its first delivery adapter") + if first_delivery_context is not None and goal_admission is not None and goal_admission.enabled: + raise ValueError("First delivery inference cannot run inside source-session effect admission") if host_runner is not None and host_argv is not None: raise ValueError("run-once accepts either host_argv or host_runner, not both") if host_runner is None: @@ -1528,6 +1590,7 @@ def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: host_runner=host_runner, usage_runtime_root=runtime_root, usage_goal_id=goal_id, + first_delivery_context=first_delivery_context, argv=argv, completion_lifecycle_configured=all( callback is not None @@ -1564,6 +1627,16 @@ def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: if terminal is not None: return finish_recovery(terminal) + result, terminal = _first_delivery_stage( + plan=plan, request=request, result=result, journal=journal, completed_phases=completed_phases, + persist_journal=persist_journal, effects=effects, first_delivery_context=first_delivery_context, + first_delivery_completion=first_delivery_completion, completion_intent=completion_intent, + runtime_root=runtime_root, goal_id=goal_id, host_runner=host_runner, + argv=argv, project=project, timeout_seconds=timeout_seconds, + ) + if terminal is not None: + return finish_recovery(terminal) + settled = _typed_settlement_stage( plan, result, diff --git a/loopx/control_plane/turn_driver/first_delivery.py b/loopx/control_plane/turn_driver/first_delivery.py new file mode 100644 index 0000000000..c34bedf04e --- /dev/null +++ b/loopx/control_plane/turn_driver/first_delivery.py @@ -0,0 +1,101 @@ +"""Bounded direction inference using the existing Turn journal and Host IO. + +Todo/run receipts remain the commit authority. This module records which read +was delivered to which inference, and never manufactures a fresh token for a +previous output. The journal's lane lock serializes inference; source and +provider locks are released before the Host is called. +""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any + +from ..effect_runtime import effect_runtime_result +from .journal_store import journal_committed_effect_id +from .settlement import invoke_result_effect, terminal_closeout_requirement + + +MAX_DIRECTION_ATTEMPTS = 2 + + +def prepare_first_delivery( + *, plan: Mapping[str, Any], request: Mapping[str, Any], result: dict[str, Any], + journal: dict[str, Any], persist: Callable[[Mapping[str, Any]], None], + read_context: Callable[[str], dict[str, Any]], + invoke_host: Callable[[Mapping[str, Any]], dict[str, Any]], + completion_intent: Callable[..., dict[str, Any]] | None, + commit_result: Callable[..., dict[str, Any]] | None, +) -> dict[str, Any]: + """Preserve native result/terminal ordering before bounded direction review.""" + original = journal.get("delivery_result_context") + if not isinstance(original, dict): + raise ValueError("Protected Turn has no result read identity; do not attach a new read to the previous result.") + result = {**result, "delivery_read_context_id": original["read_context_id"]} + terminal_needed = False + if result.get("result_kind") in {"repair_required", "replan_required"}: + raise ValueError("First delivery does not support compound repair or replan mutations; retain the candidate for explicit recovery.") + if result.get("result_kind") == "validated_completion": + if completion_intent is None or commit_result is None: + raise ValueError("Protected completion adapter is unavailable") + terminal_needed, intent_error = terminal_closeout_requirement( + plan=plan, result=result, journal=journal, completion_intent=completion_intent) + if intent_error: + raise ValueError(intent_error) + if not terminal_needed: + completion = invoke_result_effect(commit_result, result, + f"{journal_committed_effect_id(journal)}#durable_writeback") + if completion.get("ok") is not True: + raise ValueError(str(completion.get("reason") or "Protected result commit failed")) + journal["delivery_completion"] = dict(completion) + persist(journal) + result["_delivery_completion"] = dict(completion) + result = review_first_delivery(request=request, result=result, journal=journal, + persist=persist, read_context=read_context, invoke_host=invoke_host) + if terminal_needed and result.get("_direction_decision") != "terminal_ready": + raise ValueError("Current direction does not authorize the requested no-followup closeout; retain the original Turn and resolve the remaining work.") + return result + + +def direction_host_request(request: Mapping[str, Any], context: Mapping[str, Any], result: Mapping[str, Any]) -> dict[str, Any]: + return {**request, "direction_review": { + "context": dict(context), + "candidate_result": {key: result.get(key) for key in ( + "result_kind", "summary", "classification", "delivery_outcome", "next_action")}, + "instructions": "Judge the direction from this current basis. Do not execute implementation, Todo writes, quota spend or external effects. " + "Return read_context_id, decision (continue, revalidate_result, terminal_ready), and exactly one agent_vision or vision_unchanged_reason. " + "Use revalidate_result when the current acceptance or dependency invalidates the prepared result. A committed result is retained; pending work is not Goal completion.", + }} + + +def review_first_delivery( + *, request: Mapping[str, Any], result: dict[str, Any], journal: dict[str, Any], + persist: Callable[[Mapping[str, Any]], None], read_context: Callable[[str], dict[str, Any]], + invoke_host: Callable[[Mapping[str, Any]], dict[str, Any]], +) -> dict[str, Any]: + attempts = journal.setdefault("direction_reviews", []) + current = attempts[-1] if attempts else None + if current is None or current.get("decision") is None: + if len(attempts) >= MAX_DIRECTION_ATTEMPTS: + raise ValueError("Direction review budget exhausted; retain this Turn and its receipts for operator recovery.") + context = read_context("first_delivery") + current = {"read_context_id": context["read_context_id"], "versions": context["versions"], + "purpose": "first_delivery", "attempt": len(attempts) + 1, "response": None} + attempts.append(current) + persist(journal) + observation = invoke_host(direction_host_request(request, context, result)) + current["host_observation"] = {key: observation.get(key) for key in ("ok", "reason", "returncode")} + persist(journal) + if observation.get("ok") is not True: + raise ValueError("Direction Host failed; result receipts remain committed and quota has not been spent.") + decision = effect_runtime_result("turn.first_delivery.evaluate", { + "response": observation["value"], "read_context_id": context["read_context_id"], + }) + current.update(decision=decision["decision"], response=decision) + persist(journal) + if current["decision"] == "revalidate_result": + raise ValueError("Current direction requires result revalidation; keep the original candidate and Turn identity.") + response = current["response"] + return {**result, "agent_vision": response["agent_vision"], + "vision_unchanged_reason": response["vision_unchanged_reason"], + "checkpoint_read_context_id": response["read_context_id"], "first_delivery": True, + "_direction_decision": response["decision"]} diff --git a/loopx/control_plane/turn_driver/first_delivery.ts b/loopx/control_plane/turn_driver/first_delivery.ts new file mode 100644 index 0000000000..52f5e7c94e --- /dev/null +++ b/loopx/control_plane/turn_driver/first_delivery.ts @@ -0,0 +1,40 @@ +/** Direction-only Host response. The original result remains immutable. */ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {normalizeVisionUnchangedReason} from "../goals/vision_checkpoint.ts"; + +export function evaluateFirstDelivery(value: unknown): JsonObject { + const request = requireJsonObject(value, "first delivery"); + if (request.phase === "project") { + const stage = request.unknown === true ? "operation_unknown" + : request.direction_committed !== true ? "direction_pending" + : request.settlement_complete === true ? "settled" : "settlement_pending"; + return {schema_version: "first_delivery_progress_v0", stage, + result_committed: request.result_committed === true, + direction_committed: request.direction_committed === true, + quota_spent: request.quota_spent === true, + goal_completion_certified: false, + next_action: stage === "operation_unknown" ? "Read back the original Turn and its run artifacts; retain its identity." + : stage === "direction_pending" ? "Resume the original Turn or read first_delivery context and judge the current direction. Retain the committed result." + : stage === "settlement_pending" ? "Resume the original Turn's remaining settlement; retain successful writeback and quota receipts." + : "Turn settlement is complete. Read the current Goal obligations before selecting further work."}; + } + const response = requireJsonObject(request.response, "direction response"); + if (Object.keys(response).some(key => !["read_context_id", "decision", "agent_vision", "agent_vision_json", "vision_unchanged_reason"].includes(key)) || + response.read_context_id !== requireNonEmptyString(request.read_context_id, "read context id")) { + throw new EffectRuntimeRequestError("Direction response must echo the delivered read identity and contain only direction fields."); + } + if (!["continue", "revalidate_result", "terminal_ready"].includes(String(response.decision))) { + throw new EffectRuntimeRequestError("Direction decision must be continue, revalidate_result or terminal_ready."); + } + if (response.agent_vision != null && response.agent_vision_json) throw new EffectRuntimeRequestError("Duplicate direction Vision payload."); + const rawVision = response.agent_vision ?? (response.agent_vision_json ? JSON.parse(String(response.agent_vision_json)) : null); + const vision = rawVision == null ? null : requireJsonObject(rawVision, "agent vision"); + const reason = normalizeVisionUnchangedReason(response.vision_unchanged_reason); + if ((vision === null) === (reason === null)) { + throw new EffectRuntimeRequestError("Direction response requires exactly one authored Vision or unchanged reason."); + } + return {read_context_id: response.read_context_id, decision: response.decision, + agent_vision: vision, vision_unchanged_reason: reason}; +} diff --git a/loopx/control_plane/turn_driver/host_todo_completion.ts b/loopx/control_plane/turn_driver/host_todo_completion.ts index 5dd272d20a..234c3c4f58 100644 --- a/loopx/control_plane/turn_driver/host_todo_completion.ts +++ b/loopx/control_plane/turn_driver/host_todo_completion.ts @@ -23,6 +23,7 @@ export const HOST_TODO_COMPLETION_TRANSACTION_SCHEMA_VERSION = "loopx_host_todo_completion_transaction_v0"; export const HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION = "loopx_host_todo_completion_transaction_v1"; +export const HOST_TODO_FIRST_DELIVERY_TRANSACTION_SCHEMA_VERSION = "loopx_host_todo_completion_transaction_v2"; export const HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION = "loopx_host_todo_completion_reduction_v0"; export const HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION = @@ -35,6 +36,8 @@ const STEP_KINDS = [ "durable_writeback", "quota_spend", "terminal_closeout", + "delivery_context", + "direction_context", ] as const; const TODO_ID_PATTERN = /^todo_[a-z0-9_-]{3,64}$/; @@ -56,6 +59,8 @@ interface HostTodoCompletionRequest { vision_path: string | null; vision_unchanged_reason: string | null; checkpoint_read_context_id: string | null; + first_delivery: boolean; + delivery_read_context_id: string | null; goal_instance_id: string | null; provider_outcomes: readonly ProviderOutcome[]; } @@ -82,7 +87,7 @@ interface GuardSelection extends JsonObject { function decodePhase(value: JsonObject): HostTodoCompletionPhase { requireStringLiteral( value.schema_version, - [HOST_TODO_COMPLETION_TRANSACTION_SCHEMA_VERSION, HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION] as const, + [HOST_TODO_COMPLETION_TRANSACTION_SCHEMA_VERSION, HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION, HOST_TODO_FIRST_DELIVERY_TRANSACTION_SCHEMA_VERSION] as const, "schema_version", "schema_version is unsupported", ); @@ -134,11 +139,15 @@ function decodeRequest( const visionPath = optionalText("vision_path"); const unchanged = normalizeVisionUnchangedReason(optionalText("vision_unchanged_reason")); const readContextId = optionalText("checkpoint_read_context_id"); + const firstDelivery = value.schema_version === HOST_TODO_FIRST_DELIVERY_TRANSACTION_SCHEMA_VERSION; + const resultContextId = optionalText("delivery_read_context_id"); + if ((value.first_delivery === true || resultContextId) && !firstDelivery) throw new EffectRuntimeRequestError("First delivery requires the v2 host contract."); + if (firstDelivery && value.first_delivery !== true) throw new EffectRuntimeRequestError("v2 requires explicit first_delivery opt-in."); if (visionPath && unchanged) { throw new EffectRuntimeRequestError("choose a vision patch or an unchanged reason, not both"); } if ((visionPath || unchanged || readContextId || phase === "vision_refresh" || phase === "vision_context") && - value.schema_version !== HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION) { + value.schema_version !== HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION && !firstDelivery) { throw new EffectRuntimeRequestError("host vision authoring requires v1"); } if (phase === "vision_refresh" && !visionPath && !unchanged) { @@ -147,7 +156,7 @@ function decodeRequest( if (phase === "vision_context" && (visionPath || unchanged || readContextId)) { throw new EffectRuntimeRequestError("vision context reads cannot submit a decision or receipt"); } - if (readContextId && phase !== "vision_refresh") { + if (readContextId && phase !== "vision_refresh" && !firstDelivery) { throw new EffectRuntimeRequestError("checkpoint read context belongs only to vision recovery"); } const goalId = requireNonEmptyString(value.goal_id, "goal_id"); @@ -194,6 +203,8 @@ function decodeRequest( vision_path: visionPath, vision_unchanged_reason: unchanged, checkpoint_read_context_id: readContextId, + first_delivery: firstDelivery, + delivery_read_context_id: resultContextId, goal_instance_id: parsedGoalRef?.kind === "parsed" ? parsedGoalRef.value.goalInstanceId.value : null, @@ -202,6 +213,9 @@ function decodeRequest( if (phase === "finalize") { request.provider_outcomes = decodeProviderOutcomes(value.provider_outcomes); } + if (firstDelivery && (visionPath || unchanged) && !readContextId) { + throw new EffectRuntimeRequestError("Read the post-result direction context before authoring Vision; an adapter cannot bind old Vision to a new read."); + } return request; } @@ -372,6 +386,7 @@ function writebackArgs(request: HostTodoCompletionRequest, identity: JsonObject) ...(request.vision_path ? ["--agent-vision-json", request.vision_path] : []), ...(request.vision_unchanged_reason ? ["--vision-unchanged-reason", request.vision_unchanged_reason] : []), ...(request.checkpoint_read_context_id ? ["--checkpoint-read-context", request.checkpoint_read_context_id] : []), + ...(request.first_delivery ? ["--first-delivery", "--progress-scope", "goal"] : []), ]; } @@ -401,6 +416,7 @@ function providerSteps( if (request.goal_instance_id) { lifecycleArgs.push("--goal-instance-id", request.goal_instance_id); } + if (request.delivery_read_context_id) lifecycleArgs.push("--delivery-read-context", request.delivery_read_context_id); const steps: ProviderStep[] = [ { step_kind: "guard", @@ -461,6 +477,8 @@ function providerSteps( step_kind: "terminal_closeout", args: [ ...request.completion_args, + ...(request.first_delivery ? ["--delivery-read-context", request.delivery_read_context_id ?? "missing", + "--direction-read-context", request.checkpoint_read_context_id ?? "missing"] : []), "--turn-instance-id", turnId, ...(request.goal_instance_id @@ -471,6 +489,15 @@ function providerSteps( continue_when: null, }); } + if (request.first_delivery && !request.checkpoint_read_context_id) { + const purpose = request.delivery_read_context_id ? "first_delivery" : "delivery_result"; + const context: ProviderStep = {step_kind: purpose === "delivery_result" ? "delivery_context" : "direction_context", + args: ["checkpoint-context", "--goal-id", request.goal_id, "--agent-id", request.agent_id, + "--todo-id", request.todo_id, "--turn-instance-id", turnId, "--purpose", purpose, "--decision-scope", "goal", + ...(request.goal_instance_id ? ["--goal-instance-id", request.goal_instance_id] : [])], + legacy_args: null, continue_when: null}; + return [...steps.slice(0, request.delivery_read_context_id ? 2 : 1), context]; + } return steps; } @@ -735,6 +762,27 @@ function finalize(request: HostTodoCompletionRequest): JsonObject { const steps = providerSteps(request, identity); const outcomes = request.provider_outcomes; validateOutcomeOrder(outcomes, steps); + if (request.first_delivery && !request.checkpoint_read_context_id) { + const last = outcomes.at(-1); + const context = last ? parseObject(last.output) : null; + const expectedStep = request.delivery_read_context_id ? "direction_context" : "delivery_context"; + if (outcomes.length !== steps.length || last?.step_kind !== expectedStep || context?.ok !== true || + !identityMatches(context, identity)) { + return reduction("finalize", "provider_result", identity, null, context ?? {ok: false, error: "First delivery stopped before context readback."}); + } + return reduction("finalize", "provider_result", identity, null, { + schema_version: HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION, ok: true, completed: request.delivery_read_context_id !== null, + settlement_complete: false, settlement_identity: identity, + stage: request.delivery_read_context_id ? "direction_pending" : "result_review_pending", + ...(request.delivery_read_context_id ? {completion: outcomeAt(outcomes, 1)} : {}), context, + recovery: {tool: "complete_task", first_delivery: true, + delivery_read_context_id: request.delivery_read_context_id ?? context.read_context_id, + ...(request.delivery_read_context_id ? {read_context_id: context.read_context_id} : {}), + instruction: request.delivery_read_context_id + ? "Read this post-result context and judge direction. Call complete_task with the original completion intent, both read identities and the new Vision decision. Result replay does not repeat work." + : "Read this result basis, check the candidate and validation, then call complete_task with this delivery_read_context_id and the same intended result. Do not submit Vision until the post-result context is returned."}, + }); + } if (outcomes.length === 0) { return reduction( @@ -1023,7 +1071,7 @@ export function evaluateHostTodoCompletion(value: JsonObject): JsonObject { "--turn-instance-id", String(identity.turn_instance_id), ...(request.goal_instance_id ? ["--goal-instance-id", request.goal_instance_id] - : [])], + : []), ...(request.first_delivery ? ["--purpose", "first_delivery", "--decision-scope", "goal"] : [])], }; } if (phase === "vision_refresh") { diff --git a/loopx/control_plane/turn_driver/turn_contract_generated.py b/loopx/control_plane/turn_driver/turn_contract_generated.py index 0e43ba2a38..4e2ae8ff0b 100644 --- a/loopx/control_plane/turn_driver/turn_contract_generated.py +++ b/loopx/control_plane/turn_driver/turn_contract_generated.py @@ -1,6 +1,6 @@ """Generated by scripts/generate_turn_contract.py; do not edit. Source: loopx/control_plane/turn_loop_controller_contract_v0.json -SHA256: 184046656dada524b5412be128424b840dc1d490eef5bf8a0c9e9e5bb786492a""" +SHA256: 252c7ea204f59809779aa2fed4a7998d5b0579d46bb141bd95f3711553b750c4""" from __future__ import annotations from enum import Enum diff --git a/loopx/control_plane/turn_driver/turn_contract_generated.ts b/loopx/control_plane/turn_driver/turn_contract_generated.ts index 601d47dc43..311bf21e37 100644 --- a/loopx/control_plane/turn_driver/turn_contract_generated.ts +++ b/loopx/control_plane/turn_driver/turn_contract_generated.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate_turn_contract.py; do not edit. // Source: loopx/control_plane/turn_loop_controller_contract_v0.json -// SHA256: 184046656dada524b5412be128424b840dc1d490eef5bf8a0c9e9e5bb786492a +// SHA256: 252c7ea204f59809779aa2fed4a7998d5b0579d46bb141bd95f3711553b750c4 export const TURN_RESULT_KINDS = [ "validated_progress", diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 3a201afc09..964d76b17f 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -207,6 +207,9 @@ def complete_task( successor_todo_ids: list[str] | None = None, agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", + first_delivery: bool = False, + delivery_read_context_id: str = "", + read_context_id: str = "", ) -> str: goal_id, _ = self.context() if not goal_id: @@ -264,6 +267,9 @@ def complete_task( scheduler_owner=self.config.scheduler_owner, execution_mode=self.config.execution_mode, completion_args=tuple(args), + first_delivery=first_delivery, + delivery_read_context_id=delivery_read_context_id or None, + checkpoint_read_context_id=read_context_id or None, no_follow_up=no_follow_up, goal_ref=self.goal_ref(), ) @@ -274,6 +280,7 @@ def review_task_vision( self, todo_id: str, agent_id: str, agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", read_context_id: str = "", + first_delivery: bool = False, ) -> str: goal_id, _ = self.context() if not goal_id: @@ -288,6 +295,7 @@ def review_task_vision( scheduler_owner=self.config.scheduler_owner, execution_mode=self.config.execution_mode, completion_args=(), checkpoint_read_context_id=read_context_id or None, + first_delivery=first_delivery, goal_ref=self.goal_ref(), ) with host_vision_request(request, agent_vision, vision_unchanged_reason) as authored: @@ -335,6 +343,7 @@ def review_task_vision( todo_id: str, agent_id: str, agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", read_context_id: str = "", + first_delivery: bool = False, ) -> str: """Supply a missing vision decision for a previously completed MCP Todo. First call with only todo_id and agent_id to read the current basis. @@ -348,7 +357,7 @@ def review_task_vision( An unchanged reason requires an existing valid vision. Recheck should_run; checkpoint success alone does not certify Goal completion or clear gates. """ - return control.review_task_vision(todo_id, agent_id, agent_vision, vision_unchanged_reason, read_context_id) + return control.review_task_vision(todo_id, agent_id, agent_vision, vision_unchanged_reason, read_context_id, first_delivery) @server.tool() def complete_task( @@ -362,8 +371,16 @@ def complete_task( successor_todo_ids: list[str] | None = None, agent_vision: dict[str, Any] | None = None, vision_unchanged_reason: str = "", + first_delivery: bool = False, + delivery_read_context_id: str = "", + read_context_id: str = "", ) -> str: - """Complete verified work and settle once. Link existing planned successors + """Complete verified work and settle once. With first_delivery=true, first + call without Vision to read the result basis; verify it and echo its + delivery_read_context_id. Read the returned post-result direction basis, + then call with both original read identities and a newly judged Vision. + An interrupted call retains the same Todo/Turn; do not repeat work. + Link existing planned successors with successor_todo_ids; next_agent_todo creates a NEW Todo, not an id link. no_follow_up closes this Todo's continuation, NOT the Goal's vision. Do not duplicate existing work; only the fresh should_run contract can @@ -385,6 +402,9 @@ def complete_task( successor_todo_ids=successor_todo_ids, agent_vision=agent_vision, vision_unchanged_reason=vision_unchanged_reason, + first_delivery=first_delivery, + delivery_read_context_id=delivery_read_context_id, + read_context_id=read_context_id, ) return server, control diff --git a/loopx/quota.py b/loopx/quota.py index 1923113c41..21b36a3de0 100644 --- a/loopx/quota.py +++ b/loopx/quota.py @@ -930,7 +930,7 @@ def build_quota_should_run( build_quota_should_run as _build_quota_should_run, ) - return _build_quota_should_run( + payload = _build_quota_should_run( status_payload, goal_id=goal_id, agent_id=agent_id, @@ -953,6 +953,19 @@ def build_quota_should_run( workspace_path=workspace_path, goal_ref=goal_ref, ) + observation_root = runtime_root or status_payload.get("runtime_root") + if goal_ref is None and agent_id and observation_root: + from .control_plane.goals.checkpoint_context_io import pending_first_delivery_progress + + delivery_progress = pending_first_delivery_progress( + Path(observation_root), goal_id, agent_id, + ) + if delivery_progress is not None: + payload["first_delivery_progress"] = delivery_progress + # Observation cannot grant work or override an owner pause/health hold. + if payload.get("ok") and payload.get("state") != "paused": + payload["recommended_action"] = delivery_progress["next_action"] + return payload def _quota_spend_index_basis( diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index c7aee9a509..e8815dca6e 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -799,7 +799,7 @@ }, { "site": "loopx/cli_commands/project_lifecycle_refresh_state.py::.handle_refresh_state_command::codec_read:load_registry#1", - "line": 629, + "line": 638, "column": 17, "kind": "codec_read", "api": "load_registry", @@ -807,7 +807,7 @@ }, { "site": "loopx/cli_commands/project_lifecycle_refresh_state.py::.handle_refresh_state_command::codec_read:load_registry#2", - "line": 710, + "line": 719, "column": 17, "kind": "codec_read", "api": "load_registry", @@ -1181,9 +1181,25 @@ "api": "project_registry_transaction", "classification": "codec_api" }, + { + "site": "loopx/control_plane/goals/checkpoint_commit.ts::::direct_json_read:readFileSync#1", + "line": 332, + "column": 34, + "kind": "direct_json_read", + "api": "readFileSync", + "classification": "legacy_registry_source" + }, + { + "site": "loopx/control_plane/goals/checkpoint_context_io.py::._local_source_facts::codec_read:load_registry#1", + "line": 237, + "column": 20, + "kind": "codec_read", + "api": "load_registry", + "classification": "codec_api" + }, { "site": "loopx/control_plane/goals/checkpoint_context_io.py::.read_checkpoint_context::codec_read:load_registry#1", - "line": 137, + "line": 262, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 9ba202987b..029c4da4a4 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -221,6 +221,7 @@ "input_producer": "loopx/control_plane/turn_driver/transaction.py::_result_kind", "producers": [ "loopx/control_plane/turn_driver/executor.py::_host_result_stage", + "loopx/control_plane/turn_driver/executor.py::_first_delivery_stage", "loopx/control_plane/turn_driver/executor.py::_run_task_validator", "loopx/control_plane/turn_driver/executor.py::_task_validation_receipt", "loopx/control_plane/turn_driver/executor.py::_task_validation_stage", diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index a160917caa..e9ad808102 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -106,6 +106,7 @@ from .control_plane.goals.goal_frontier import latest_agent_vision_from_runs from .control_plane.goals.checkpoint_context_io import ( checkpoint_commit_guard, commit_checkpoint_run, require_complete_checkpoint_index, inspect_checkpoint_replay, + first_delivery_context_enrolled, ) from .registry import registry_goals as registry_goals, resolve_state_file as resolve_state_file from .runtime import validate_goal_id_path_segment @@ -718,6 +719,7 @@ def refresh_state_run( merge_agent_vision_patch: bool = False, vision_unchanged_reason: str | None = None, checkpoint_read_context_id: str | None = None, + first_delivery: bool = False, progress_observation: dict[str, Any] | None = None, completion_todo_id: str | None = None, completion_turn_key: str | None = None, @@ -735,6 +737,8 @@ def refresh_state_run( raise ValueError("--next-action-basis requires --next-action") if checkpoint_read_context_id and not turn_instance_id: raise ValueError("--checkpoint-read-context requires the original Turn identity") + if first_delivery and not (turn_instance_id and agent_id and (todo_id or replan_obligation_id)): + raise ValueError("--first-delivery requires the original Goal/Agent/Todo or obligation/Turn") validate_public_safe_text("classification", classification) if usage_measurement is not None and usage_codex_session is not None: raise ValueError("--usage-json cannot be combined with --usage-codex-session") @@ -822,6 +826,7 @@ def refresh_state_run( refresh_recovery = None prior_writeback_run = None checkpoint_supplement = False + first_delivery_checkpoint = False if todo_id or normalized_replan_obligation_id or turn_instance_id: if checkpoint_read_context_id or agent_vision_packet or vision_unchanged_reason: require_complete_checkpoint_index(runtime_root / "goals" / safe_goal_id / "runs" / "index.jsonl") @@ -841,6 +846,11 @@ def refresh_state_run( source_admission=source_admission, borrow_source_admission=source_admission is not None, refresh_retry=(refresh_retry_request := { + **({"first_delivery_request": { + "classification": classification, "recommended_action": recommended_action, + "agent_lane": agent_lane, "progress_scope": progress_scope, + "completion_todo_id": completion_todo_id, "completion_turn_key": completion_turn_key, + }} if first_delivery else {}), "checkpoint_read_context_id": checkpoint_read_context_id, "external_delivery": external_delivery, "vision": agent_vision_packet, @@ -890,7 +900,20 @@ def refresh_state_run( recovery_payload, registry_path=registry_path, runtime_root=runtime_root, goal_id=safe_goal_id, project=project, state_file=state_file, ) - if checkpoint_read_context_id and not checkpoint_supplement: + first_delivery_checkpoint = ( + refresh_recovery["decision"] == "append" + and first_delivery_context_enrolled(runtime_root, settlement_identity) + ) + if first_delivery != first_delivery_checkpoint: + raise ValueError("first delivery requires matching --first-delivery and persisted first_delivery context; " + "read and judge checkpoint-context --purpose first_delivery on the original Turn") + if first_delivery_checkpoint and not (agent_vision_packet or vision_unchanged_reason): + raise ValueError("first delivery direction is pending; read checkpoint-context --purpose first_delivery " + "for this original Turn, judge the returned state, then submit its context and direction") + if first_delivery_checkpoint and (next_action or usage_codex_session is not None): + raise ValueError("first delivery checkpoint does not support combined next-action or session usage booking; " + "retain the original Turn and use separately owned stages") + if checkpoint_read_context_id and not (checkpoint_supplement or first_delivery_checkpoint): raise ValueError("--checkpoint-read-context applies only to a missing-checkpoint supplement") settlement_workspace_requirement = resolve_settlement_workspace_requirement( delivery_workspace_causality, settlement_binding_kind=settlement_identity.binding_kind.value @@ -1337,13 +1360,15 @@ def refresh_state_run( source_context=recommendation_source_context(current_goal, current_text, source_registry=next_action_source_registry, todo_fields=current_planning.todo_fields), runs=newest_first_runs) - if checkpoint_supplement: + if checkpoint_supplement or first_delivery_checkpoint: assert settlement_identity is not None context = usage_booking_guard.enter_context(checkpoint_commit_guard( runtime_root=runtime_root, registry_path=registry_path, state_file=resolved_state_file, identity=settlement_identity, read_context_id=checkpoint_read_context_id, goal_ref=goal_ref, + purpose="first_delivery" if first_delivery_checkpoint else "supplement_checkpoint", + decision_scope=normalized_progress_scope if first_delivery_checkpoint else None, )) for projection in (record, index_record, payload): projection["vision_checkpoint"] = { @@ -1411,12 +1436,14 @@ def refresh_state_run( index_record["markdown_path"] = str(markdown_path) payload["json_path"] = str(json_path) payload["markdown_path"] = str(markdown_path) - if checkpoint_supplement: + if checkpoint_supplement or first_delivery_checkpoint: saved = commit_checkpoint_run(runtime_root=runtime_root, registry_path=registry_path, state_file=resolved_state_file, identity=settlement_identity, refresh_retry=refresh_retry_request, record=record, index_record=index_record, markdown=render_state_refresh_markdown(payload) + "\n", - goal_ref=goal_ref, source_admission=source_admission) + goal_ref=goal_ref, source_admission=source_admission, + purpose="first_delivery" if first_delivery_checkpoint else "supplement_checkpoint", + decision_scope=normalized_progress_scope if first_delivery_checkpoint else None) for projection in (record, index_record, payload): projection["vision_checkpoint"]["read_context"] = saved["context"] for projection in (index_record, payload): diff --git a/loopx/todos.py b/loopx/todos.py index 835d0347ec..7e06132696 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -1312,6 +1312,9 @@ def complete_goal_todo( completion_turn_key: str | None = None, completion_identity_source: str | None = None, terminal_review_basis: Mapping[str, Any] | None = None, + delivery_read_context_id: str | None = None, + delivery_direction_context_id: str | None = None, + delivery_settlement_identity: Mapping[str, Any] | None = None, completion_delivery_workspace: Mapping[str, Any] | None = None, completion_validation_workspace_path: Path | None = None, task_lease_idempotency_key: str | None = None, From 04cb72205d28d44d7dbf1f6509f3830d6d0f84bd Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 5 Oct 2026 19:30:27 +0800 Subject: [PATCH 02/18] test(control-plane): qualify staged delivery on File and SQLite Signed-off-by: Tartar --- tests/control_plane/checkpoint_process.py | 5 +- .../test_checkpoint_provider_fence.py | 20 +-- .../test_checkpoint_read_context.py | 101 ++++++++++++++- .../test_first_delivery_faults.py | 66 ++++++++++ .../test_first_delivery_managed.py | 118 ++++++++++++++++++ .../control_plane/test_first_delivery_mcp.py | 87 +++++++++++++ .../checkpoint_commit_probe.ts | 28 +++++ .../checkpoint_read_context.test.ts | 54 ++++++++ .../content_digest_single_owner.test.ts | 1 + .../terminal_source_conformance.ts | 59 ++++++++- 10 files changed, 526 insertions(+), 13 deletions(-) create mode 100644 tests/control_plane/test_first_delivery_faults.py create mode 100644 tests/control_plane/test_first_delivery_managed.py create mode 100644 tests/control_plane/test_first_delivery_mcp.py diff --git a/tests/control_plane/checkpoint_process.py b/tests/control_plane/checkpoint_process.py index 16ef630268..e19841b5b9 100644 --- a/tests/control_plane/checkpoint_process.py +++ b/tests/control_plane/checkpoint_process.py @@ -32,7 +32,7 @@ def wait_for(path: Path, child=None, timeout=20): time.sleep(0.01) -def refresh(registry, runtime, token, *, goal_ref=None): +def refresh(registry, runtime, token, *, goal_ref=None, first_delivery=False): from loopx.state_refresh import refresh_state_run from tests.control_plane.test_quota_settlement_cli import GOAL_ID, AGENT_ID, TODO_ID, TURN_ID return refresh_state_run(registry_path=Path(registry), runtime_root_override=str(runtime), @@ -41,8 +41,9 @@ def refresh(registry, runtime, token, *, goal_ref=None): delivery_batch_scale="implementation", delivery_outcome="outcome_progress", vision_unchanged_reason="The current basis remains applicable.", checkpoint_read_context_id=token, dry_run=False, sync_global=False, + **({"delivery_workspace_path": Path(registry).parent.parent} if first_delivery else {}), external_delivery={"suppress": True, "resume_key": None}, - goal_ref=goal_ref) + goal_ref=goal_ref, first_delivery=first_delivery) def main(request): diff --git a/tests/control_plane/test_checkpoint_provider_fence.py b/tests/control_plane/test_checkpoint_provider_fence.py index 3c94c84e18..e8f31f4cde 100644 --- a/tests/control_plane/test_checkpoint_provider_fence.py +++ b/tests/control_plane/test_checkpoint_provider_fence.py @@ -90,9 +90,9 @@ def _replace_source_goal(registry: Path, instance_id: str) -> None: transaction.commit(payload) -def fixture(tmp_path, monkeypatch, provider): +def fixture(tmp_path, monkeypatch, provider, *, first_delivery=False): isolate_sqlite_runtime(tmp_path, monkeypatch) - project, runtime, registry, binding, delivery, original = _missing(tmp_path) + project, runtime, registry, binding, delivery, original = _missing(tmp_path, defer=first_delivery) state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" todos = [{"schema_version": "todo_item_v0", "todo_id": name, "index": index, "role": "agent", "status": "open", "done": False, "text": f"Synthetic page work {name}", @@ -109,7 +109,7 @@ def fixture(tmp_path, monkeypatch, provider): def read(): return context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, - dependency_todo_ids=["todo_dependency"]) + dependency_todo_ids=["todo_dependency"], purpose="first_delivery" if first_delivery else "supplement_checkpoint") return project, runtime, registry, state, read, original @@ -248,8 +248,9 @@ def test_existing_cli_maintenance_guard_precedes_provider_commit(tmp_path, monke @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_provider_transaction_cannot_commit_between_final_head_and_checkpoint(tmp_path, monkeypatch, provider): - _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider) +@pytest.mark.parametrize("first_delivery", [False, True]) +def test_provider_transaction_cannot_commit_between_final_head_and_checkpoint(tmp_path, monkeypatch, provider, first_delivery): + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=first_delivery) context = read() barrier = tmp_path / "barrier" barrier.mkdir() @@ -291,7 +292,7 @@ def native(method, params, **kwargs): child.communicate() monkeypatch.setattr(context_io, "effect_runtime_result", native) - result = refresh(registry, runtime, context["read_context_id"]) + result = refresh(registry, runtime, context["read_context_id"], first_delivery=first_delivery) assert result["vision_checkpoint"]["satisfied"] and len(observed) == 1 assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID)["provider_revision"] != observed[0] rows = [json.loads(line) for line in (runtime / f"goals/{GOAL_ID}/runs/index.jsonl").read_text().splitlines()] @@ -300,7 +301,8 @@ def native(method, params, **kwargs): @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_provider_commit_after_python_check_is_revalidated_at_save(tmp_path, monkeypatch, provider): +@pytest.mark.parametrize("first_delivery", [False, True]) +def test_provider_commit_after_python_check_is_revalidated_at_save(tmp_path, monkeypatch, provider, first_delivery): """Provider contract: a transaction without the CLI's outer M protection. The reviewed implementation accepts this old checkpoint. The native save @@ -308,7 +310,7 @@ def test_provider_commit_after_python_check_is_revalidated_at_save(tmp_path, mon hold M before committing; this test does not pretend to reproduce that path. """ from loopx import state_refresh - _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider) + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=first_delivery) token = read()["read_context_id"] index = runtime / f"goals/{GOAL_ID}/runs/index.jsonl" before = index.read_bytes() @@ -329,7 +331,7 @@ def race(*args): monkeypatch.setattr(state_refresh, "reserve_unique_run_paths", race) try: with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: - refresh(registry, runtime, token) + refresh(registry, runtime, token, first_delivery=first_delivery) assert rejected.value.code == "checkpoint_read_context_stale" assert index.read_bytes() == before finally: diff --git a/tests/control_plane/test_checkpoint_read_context.py b/tests/control_plane/test_checkpoint_read_context.py index 2f2f7a51be..27782bb53a 100644 --- a/tests/control_plane/test_checkpoint_read_context.py +++ b/tests/control_plane/test_checkpoint_read_context.py @@ -14,7 +14,7 @@ ) -def _missing(root: Path): +def _missing(root: Path, *, defer=False): project, runtime, registry = _write_fixture(root) binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) for args in ( @@ -28,6 +28,8 @@ def _missing(root: Path): delivery = ("refresh-state", *binding, "--classification", "validated_change", "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", "--no-global-sync", "--suppress-external-sinks") + if defer: + return project, runtime, registry, binding, delivery, None rc, original = _run_cli(registry, runtime, *delivery, cwd=project) assert rc == 0 and original["vision_checkpoint"]["decision"] == "missing_required", original return project, runtime, registry, binding, delivery, original @@ -46,6 +48,103 @@ def test_checkpoint_recovery_is_not_a_fresh_turn_preflight(tmp_path): assert _spend_run_count(runtime) == 0 +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_first_delivery_context_cli_rejects_stale_and_replays_success(tmp_path, monkeypatch, provider): + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + todo = {"schema_version": "todo_item_v0", "todo_id": TODO_ID, "index": 1, + "role": "agent", "status": "open", "done": False, + "text": "Validate and settle the selected delivery.", "claimed_by": AGENT_ID, + "task_class": "advancement_task", "archive_state": "active", "source_section": "Agent Todo"} + projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo]) + initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) + binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *binding, + "--scan-path", str(project), cwd=project) + assert rc == 0, json.dumps(guard) + read = ("checkpoint-context", *binding, "--purpose", "first_delivery") + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + delivery = ("refresh-state", *binding, "--first-delivery", "--classification", "validated_change", + "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", + "--vision-summary", "Continue the scoped route.", "--vision-acceptance", "Checks pass; remaining work stays open.", + "--no-global-sync", "--suppress-external-sinks") + index = runtime / f"goals/{GOAL_ID}/runs/index.jsonl" + before = index.read_bytes() if index.exists() else b"" + rc, no_context = _run_cli(registry, runtime, *delivery, cwd=project) + assert rc == 1 and no_context["error_code"] == "checkpoint_read_context_required", no_context + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance\n\nVerify the additional output.\n", encoding="utf-8") + rc, stale = _run_cli(registry, runtime, *delivery, "--checkpoint-read-context", context["read_context_id"], cwd=project) + assert rc == 1 and stale["error_code"] == "checkpoint_read_context_stale", stale + assert "goal" in stale["checkpoint_read_context"]["changed_components"] + assert (index.read_bytes() if index.exists() else b"") == before + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + commit = (*delivery, "--checkpoint-read-context", context["read_context_id"]) + rc, saved = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and saved["vision_checkpoint"]["satisfied"], json.dumps(saved) + after = index.read_bytes() + state.write_text(state.read_text(encoding="utf-8") + "\nAcceptance changed after success.\n", encoding="utf-8") + rc, replay = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and replay["idempotent_replay"], replay + assert index.read_bytes() == after + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_delivery_result_basis_precedes_cas_and_replays_before_current_freshness(tmp_path, monkeypatch, provider): + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + todo = {"schema_version": "todo_item_v0", "todo_id": TODO_ID, "index": 1, + "role": "agent", "status": "open", "done": False, "claimed_by": AGENT_ID, + "text": "Validate and settle the selected delivery.", "task_class": "advancement_task", + "archive_state": "active", "source_section": "Agent Todo"} + peer = {**todo, "todo_id": "todo_unrelated_peer", "index": 2, "text": "Independent output."} + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, todos=[todo, peer], handoff_mode="soft_claim"), state_path=state, provider=provider) + binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--codex-app", *binding, + "--scan-path", str(project), cwd=project) + assert rc == 0, guard + read = ("checkpoint-context", *binding, "--purpose", "delivery_result") + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + complete = ("todo", "complete", *binding, "--note", "Validated the candidate; direction review remains.") + rc, missing = _run_cli(registry, runtime, *complete, cwd=project) + assert rc == 1 and missing.get("error_code") == "checkpoint_read_context_unknown_or_replaced", missing + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance\n\nAdditional acceptance requirement.\n", encoding="utf-8") + rc, stale = _run_cli(registry, runtime, *complete, "--delivery-read-context", context["read_context_id"], cwd=project) + assert rc == 1 and stale.get("error_code") == "checkpoint_read_context_stale", json.dumps(stale) + rc, context = _run_cli(registry, runtime, *read, cwd=project) + assert rc == 0, context + commit = (*complete, "--delivery-read-context", context["read_context_id"]) + rc, peer_update = _run_cli(registry, runtime, "todo", "update", "--goal-id", GOAL_ID, + "--todo-id", "todo_unrelated_peer", "--agent-id", AGENT_ID, "--note", "Peer progress must survive.", cwd=project) + assert rc == 0, peer_update + rc, saved = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and saved["completed"], saved + rc, peer_readback = _run_cli(registry, runtime, "todo", "list", "--goal-id", GOAL_ID, + "--todo-id", "todo_unrelated_peer", cwd=project) + assert rc == 0 and peer_readback["todo"]["note"] == "Peer progress must survive.", peer_readback + state.write_text(state.read_text(encoding="utf-8") + "\nRequirement after successful result.\n", encoding="utf-8") + rc, replay = _run_cli(registry, runtime, *commit, cwd=project) + assert rc == 0 and replay["idempotent_replay"], replay + rc, conflict = _run_cli(registry, runtime, *commit, "--note", "Different candidate intent.", cwd=project) + assert rc == 1, conflict + # The next direction reads the committed result, so its own open -> done + # change cannot invalidate the new basis. + rc, direction = _run_cli(registry, runtime, "checkpoint-context", *binding, "--purpose", "first_delivery", cwd=project) + assert rc == 0 and direction["basis"]["todo"]["status"] == "done", direction + + def test_missing_replaced_stale_context_requires_reread_and_preserves_delivery(tmp_path): project, runtime, registry, binding, delivery, original = _missing(tmp_path) state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" diff --git a/tests/control_plane/test_first_delivery_faults.py b/tests/control_plane/test_first_delivery_faults.py new file mode 100644 index 0000000000..6f936b498f --- /dev/null +++ b/tests/control_plane/test_first_delivery_faults.py @@ -0,0 +1,66 @@ +"""Kill real native appends and inspect original identities on both providers.""" +from __future__ import annotations + +import json + +import pytest + +from loopx.control_plane.goals import checkpoint_context_io as context_io +from loopx.control_plane.quota.settlement import SettlementIdentity +from tests.control_plane.checkpoint_process import refresh, start_probe +from tests.control_plane.test_checkpoint_provider_fence import fixture +from tests.control_plane.test_quota_settlement_cli import GOAL_ID, AGENT_ID, TODO_ID, _spend_run_count + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("fault", ["before_artifacts", "after_json", "after_markdown", "after_index"]) +def test_original_direction_readback_distinguishes_torn_from_committed(tmp_path, monkeypatch, provider, fault): + _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + token = read()["read_context_id"] + barrier = tmp_path / "fault" + barrier.mkdir() + (barrier / "release").touch() + original = context_io.effect_runtime_result + + def native(method, params, **kwargs): + if method != "goal.checkpoint_read_context.commit": + return original(method, params, **kwargs) + child = start_probe({"mode": "checkpoint", "provider": provider, "barrier": str(barrier), + "params": params, "fault": fault}) + stdout, stderr = child.communicate(timeout=25) + assert child.returncode == 86, stdout + stderr + raise OSError("injected native response loss") + + monkeypatch.setattr(context_io, "effect_runtime_result", native) + with pytest.raises(OSError, match="injected"): + refresh(registry, runtime, token, first_delivery=True) + monkeypatch.setattr(context_io, "effect_runtime_result", original) + if fault == "before_artifacts": + observed = read() + assert observed["read_context_id"] != token + saved = refresh(registry, runtime, observed["read_context_id"], first_delivery=True) + assert saved["appended"] + elif fault == "after_index": + observed = read() + assert observed["status"] == "committed" and observed["replayed"] + replay = refresh(registry, runtime, token, first_delivery=True) + assert replay["appended"] is False + assert replay["json_path"] == observed["json_path"] + else: + for action in (read, lambda: refresh(registry, runtime, token, first_delivery=True)): + with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: + action() + assert rejected.value.code == "checkpoint_commit_unknown" + # A new Turn identity cannot bypass an unresolved append for the same + # Agent/Todo. This check precedes any new admission or token issuance. + identity = SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, + todo_id=TODO_ID, turn_instance_id="different-turn") + with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: + context_io._checkpoint_effect("goal.checkpoint_read_context.inspect_attempt", { + "runtime_root": str(runtime.resolve()), "identity": identity.as_dict(), + "check_other_attempts": True, + }) + assert rejected.value.code == "checkpoint_commit_unknown" + rows = [json.loads(line) for line in (runtime / "goals" / GOAL_ID / "runs/index.jsonl").read_text().splitlines()] + assert sum(bool(row.get("vision_checkpoint", {}).get("read_context")) for row in rows) == (fault in {"after_index", "before_artifacts"}) + assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane/test_first_delivery_managed.py b/tests/control_plane/test_first_delivery_managed.py new file mode 100644 index 0000000000..5f5778b2d5 --- /dev/null +++ b/tests/control_plane/test_first_delivery_managed.py @@ -0,0 +1,118 @@ +"""Real managed CLI and provider; the deterministic Host tests orchestration, +not model benefit. The separate Agent study must use an actual model.""" +from __future__ import annotations + +import contextlib +import io +import json +import sys +from pathlib import Path + +import pytest + +from loopx.cli import main +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from tests.test_loopx_turn_driver import ( + _write_live_fixture, _completion_host_and_validation_scripts, _turn_run_once_completion_argv, _turn_journal, +) + + +def _cli(args): + output = io.StringIO() + with contextlib.redirect_stdout(output): + code = main(args) + return code, json.loads(output.getvalue()) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("interruption", [None, "result_response_lost", "direction_call_failed", "stale_direction", "deferred_terminal"]) +def test_managed_first_delivery_judges_after_result_and_replays_without_host(tmp_path, monkeypatch, provider, interruption): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_live_fixture(tmp_path) + state = project / ".codex/goals/loopx-turn-fixture/ACTIVE_GOAL_STATE.md" + todo = {"schema_version": "todo_item_v0", "todo_id": "todo_fixture0001", "index": 1, + "done": False, "text": "Advance one public fixture.", "role": "agent", "status": "open", + "archive_state": "active", "source_section": "Agent Todo", "task_class": "advancement_task", + "action_kind": "fixture", "claimed_by": "codex-fixture", "priority": "P0"} + if interruption == "deferred_terminal": + todo["no_followup"] = True + initialize_canonical_authority(runtime, "loopx-turn-fixture", + build_todo_runtime_shadow_projection(goal_id="loopx-turn-fixture", handoff_mode="soft_claim", todos=[todo]), + state_path=state, provider=provider) + prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] + code, baseline = _cli([*prefix, "refresh-state", "--goal-id", "loopx-turn-fixture", "--agent-id", "codex-fixture", + "--vision-summary", "Validate the fixture and review remaining work.", "--vision-acceptance", "Fixture validation passes.", + "--no-global-sync", "--suppress-external-sinks"]) + assert code == 0, baseline + host_project = project / "host-workspace" + host_project.mkdir() + host, validation = _completion_host_and_validation_scripts() + host = host.replace("request = json.load(sys.stdin)", '''request = json.load(sys.stdin) +with pathlib.Path("host-calls.txt").open("a") as log: + log.write("direction\\n" if "direction_review" in request else "implementation\\n") +if "direction_review" in request: + context = request["direction_review"]["context"] + assert context["basis"]["todo"]["status"] == "done" + json.dump({"read_context_id": context["read_context_id"], "decision": "continue", + "vision_unchanged_reason": "The fixture passed; select remaining work from the current frontier."}, sys.stdout) + raise SystemExit(0) +assert "delivery_result_context" in request +''') + if interruption == "deferred_terminal": + host = host.replace('== "done"', '== "open"').replace('"decision": "continue"', '"decision": "terminal_ready"') + argv = _turn_run_once_completion_argv(host_project, runtime, registry, host, validation) + host_file = host_project / "host.py" + host_file.write_text(host, encoding="utf-8") + argv[argv.index(json.dumps([sys.executable, "-c", host]))] = json.dumps([sys.executable, str(host_file)]) + argv.insert(-1, "--first-delivery") + if interruption == "result_response_lost": + from loopx.cli_commands import turn_run_once + original = turn_run_once.write_turn_validated_completion + + def lose_once(**kwargs): + result = original(**kwargs) + monkeypatch.setattr(turn_run_once, "write_turn_validated_completion", original) + raise OSError("injected response loss after result CAS") + + monkeypatch.setattr(turn_run_once, "write_turn_validated_completion", lose_once) + elif interruption == "stale_direction": + from loopx.cli_commands import turn_run_once + original = turn_run_once.refresh_state_run + + def change_basis_once(**kwargs): + if kwargs.get("first_delivery"): + state.write_text(state.read_text(encoding="utf-8") + "\n## Direction update\nReview the current remaining frontier.\n", encoding="utf-8") + monkeypatch.setattr(turn_run_once, "refresh_state_run", original) + return original(**kwargs) + + monkeypatch.setattr(turn_run_once, "refresh_state_run", change_basis_once) + elif interruption == "direction_call_failed": + from loopx.control_plane.turn_driver import first_delivery + original = first_delivery.review_first_delivery + + def fail_once(**kwargs): + monkeypatch.setattr(first_delivery, "review_first_delivery", original) + raise OSError("injected interruption before direction Host") + + monkeypatch.setattr(first_delivery, "review_first_delivery", fail_once) + code, result = _cli(argv) + if interruption not in {None, "deferred_terminal"}: + assert result["status"] == "failed", result + assert result["first_delivery_progress"]["stage"] in {"direction_pending", "operation_unknown"} + code, result = _cli([*argv[:-1], "--resume-turn-key", result["resume_turn_key"], "--retry-failed-turn", "--execute"]) + assert code == 0 and result["status"] == "committed", json.dumps(result) + assert result["first_delivery_progress"]["stage"] == "settled" + calls = ["implementation", "direction"] + (["direction"] if interruption == "stale_direction" else []) + assert (host_project / "host-calls.txt").read_text().splitlines() == calls + journal = _turn_journal(runtime) + assert len(journal["direction_reviews"]) == len(calls) - 1 + if interruption != "deferred_terminal": + assert journal["delivery_completion"]["ok"] + assert journal["direction_reviews"][-1]["response"]["read_context_id"] == journal["direction_reviews"][-1]["read_context_id"] + code, replay = _cli([*argv[:-1], "--resume-turn-key", result["resume_turn_key"], "--execute"]) + assert code == 0 and replay["replayed"], replay + assert (host_project / "host-calls.txt").read_text().splitlines() == calls + rows = [json.loads(line) for line in (runtime / "goals/loopx-turn-fixture/runs/index.jsonl").read_text().splitlines()] + assert sum(row["classification"] == "quota_slot_spent" for row in rows) == 1 + assert sum(bool(row.get("vision_checkpoint", {}).get("read_context")) for row in rows) == 1 diff --git a/tests/control_plane/test_first_delivery_mcp.py b/tests/control_plane/test_first_delivery_mcp.py new file mode 100644 index 0000000000..652c8ab017 --- /dev/null +++ b/tests/control_plane/test_first_delivery_mcp.py @@ -0,0 +1,87 @@ +"""MCP -> real CLI -> local authority phased result/direction settlement.""" +from __future__ import annotations + +import json + +import pytest + +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.status import parse_active_state_todos +from loopx.todos import add_goal_todo +from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from tests.test_goal_mode_mcp_settlement import GOAL_ID, AGENT_ID, _write_fixture, _control, _run_cli + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("new_obligation", [False, True]) +def test_mcp_first_delivery_and_terminal_recheck(tmp_path, monkeypatch, provider, new_obligation): + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, state = _write_fixture(tmp_path) + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["adapter"] = {"kind": "fixture_v0", "status": "connected-delivery"} + registry.write_text(json.dumps(registration), encoding="utf-8") + added = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Verify the bounded output.", task_class="advancement_task", + claimed_by=AGENT_ID, continuation_policy="same_agent_non_delivery") + todo_id = added["todo_id"] + runtime = tmp_path / "runtime" + todos = parse_active_state_todos(state.read_text(encoding="utf-8")) + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, handoff_mode="soft_claim", todos=todos["agent_todos"]["items"]), + state_path=state, provider=provider) + rc, baseline = _run_cli(registry, "refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--vision-summary", "Verify the bounded output.", "--vision-acceptance", "Output verification passes.", + "--no-global-sync", "--suppress-external-sinks") + assert rc == 0, baseline + control = _control(registry) + intent = dict(todo_id=todo_id, agent_id=AGENT_ID, evidence="The bounded output passed verification.", + no_follow_up=True, first_delivery=True) + first = json.loads(control.complete_task(**intent)) + assert first["ok"] and first["stage"] == "result_review_pending", first + result_id = first["context"]["read_context_id"] + second = json.loads(control.complete_task(**intent, delivery_read_context_id=result_id)) + assert second["ok"] and second["stage"] == "direction_pending", second + assert second["context"]["basis"]["todo"]["status"] == "done" + assert second["settlement_complete"] is False + rc, status = _run_cli(registry, "status", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID) + assert rc == 0, status + observed = status["attention_queue"]["items"][0]["first_delivery_progress"] + assert observed["stage"] == "direction_pending" and observed["result_committed"] is True + assert observed["goal_completion_certified"] is False + assert status["attention_queue"]["items"][0]["recommended_action"] == observed["next_action"] + from loopx.status import collect_status + + # Dashboard and Lark projections call the shared collector, not the CLI's + # Agent decorator. An unscoped read must retain the same recovery message. + shared = collect_status(registry_path=registry, runtime_root_override=str(runtime), + scan_roots=[state.parent], limit=10, goal_id=GOAL_ID, include_public_boundary_scan=False) + shared_item = shared["attention_queue"]["items"][0] + assert shared_item["first_delivery_progress"]["stage"] == "direction_pending" + assert shared_item["recommended_action"] == observed["next_action"] + direction_id = second["context"]["read_context_id"] + run_cli = control.run_cli + injected = [] + + def provider_call(args, **kwargs): + output = run_cli(args, **kwargs) + if new_obligation and args[:2] == ["quota", "spend-slot"] and not injected: + injected.append(add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Verify the newly required output.", task_class="advancement_task", + claimed_by=AGENT_ID, continuation_policy="same_agent_non_delivery")) + return output + + control.run_cli = provider_call + decision = dict(delivery_read_context_id=result_id, read_context_id=direction_id, + vision_unchanged_reason="The bounded output passed; the current frontier was reviewed.") + final = json.loads(control.complete_task(**intent, **decision)) + assert final["ok"] is (not new_obligation), json.dumps(final) + if new_obligation: + assert final["settlement"]["failed_stage"] == "terminal_closeout", final + assert "checkpoint_read_context_stale" in final["settlement"]["reason"], final + else: + assert final["settlement"]["terminal_closeout"]["completion_continuation"] == "no_followup", final + replay = json.loads(control.complete_task(**intent, **decision)) + assert replay["ok"], replay + rows = [json.loads(line) for line in (runtime / f"goals/{GOAL_ID}/runs/index.jsonl").read_text().splitlines()] + assert sum(row["classification"] == "quota_slot_spent" for row in rows) == 1 + assert sum(bool(row.get("vision_checkpoint", {}).get("read_context")) for row in rows) == 1 diff --git a/tests/control_plane_ts/checkpoint_commit_probe.ts b/tests/control_plane_ts/checkpoint_commit_probe.ts index 6c033f669f..06c81b1675 100644 --- a/tests/control_plane_ts/checkpoint_commit_probe.ts +++ b/tests/control_plane_ts/checkpoint_commit_probe.ts @@ -1,6 +1,8 @@ /** Isolated process barriers around the production persistence methods. No * runtime test flag or alternate store implementation enters product code. */ import {existsSync, readFileSync, writeFileSync} from "node:fs"; +import fs from "node:fs"; +import {syncBuiltinESMExports} from "node:module"; import {join} from "node:path"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; @@ -13,6 +15,32 @@ let raw = ""; for await (const chunk of process.stdin) raw += chunk; const input = JSON.parse(raw) as {mode: string; barrier: string; provider: string; method: string; params: JsonObject; repeat?: boolean; fault?: string; provider_direct?: boolean}; +if (input.fault?.startsWith("after_")) { + const descriptors = new Map(); + const open = fs.openSync; + const flush = fs.fsyncSync; + fs.openSync = ((path, ...args) => { + const fd = open(path, ...args); + descriptors.set(fd, String(path)); + return fd; + }) as typeof fs.openSync; + fs.fsyncSync = fd => { + flush(fd); + const path = descriptors.get(fd) ?? ""; + const suffix = input.fault === "after_json" ? ".json" : input.fault === "after_markdown" ? ".md" : "index.jsonl"; + if (path.includes("runs") && path.endsWith(suffix)) process.exit(86); + }; + syncBuiltinESMExports(); +} +if (input.fault === "before_artifacts") { + const rename = fs.renameSync; + fs.renameSync = (from, to) => { + rename(from, to); + if (String(to).includes("checkpoint-contexts") && + JSON.parse(readFileSync(to, "utf8")).commit_attempt != null) process.exit(86); + }; + syncBuiltinESMExports(); +} const signal = (name: string, value: unknown = true) => writeFileSync(join(input.barrier, name), JSON.stringify(value)); const pause = () => { const deadline = Date.now() + 20000; diff --git a/tests/control_plane_ts/checkpoint_read_context.test.ts b/tests/control_plane_ts/checkpoint_read_context.test.ts index e496374c8f..92d4316185 100644 --- a/tests/control_plane_ts/checkpoint_read_context.test.ts +++ b/tests/control_plane_ts/checkpoint_read_context.test.ts @@ -112,3 +112,57 @@ test("obligations cover the full frontier and a completed checkpoint cannot acqu assert.equal(read({prior: {vision_checkpoint: {decision: "patched", satisfied: true}}}).error_code, "checkpoint_context_not_missing"); }); + +test("first delivery has independent admission and cannot reuse a supplement receipt", () => { + const first = {purpose: "first_delivery", decision_scope: "agent_lane", prior: null, admitted_turn: true}; + const receipt = read(first).receipt; + assert.equal(check(receipt, first).ok, true); + assert.equal(read({...first, admitted_turn: false}).error_code, "checkpoint_first_delivery_not_admitted"); + assert.equal(read({...first, prior: {vision_checkpoint: {decision: "missing_required", satisfied: false}}}).ok, false); + assert.equal(check(receipt).ok, false); + assert.equal(check(read().receipt, first).ok, false); +}); + +test("first direction detects frontier membership while ignoring independent peer work", () => { + const first = {purpose: "first_delivery", decision_scope: "agent_lane", prior: null, admitted_turn: true}; + const source = {...facts, todos: [...facts.todos, {todo_id: "peer", claimed_by: "other", status: "open"}] as JsonObject[]}; + const receipt = read({...first, facts: source}).receipt; + const changed = structuredClone(source); + changed.todos[4].status = "done"; + assert.equal(check(receipt, {...first, facts: changed}).ok, true); + changed.todos.push({todo_id: "new", claimed_by: "agent", status: "open"}); + assert.deepEqual(check(receipt, {...first, facts: changed}).changed_components, ["frontier"]); + changed.todos.pop(); + changed.todos[4].claimed_by = "agent"; + assert.deepEqual(check(receipt, {...first, facts: changed}).changed_components, ["frontier"]); + const whole = read({...first, decision_scope: "goal", facts: source}).receipt; + assert.equal(check(whole, {...first, decision_scope: "agent_lane", facts: source}).error_code, + "checkpoint_read_context_scope_mismatch"); + assert.equal(check(whole, {...first, decision_scope: "goal", facts: changed}).ok, false); +}); + +test("first result and direction bind authority, lease expiry, purpose and unknown fields", () => { + for (const purpose of ["first_delivery", "delivery_result"]) { + const request = {purpose, decision_scope: "goal", prior: null, admitted_turn: true}; + const baseline = {...facts, execution_lease: {owner: "agent", active: true}, + source: {authority: "file_v0", store_identity: "store-1", registry_goal: {registered_agents: ["agent"]}}}; + const receipt = read({...request, facts: baseline}).receipt; + for (const change of [ + {...baseline, execution_lease: {owner: "agent", active: false}}, + {...baseline, execution_lease: {owner: "other", active: true}}, + {...baseline, source: {...baseline.source, store_identity: "store-2"}}, + {...baseline, source: {...baseline.source, registry_goal: {registered_agents: []}}}, + {...baseline, todos: baseline.todos.map((row, i) => i === 0 ? {...row, future_business_rule: "changed"} : row)}, + ]) assert.equal(check(receipt, {...request, facts: change}).error_code, "checkpoint_read_context_stale"); + assert.equal(check(receipt, {...request, purpose: purpose === "first_delivery" ? "delivery_result" : "first_delivery"}).ok, false); + } +}); + +test("an uncertain direction cannot acquire a replacement read or reexecute", () => { + const request = {purpose: "first_delivery", decision_scope: "goal", prior: null, admitted_turn: true}; + const receipt = {...read(request).receipt as JsonObject, commit_attempt: {json_path: "unindexed"}}; + for (const result of [read({...request, receipt}), check(receipt, request)]) { + assert.equal(result.error_code, "checkpoint_commit_unknown"); + assert.equal(result.reread_required, false); + } +}); diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 569e2ae329..1dce481fd8 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -117,6 +117,7 @@ const CANONICAL_CONSUMERS = [ "control_plane/effect_runtime_snapshot.ts", "control_plane/goals/acceptance_authority.ts", "control_plane/goals/acceptance_contract.ts", + "control_plane/goals/checkpoint_commit.ts", "control_plane/goals/goal_amendment_proposal.ts", "control_plane/goals/operator_actions.ts", "control_plane/goals/shared_goal_alignment.ts", diff --git a/tests/control_plane_ts/terminal_source_conformance.ts b/tests/control_plane_ts/terminal_source_conformance.ts index 875b819b3c..1faa750a95 100644 --- a/tests/control_plane_ts/terminal_source_conformance.ts +++ b/tests/control_plane_ts/terminal_source_conformance.ts @@ -2,7 +2,7 @@ * validation, lease retirement and receipt recovery on every real provider. */ import assert from "node:assert/strict"; import test from "node:test"; -import type {AuthorityStore} from "../../loopx/control_plane/coordination/authority_store.ts"; +import type {AuthorityStore, AuthorityStoreHead} from "../../loopx/control_plane/coordination/authority_store.ts"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {executeCoordinationTodoTerminalLifecycle as execute, type CoordinationTodoTerminalLifecycleInput} from "../../loopx/control_plane/coordination/todo_terminal_lifecycle.ts"; @@ -24,6 +24,63 @@ const validation = {schema_version: "issue_fix_validation_command_v0", command_l stdout_captured: false, stderr_captured: false, local_path_captured: false}; export function registerTerminalSourceConformance(provider: string, factory: AuthorityStoreConformanceFactory): void { + test(`${provider}: protected completion final CAS preserves a racing peer and historical recovery`, async t => { + const {store, contender} = await factory(t); + const goal = "protected-terminal-cas"; + const fixture = productionScaleCoordinationFixture(goal, "native"); + const scenario = fixture.semantic_cases.terminal_source!; + assert.equal((await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + next_projection: fixture.projection, events: [], receipts: []})).status, "applied"); + const before = await loaded(store); + const request: CoordinationTodoTerminalLifecycleInput = {goal_id: goal, todo_id: fixture.completion_todo_id, + expected_role: "agent", command: "complete", actor_agent_id: "agent-a", registered_agents: fixture.registered_agents, + lifecycle_grants: [], authority_reason: null, decision_outcome: null, + operation_identity: {kind: "completion_turn"}, + lease_idempotency_key: fixture.completion_lease_idempotency_key, + lease_expected_version: fixture.completion_lease_expected_version, + allow_user_gate_auto_acquire: false, requested_no_followup: false, + requested_completion_turn_key: "protected-turn", requested_completion_identity_source: "turn_settlement", + linked_successor_todo_ids: [], successor_intents: [], note: "Reviewed result", evidence: "Verified result", reason: null, + clear_claim: false, validation_declaration: declaration, validation_receipt: validation, + validation_declaration_sha256: canonicalAuthoritySha256(declaration), + completion_policy_request: null, dry_run: false, now: new Date(String(scenario.observed_at)), + delivery_read_context_id: "result-read", validation_source_provider_revision: before.provider_revision}; + const missingCheck = await execute(store, request); + assert.equal(missingCheck.reason_code, "delivery_basis_check_required", JSON.stringify(missingCheck)); + const checkedHeads: string[] = []; + const checkBasis = async (head: AuthorityStoreHead) => { + checkedHeads.push(head.provider_revision); + return {ok: true}; // The peer is unrelated; basis construction has separate integration coverage. + }; + const peer = (before.head.todos as JsonObject[]).find(todo => todo.todo_id !== request.todo_id)!; + let attemptedId = ""; + const racing = new Proxy(store, {get(target, key) { + if (key === "commitAuthority") return async (...args: Parameters) => { + attemptedId = args[0].operation_id; + assert.equal((await contender.commitAuthority(prepareCoordinationProjectionCommit({goal_id: goal, + operation_id: "racing-peer", expected_provider_revision: before.provider_revision, projection: before.head, + mutations: [{kind: "todo_upsert", todo: {...peer, note: "Peer update survives"}}]}))).status, "applied"); + return target.commitAuthority(...args); + }; + const member = Reflect.get(target, key); + return typeof member === "function" ? member.bind(target) : member; + }}); + const rejected = await execute(racing, request, async () => true, checkBasis); + assert.equal(rejected.conflict_kind, "provider_revision_mismatch", JSON.stringify(rejected)); + assert.ok(attemptedId); + assert.equal((await store.readReceipt(attemptedId)).status, "missing"); + const current = await loaded(store); + assert.equal((current.head.todos as JsonObject[]).find(todo => todo.todo_id === request.todo_id)!.status, "open"); + const completed = await execute(store, request, async () => true, checkBasis); + assert.equal(completed.status, "applied", JSON.stringify(completed)); + assert.deepEqual([...new Set(checkedHeads)], [before.provider_revision, current.provider_revision]); + const after = await loaded(store); + assert.equal((after.head.todos as JsonObject[]).find(todo => todo.todo_id === peer.todo_id)!.note, "Peer update survives"); + const replay = await execute(contender, request, async () => {throw new Error("history before authority");}, + async () => {throw new Error("history before freshness");}); + assert.equal(replay.status, "replayed"); + assert.deepEqual(await loaded(store), after); + }); for (const schema of ["legacy", "native"] as const) { test(`${provider}: supersede links existing work and retires its original lease atomically (${schema})`, async t => { const {store, contender} = await factory(t); From 53bf73652e8aabe41e72fed849ab988a204da22c Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 5 Oct 2026 19:30:28 +0800 Subject: [PATCH 03/18] docs(protocol): document first-delivery opt-in and recovery boundaries Signed-off-by: Tartar --- .../rfcs/loopx-overall-roadmap-v0.md | 11 +++ .../goal-vision-replan-contract-v0.md | 96 ++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index bb5505f84d..670427de88 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -956,6 +956,17 @@ independent admission without weakening task-level validation. See the [acceptance contract](../../reference/goal-acceptance-observations.md#owner-authorized-contract-v0). This narrows a local recovery gap, not the full R1/R2 coordination acceptance. +**R1 first delivery checkpoint.** The optional local-registry File/SQLite path +binds Todo result commits and subsequent direction decisions to separate read +bases. CLI, managed execution and MCP share the existing typed owners and +original-Turn receipts; managed reasoning occurs after allowed result commits, +while deferred no-followup preserves refresh/spend/terminal ordering. Frontier +membership, relevant dependencies, acceptance and source identity are checked at +the owning commit. See the [operating protocol](../../reference/protocols/goal-vision-replan-contract-v0.md#opt-in-first-delivery-freshness). +Unindexed artifacts remain unknown and require original-identity reconciliation. +This is local staged delivery, not distributed atomicity, source-session profile +qualification, independent Goal acceptance or evidence of improved Agent outcomes. + **R1 transaction checkpoint.** Team-plan admission and whole-batch planning now live in `work_items/team_plan.ts`. Confirmation assigns all admitted lanes in one write with a durable operation receipt; identity is proposal + lane, never Todo text. File/SQLite authority uses the existing CAS and receipt owner; legacy Markdown writes the records and immutable receipt together under its existing fence and lock. Exact replay reads historical results even after a receiver changes, completes or deletes work. A precommit failure creates no lane prefix, and pending canonical display delivery requires recovery before Chat reports verified success. The card names partial assignments and gaps; quota/stop remain advisory and an explicit enforcement claim is rejected. Agent-originated settlement binds the same state basis at its journal's first write and re-reads it at settlement; a plan whose basis is missing or moved, or whose every lane is a gap, is a typed failed receipt that creates no Todo and replays unchanged. This closes the local assignment/retry portion of F4, not R1's collaboration acceptance. Registered receivers are assigned without being impersonated as authors; agent-originated settlement cannot assign another peer without owner confirmation. Assignment does not attest receiver adoption, a lease, execution, dependency consumption or independent acceptance. Do not add a second confirmation to ordinary already-authorized work. Gap resolution requires new explicit intent; replay must not silently extend the confirmed subset. The fingerprint binds current local state and canonical revision, not a full shared Goal-intent transaction. R2/R3/R4 still own executor qualification, receiver adoption/result return and shared intent/authorization; the cross-host Turn lease is not a plan barrier. diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 0fdc0b60b8..5d0b47ab8d 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -406,11 +406,105 @@ must satisfy the checkpoint before terminal closeout; it neither re-authors the outcome nor spends a second time. Never invent an unchanged reason to clear a gap. Typed in-flight continuations keep their existing exemption. +### Opt-in first delivery freshness + +First delivery protection extends the built-in Todo, checkpoint and Turn owners; +it introduces no new provider or actor authority. It is off by default. Supported +profiles are local-registry Goals with File or SQLite canonical Todo authority +and an admitted, named Turn. Source-session GoalRef admission, other providers, +managed DSH/operation-tools execution and compound repair/replan effects are +rejected on this path. Existing non-opted-in behavior and checkpoint-only +recovery retain their separate admission rules. + +The stages are independent: prepare and validate the candidate, commit the +allowed result operation, read the resulting state, judge the direction outside +source/provider locks, commit that direction, then finish settlement. Managed +no-followup completion remains deferred until after refresh and quota spend. +Neither Todo completion nor a satisfied direction checkpoint certifies Goal +completion. + +After the ordinary quota guard admits the Turn, direct CLI callers read a result +basis before preparing the candidate: + +```sh +loopx --format json checkpoint-context --goal-id example --agent-id agent-a \ + --todo-id todo_page --turn-instance-id turn-1 --purpose delivery_result +``` + +Validate against that basis. Pass its `read_context_id` to `todo complete` as +`--delivery-read-context`, retaining the Goal, Agent, Todo, Turn and original +candidate/validation options. The Todo owner checks historical receipts first, +compares relevant facts against its current authority head, and commits an exact +delta and receipt with revision CAS. Unrelated revisions may retry without +another model decision. A related change requires rechecking the candidate. + +After the allowed result commit, read the direction: + +```sh +loopx --format json checkpoint-context --goal-id example --agent-id agent-a \ + --todo-id todo_page --turn-instance-id turn-1 --purpose first_delivery \ + --decision-scope goal +``` + +Read the returned basis and produce a new Vision or unchanged reason. Submit it +using the existing delivery fields plus `refresh-state --first-delivery +--checkpoint-read-context ID --progress-scope goal`. Both `agent_lane` and `goal` +scopes bind complete membership and dependency closure; no-followup requires +Goal scope. Reading more work grants no authority to write another Agent's work. +Final submission protects registry, Goal state, index and the real provider +through append. It rejects `--next-action` and Codex session usage booking as +compound effects; explicit usage observations can accompany the run. Lock order: +index, registry, maintenance, Todo projection, state, then provider. Model calls +and validation commands stay outside this section. + +Managed callers add `--first-delivery` to their existing qualified +`turn run-once --execute` command. The persisted plan retains enrollment on +`--resume-turn-key`; an unprotected adapter cannot recover it. The first Host +receives `delivery_result_context`. After validation and any allowed completion, +a separate direction-only Host receives current context and echoes its identity. +At most two direction attempts are allowed per Turn; these are real inference +calls, independently of the single quota debit. Failure, exhaustion or +`revalidate_result` retains the candidate and receipts for recovery. No-followup +also requires `terminal_ready`. Codex direction calls use an ephemeral read-only +session without MCP write tools. Generic adapters must honor the direction-only +IO contract. Retry a failed stage with the original resume key and +`--retry-failed-turn`; do not reexecute the completed implementation. + +MCP callers use `complete_task(first_delivery=true)`. The v2 protocol first +returns `result_review_pending` with a result context. Validate, then call again +with `delivery_read_context_id`; it commits the ordinary completion and returns +`direction_pending`. Judge that context and call again with both +`delivery_read_context_id` and `read_context_id`, plus the new Vision/reason. +`review_task_vision(first_delivery=true)` can reread a rejected direction; it +never attaches a new token automatically to an old Vision. Final no-followup +binds the committed direction and checks current work after spend. Existing +v0/v1 callers retain their contracts. + +Execution, status and quota readbacks expose `first_delivery_progress`: result and +direction commits, quota spend, pending stage and recovery action. Shared and Agent-scoped +status mirror recovery text into the existing next-action field used by the +dashboard and channel projections. These receipt observations are neither new +workflow authority nor a Goal completion signal. + +After a lost response, retry the original request. `checkpoint-context` for its +first-delivery identity verifies an indexed direction and artifacts before +returning `committed`. A proved empty append can retry after freshness validation. +JSON/Markdown without a complete consistent index remains +`checkpoint_commit_unknown`: preserve the original identity and artifacts for +operator reconciliation; another Turn cannot bypass that unresolved append. +The files, provider, quota and Git are not one transaction. + +To disable, omit the opt-in for **new** Turns. Finish or explicitly isolate +enrolled pending Turns with a compatible runtime before downgrading. Preserve +receipts and successful artifacts. Existing integration receipts, exact candidate +SHA validation and ref CAS still own code publication. This adds no cross-host, +PostgreSQL or model-quality guarantee. + ### Read basis for checkpoint-only recovery Missing-checkpoint supplementation now requires an explicit read receipt. This is a default admission change for both legacy and newly committed Turn writebacks; -normal first writebacks and non-Turn vision authoring retain their existing rules. +non-opted-in first writebacks and non-Turn vision authoring retain their existing rules. From the original working directory and with the original registry/runtime/project/ state-file options, read the basis for the exact settlement: From b8efd1db2a34566df63fb5d8307f2634a4bf7ecd Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 5 Oct 2026 19:47:55 +0800 Subject: [PATCH 04/18] refactor(turn): separate direction Host IO and qualify bounded recovery Signed-off-by: Tartar --- ...{first_delivery.py => direction_review.py} | 2 +- loopx/control_plane/turn_driver/executor.py | 2 +- .../turn_driver/first_delivery.ts | 2 +- .../test_checkpoint_provider_fence.py | 6 ++++-- .../test_first_delivery_managed.py | 19 +++++++++++++++---- .../checkpoint_commit_probe.ts | 3 ++- 6 files changed, 24 insertions(+), 10 deletions(-) rename loopx/control_plane/turn_driver/{first_delivery.py => direction_review.py} (98%) diff --git a/loopx/control_plane/turn_driver/first_delivery.py b/loopx/control_plane/turn_driver/direction_review.py similarity index 98% rename from loopx/control_plane/turn_driver/first_delivery.py rename to loopx/control_plane/turn_driver/direction_review.py index c34bedf04e..d07a720869 100644 --- a/loopx/control_plane/turn_driver/first_delivery.py +++ b/loopx/control_plane/turn_driver/direction_review.py @@ -1,4 +1,4 @@ -"""Bounded direction inference using the existing Turn journal and Host IO. +"""Host IO for bounded direction inference using the existing Turn journal. Todo/run receipts remain the commit authority. This module records which read was delivered to which inference, and never manufactures a fresh token for a diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 2636681076..15e005b695 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -1293,7 +1293,7 @@ def _first_delivery_stage( ) -> tuple[dict[str, Any], dict[str, Any] | None]: """Run the protected result/direction stage, preserving settlement ordering.""" if first_delivery_context is not None and "durable_writeback" not in completed_phases: - from .first_delivery import prepare_first_delivery + from .direction_review import prepare_first_delivery from ...usage_goal import observe_goal_execution try: diff --git a/loopx/control_plane/turn_driver/first_delivery.ts b/loopx/control_plane/turn_driver/first_delivery.ts index 52f5e7c94e..11cad37a30 100644 --- a/loopx/control_plane/turn_driver/first_delivery.ts +++ b/loopx/control_plane/turn_driver/first_delivery.ts @@ -16,7 +16,7 @@ export function evaluateFirstDelivery(value: unknown): JsonObject { quota_spent: request.quota_spent === true, goal_completion_certified: false, next_action: stage === "operation_unknown" ? "Read back the original Turn and its run artifacts; retain its identity." - : stage === "direction_pending" ? "Resume the original Turn or read first_delivery context and judge the current direction. Retain the committed result." + : stage === "direction_pending" ? "Result retained; review the current direction using the original Turn, then resume its remaining settlement." : stage === "settlement_pending" ? "Resume the original Turn's remaining settlement; retain successful writeback and quota receipts." : "Turn settlement is complete. Read the current Goal obligations before selecting further work."}; } diff --git a/tests/control_plane/test_checkpoint_provider_fence.py b/tests/control_plane/test_checkpoint_provider_fence.py index e8f31f4cde..f9fc8ed14f 100644 --- a/tests/control_plane/test_checkpoint_provider_fence.py +++ b/tests/control_plane/test_checkpoint_provider_fence.py @@ -249,7 +249,7 @@ def test_existing_cli_maintenance_guard_precedes_provider_commit(tmp_path, monke @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("first_delivery", [False, True]) -def test_provider_transaction_cannot_commit_between_final_head_and_checkpoint(tmp_path, monkeypatch, provider, first_delivery): +def test_provider_transaction_cannot_commit_between_final_head_and_checkpoint(tmp_path, monkeypatch, record_property, provider, first_delivery): _, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=first_delivery) context = read() barrier = tmp_path / "barrier" @@ -282,7 +282,9 @@ def native(method, params, **kwargs): assert saved["ok"] and not saved["replayed"] assert json.loads((barrier / "runtime-replay").read_text())["replayed"] finish(writer) - assert json.loads((barrier / "provider-result").read_text())["status"] == "applied" + provider_result = json.loads((barrier / "provider-result").read_text()) + assert provider_result["status"] == "applied" + record_property("provider_commit_elapsed_ms", provider_result["elapsed_ms"]) return saved finally: (barrier / "release").touch() diff --git a/tests/control_plane/test_first_delivery_managed.py b/tests/control_plane/test_first_delivery_managed.py index 5f5778b2d5..beca0972d7 100644 --- a/tests/control_plane/test_first_delivery_managed.py +++ b/tests/control_plane/test_first_delivery_managed.py @@ -26,7 +26,7 @@ def _cli(args): @pytest.mark.parametrize("provider", ["file", "sqlite"]) -@pytest.mark.parametrize("interruption", [None, "result_response_lost", "direction_call_failed", "stale_direction", "deferred_terminal"]) +@pytest.mark.parametrize("interruption", [None, "result_response_lost", "direction_call_failed", "stale_direction", "deferred_terminal", "continuous_direction_change"]) def test_managed_first_delivery_judges_after_result_and_replays_without_host(tmp_path, monkeypatch, provider, interruption): isolate_sqlite_runtime(tmp_path, monkeypatch) project, runtime, registry = _write_live_fixture(tmp_path) @@ -76,19 +76,20 @@ def lose_once(**kwargs): raise OSError("injected response loss after result CAS") monkeypatch.setattr(turn_run_once, "write_turn_validated_completion", lose_once) - elif interruption == "stale_direction": + elif interruption in {"stale_direction", "continuous_direction_change"}: from loopx.cli_commands import turn_run_once original = turn_run_once.refresh_state_run def change_basis_once(**kwargs): if kwargs.get("first_delivery"): state.write_text(state.read_text(encoding="utf-8") + "\n## Direction update\nReview the current remaining frontier.\n", encoding="utf-8") - monkeypatch.setattr(turn_run_once, "refresh_state_run", original) + if interruption == "stale_direction": + monkeypatch.setattr(turn_run_once, "refresh_state_run", original) return original(**kwargs) monkeypatch.setattr(turn_run_once, "refresh_state_run", change_basis_once) elif interruption == "direction_call_failed": - from loopx.control_plane.turn_driver import first_delivery + from loopx.control_plane.turn_driver import direction_review as first_delivery original = first_delivery.review_first_delivery def fail_once(**kwargs): @@ -101,6 +102,16 @@ def fail_once(**kwargs): assert result["status"] == "failed", result assert result["first_delivery_progress"]["stage"] in {"direction_pending", "operation_unknown"} code, result = _cli([*argv[:-1], "--resume-turn-key", result["resume_turn_key"], "--retry-failed-turn", "--execute"]) + if interruption == "continuous_direction_change": + assert result["status"] == "failed", result + code, exhausted = _cli([*argv[:-1], "--resume-turn-key", result["resume_turn_key"], "--retry-failed-turn", "--execute"]) + assert exhausted["status"] == "failed", exhausted + assert "budget exhausted" in json.dumps(exhausted) + assert (host_project / "host-calls.txt").read_text().splitlines() == ["implementation", "direction", "direction"] + rows = [json.loads(line) for line in (runtime / "goals/loopx-turn-fixture/runs/index.jsonl").read_text().splitlines()] + assert not any(row["classification"] == "quota_slot_spent" for row in rows) + assert not any(row.get("vision_checkpoint", {}).get("read_context") for row in rows) + return assert code == 0 and result["status"] == "committed", json.dumps(result) assert result["first_delivery_progress"]["stage"] == "settled" calls = ["implementation", "direction"] + (["direction"] if interruption == "stale_direction" else []) diff --git a/tests/control_plane_ts/checkpoint_commit_probe.ts b/tests/control_plane_ts/checkpoint_commit_probe.ts index 06c81b1675..de8222bfc6 100644 --- a/tests/control_plane_ts/checkpoint_commit_probe.ts +++ b/tests/control_plane_ts/checkpoint_commit_probe.ts @@ -68,9 +68,10 @@ if (input.mode === "checkpoint") { } else if (input.mode === "writer") { const original = prototype.commitAuthority; prototype.commitAuthority = async function(commit) { + const started = performance.now(); signal("writer-entered"); const result = await original.call(this as SqliteAuthorityStore & FileAuthorityStore, commit); - signal("provider-result", result); + signal("provider-result", {...result, elapsed_ms: performance.now() - started}); return result; }; } From 59b15856cbe6cb0a84f3950ab01daa0c34577a16 Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 5 Oct 2026 19:52:24 +0800 Subject: [PATCH 05/18] docs(protocol): clarify exact-candidate Git recovery Signed-off-by: Tartar --- .../protocols/goal-vision-replan-contract-v0.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 5d0b47ab8d..5684d82389 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -500,6 +500,15 @@ receipts and successful artifacts. Existing integration receipts, exact candidat SHA validation and ref CAS still own code publication. This adds no cross-host, PostgreSQL or model-quality guarantee. +Code publication remains a separate operation. Qualify ref CAS against an +integration target that is not checked out; this protocol does not wrap direct +Git writes or promise concurrent working-directory isolation. If publication +responds ambiguously, read the integration status and exact current SHA, verify +that combined candidate, then use the existing `integration-branch sync +--candidate-ref SHA --execute` recovery. This can adopt the already published +candidate without moving the branch again. A clean merge is not task acceptance: +run the task verifier on that combined SHA before confirming its result. + ### Read basis for checkpoint-only recovery Missing-checkpoint supplementation now requires an explicit read receipt. This is From 43fd546e61b1d1085b1b0a4927b46c2566251b98 Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 5 Oct 2026 20:49:51 +0800 Subject: [PATCH 06/18] fix(runtime): preserve typed startup errors for directory locators Signed-off-by: Tartar --- .../test_effect_runtime_host_permission.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/control_plane/test_effect_runtime_host_permission.py b/tests/control_plane/test_effect_runtime_host_permission.py index a5d8085c75..c9aefcdf8f 100644 --- a/tests/control_plane/test_effect_runtime_host_permission.py +++ b/tests/control_plane/test_effect_runtime_host_permission.py @@ -268,6 +268,23 @@ def test_real_locator_missing_or_invalid_still_allows_discovery( assert locator.read_text() == contents +def test_directory_locator_is_unusable_even_when_open_reports_permission_denied( + tmp_path: Path, monkeypatch, +) -> None: + locator = tmp_path / "locator.json" + locator.mkdir() + original_read = Path.read_text + + def read(path, *args, **kwargs): + if path == locator: + raise PermissionError(errno.EACCES, "directory cannot be opened as a file") + return original_read(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", read) + assert effect_runtime._read_info(locator, fingerprint="fixture") is None + assert locator.is_dir(), "discovery must not remove a foreign locator" + + def test_real_locator_live_identity_is_unchanged(tmp_path: Path) -> None: locator = tmp_path / "locator.json" info = {"schema_version": effect_runtime.EFFECT_RUNTIME_INFO_SCHEMA_VERSION, From 74a1f5b7a72ef1b797dd5e3a27e00dac9e402631 Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 5 Oct 2026 20:53:45 +0800 Subject: [PATCH 07/18] test: repair delivery fixtures and portable contract checks Signed-off-by: Tartar --- .../project_registry_io_manifest_v1.json | 8 ++++---- .../test_first_delivery_managed.py | 3 +-- .../goal_acceptance_authority.test.ts | 8 +++++--- tests/test_goal_mode_mcp_settlement.py | 19 +++++++++++++++++++ 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index e8815dca6e..6c685cb007 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -967,7 +967,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#5", - "line": 672, + "line": 675, "column": 25, "kind": "codec_read", "api": "load_registry", @@ -975,7 +975,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#6", - "line": 743, + "line": 746, "column": 13, "kind": "codec_read", "api": "load_registry", @@ -983,7 +983,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#7", - "line": 788, + "line": 791, "column": 38, "kind": "codec_read", "api": "load_registry", @@ -2191,7 +2191,7 @@ }, { "site": "loopx/state_refresh.py::.refresh_state_run::codec_read:load_registry#1", - "line": 804, + "line": 808, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/tests/control_plane/test_first_delivery_managed.py b/tests/control_plane/test_first_delivery_managed.py index beca0972d7..c25e47fc3d 100644 --- a/tests/control_plane/test_first_delivery_managed.py +++ b/tests/control_plane/test_first_delivery_managed.py @@ -6,7 +6,6 @@ import io import json import sys -from pathlib import Path import pytest @@ -71,7 +70,7 @@ def test_managed_first_delivery_judges_after_result_and_replays_without_host(tmp original = turn_run_once.write_turn_validated_completion def lose_once(**kwargs): - result = original(**kwargs) + original(**kwargs) monkeypatch.setattr(turn_run_once, "write_turn_validated_completion", original) raise OSError("injected response loss after result CAS") diff --git a/tests/control_plane_ts/goal_acceptance_authority.test.ts b/tests/control_plane_ts/goal_acceptance_authority.test.ts index 459a28596d..683f96e432 100644 --- a/tests/control_plane_ts/goal_acceptance_authority.test.ts +++ b/tests/control_plane_ts/goal_acceptance_authority.test.ts @@ -28,9 +28,11 @@ import {decodeCompletionValidationRevision, planCompletionValidationRevision} const goal = "goal-acceptance-test"; test("documented owner configuration satisfies the canonical acceptance contract", async () => { const reference = await readFile(new URL("../../docs/reference/goal-acceptance-observations.md", import.meta.url), "utf8"); - const example = reference.match(/```json\n([\s\S]*?)\n```/); - assert.ok(example, "the operation guide must include a runnable configuration"); - assert.doesNotThrow(() => normalizeGoalAcceptanceDocument(JSON.parse(example[1]))); + for (const newline of ["\n", "\r\n"]) { + const example = reference.replace(/\r?\n/g, newline).match(/```json\r?\n([\s\S]*?)\r?\n```/); + assert.ok(example, "the operation guide must include a runnable configuration"); + assert.doesNotThrow(() => normalizeGoalAcceptanceDocument(JSON.parse(example[1]))); + } }); function todo(todo_id: string, extra: JsonObject = {}): JsonObject { diff --git a/tests/test_goal_mode_mcp_settlement.py b/tests/test_goal_mode_mcp_settlement.py index 5a76e5e292..d24101e38e 100644 --- a/tests/test_goal_mode_mcp_settlement.py +++ b/tests/test_goal_mode_mcp_settlement.py @@ -258,6 +258,25 @@ def _control(registry: Path) -> GoalModeMCPControlPlane: return control +def test_real_mcp_unconnected_delivery_is_rejected_before_settlement(tmp_path: Path) -> None: + registry, state_file = _write_fixture(tmp_path) + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["adapter"].pop("status") + registry.write_text(json.dumps(registration), encoding="utf-8") + added = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Deliver the bounded fixture.", task_class="advancement_task", claimed_by=AGENT_ID) + before = state_file.read_bytes() + + result = json.loads(_control(registry).complete_task(added["todo_id"], AGENT_ID, "Verified fixture.")) + + assert result["ok"] is False + assert result["settlement"]["failed_stage"] == "guard" + assert "delivery_not_allowed" in result["settlement"]["reason"] + assert state_file.read_bytes() == before + index = registry.parent / "runtime" / "goals" / GOAL_ID / "runs" / "index.jsonl" + assert not index.exists() or not index.read_bytes() + + def test_real_mcp_settlement_rejects_missing_writeback_then_commits_same_identity( tmp_path: Path, ) -> None: From 17dc9a89c207a348104c1f8806d0ce86faec0044 Mon Sep 17 00:00:00 2001 From: Tartar Date: Mon, 5 Oct 2026 21:20:15 +0800 Subject: [PATCH 08/18] refactor(control-plane): clear quota and Lark maintainability findings Signed-off-by: Tartar --- loopx/cli_commands/quota.py | 87 ++++++++++++--------- loopx/control_plane/quota/goal_boundary.py | 33 ++++---- loopx/extensions/lark/goal_topic_runtime.py | 16 ++-- 3 files changed, 76 insertions(+), 60 deletions(-) diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index d95da591bb..fb68a4fbce 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -463,6 +463,47 @@ def _emit_quota_result( return 0 if payload.get("ok") else 1 +def _read_back_committed_heartbeat( + payload: dict[str, object], + args: argparse.Namespace, + *, + runtime_root: Path, + turn_instance_id: str, + stall_observation: str, + requested_todo_id: str | None, + goal_ref: dict[str, str] | None, +) -> None: + """Expose selection only after the original heartbeat receipt is read back.""" + receipt = find_heartbeat_receipt( + runtime_root, + goal_id=args.goal_id, + agent_id=args.agent_id, + turn_instance_id=turn_instance_id, + goal_ref=goal_ref, + ) + if receipt: + rollout_event_value = payload.get("rollout_event") + rollout_event: Mapping[str, object] = ( + rollout_event_value if isinstance(rollout_event_value, Mapping) else {} + ) + payload["heartbeat_receipt"] = heartbeat_receipt_view( + receipt, + turn_instance_id=turn_instance_id, + status="committed" if rollout_event.get("appended") else "replayed", + ) + commit_requested_action_selection(payload, requested_todo_id=requested_todo_id) + else: + fail_heartbeat_receipt( + payload, + turn_instance_id=turn_instance_id, + stall_observation=stall_observation, + reason=( + "heartbeat receipt append could not be read back; retry " + "quota should-run with the same --turn-instance-id" + ), + ) + + def handle_quota_command( args: argparse.Namespace, *, @@ -839,45 +880,19 @@ def handle_quota_command( *(["goal_ref"] if goal_ref is not None else []), ], ) - receipt = find_heartbeat_receipt( - runtime_root, - goal_id=args.goal_id, - agent_id=args.agent_id, + _read_back_committed_heartbeat( + payload, + args, + runtime_root=runtime_root, turn_instance_id=heartbeat_turn_id, + stall_observation=heartbeat_stall_observation, + requested_todo_id=( + action_selection.requested_todo_id + if action_selection is not None + else None + ), goal_ref=goal_ref, ) - if receipt: - rollout_event_value = payload.get("rollout_event") - rollout_event: Mapping[str, object] = ( - rollout_event_value - if isinstance(rollout_event_value, Mapping) - else {} - ) - payload["heartbeat_receipt"] = heartbeat_receipt_view( - receipt, - turn_instance_id=heartbeat_turn_id, - status="committed" - if rollout_event.get("appended") - else "replayed", - ) - commit_requested_action_selection( - payload, - requested_todo_id=( - action_selection.requested_todo_id - if action_selection is not None - else None - ), - ) - else: - fail_heartbeat_receipt( - payload, - turn_instance_id=heartbeat_turn_id, - stall_observation=heartbeat_stall_observation, - reason=( - "heartbeat receipt append could not be read back; retry " - "quota should-run with the same --turn-instance-id" - ), - ) else: append_cli_rollout_event( payload, diff --git a/loopx/control_plane/quota/goal_boundary.py b/loopx/control_plane/quota/goal_boundary.py index 7335868065..86814a6244 100644 --- a/loopx/control_plane/quota/goal_boundary.py +++ b/loopx/control_plane/quota/goal_boundary.py @@ -439,23 +439,24 @@ def goal_boundary( if repository_identity and repository_identity.startswith("git:"): boundary["task_repository"] = repository_identity project_asset_source = item if item is not None else goal - if isinstance(project_asset_source, dict) and project_asset_source.get( - "project_asset" - ): - project_asset = project_asset_source.get("project_asset") - if isinstance(project_asset, dict): - if project_asset.get("stop_condition"): - boundary["stop_condition"] = project_asset.get("stop_condition") - if isinstance(project_asset.get("execution_profile"), dict): - boundary["execution_profile"] = ( - quota_execution_profile_boundary_summary( - project_asset["execution_profile"] - ) - ) - if isinstance(project_asset.get("orchestration"), dict): - boundary["orchestration"] = compact_orchestration_policy( - project_asset["orchestration"] + project_asset = ( + project_asset_source.get("project_asset") + if isinstance(project_asset_source, dict) + else None + ) + if isinstance(project_asset, dict): + if project_asset.get("stop_condition"): + boundary["stop_condition"] = project_asset.get("stop_condition") + if isinstance(project_asset.get("execution_profile"), dict): + boundary["execution_profile"] = ( + quota_execution_profile_boundary_summary( + project_asset["execution_profile"] ) + ) + if isinstance(project_asset.get("orchestration"), dict): + boundary["orchestration"] = compact_orchestration_policy( + project_asset["orchestration"] + ) # Model preferences belong to the current registry, not a stale asset snapshot. if spawn_policy is not None and "orchestration" in boundary: model_config = compact_orchestration_policy(spawn_policy).get("model_config") diff --git a/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index 4fba2c2507..a4c2cf11c9 100644 --- a/loopx/extensions/lark/goal_topic_runtime.py +++ b/loopx/extensions/lark/goal_topic_runtime.py @@ -193,7 +193,7 @@ def _default_process_factory(args: list[str]) -> subprocess.Popen[str]: def _target_for_profile_chat( - target_payload: Mapping[str, Any], + target_payload: Mapping[str, object], *, profile: str, chat_id: str, @@ -201,10 +201,10 @@ def _target_for_profile_chat( active_target_refs: set[str] | None = None, root_id: str = "", binding_payloads: Mapping[str, object] | None = None, -) -> tuple[str, Mapping[str, Any]] | None: +) -> tuple[str, Mapping[str, object]] | None: targets = target_payload.get("targets") targets = targets if isinstance(targets, Mapping) else {} - candidates: list[tuple[str, Mapping[str, Any]]] = [] + candidates: list[tuple[str, Mapping[str, object]]] = [] for target_ref, target in targets.items(): if active_target_refs is not None and str(target_ref) not in active_target_refs: continue @@ -243,7 +243,7 @@ def _target_for_profile_chat( def _topic_roots_for_target( - binding_payloads: Mapping[str, Any], *, target_ref: str + binding_payloads: Mapping[str, object], *, target_ref: str ) -> list[str]: roots: list[str] = [] for goal_id, payload in binding_payloads.items(): @@ -255,7 +255,7 @@ def _topic_roots_for_target( def _topic_roots_for_bindings( - bindings: list[Mapping[str, Any]], *, target_ref: str + bindings: list[Mapping[str, object]], *, target_ref: str ) -> list[str]: roots: list[str] = [] for binding in bindings: @@ -276,9 +276,9 @@ def _topic_roots_for_bindings( def _binding_payloads_for_target( - binding_payloads: Mapping[str, Any], *, target_ref: str -) -> dict[str, Mapping[str, Any]]: - selected: dict[str, Mapping[str, Any]] = {} + binding_payloads: Mapping[str, object], *, target_ref: str +) -> dict[str, Mapping[str, object]]: + selected: dict[str, Mapping[str, object]] = {} for goal_id, payload in binding_payloads.items(): if not isinstance(payload, Mapping): continue From 70bf63f483963b67cb355a1e1b1550b6d9dd98e8 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 6 Oct 2026 11:01:06 +0800 Subject: [PATCH 09/18] fix(checkpoint): isolate unavailable historical observations Signed-off-by: Tartar --- .../goal-vision-replan-contract-v0.md | 9 ++ .../control_plane/goals/checkpoint_commit.ts | 4 +- .../goals/checkpoint_context_io.py | 100 ++++++++++++++---- loopx/control_plane/status/first_delivery.py | 2 +- .../turn_driver/first_delivery.ts | 5 + loopx/quota.py | 2 +- .../project_registry_io_manifest_v1.json | 6 +- .../test_first_delivery_observation.py | 92 ++++++++++++++++ 8 files changed, 195 insertions(+), 25 deletions(-) create mode 100644 tests/control_plane/test_first_delivery_observation.py diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 5684d82389..99f8d97fdf 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -486,6 +486,15 @@ status mirror recovery text into the existing next-action field used by the dashboard and channel projections. These receipt observations are neither new workflow authority nor a Goal completion signal. +Malformed or unreadable historical receipts are reported as scoped +`observation_errors`, never as proof that no recovery is pending. If the damaged +receipt cannot be attributed to a Turn, `observation_unavailable` retains the +Goal-scoped warning while leaving unrelated lanes' next actions intact. An +enrolled Turn whose original readback is uncertain stays `operation_unknown`; +recovery writes remain blocked until the original artifacts can be reconciled. +Observation reuses one complete canonical Todo snapshot across the discovered +identities; it does not truncate older pending work. + After a lost response, retry the original request. `checkpoint-context` for its first-delivery identity verifies an indexed direction and artifacts before returning `committed`. A proved empty append can retry after freshness validation. diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts index 29afea38de..c048275fc2 100644 --- a/loopx/control_plane/goals/checkpoint_commit.ts +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -133,7 +133,9 @@ export function inspectCheckpointAttempt(value: unknown): JsonObject { const indexed = new Set(indexBytes(join(runsDir, "index.jsonl")).toString("utf8").split(/\r?\n/) .filter(line => line.trim()).map(line => jsonObject(JSON.parse(line).settlement_identity)?.effect_id)); for (const name of names.filter(name => name.endsWith(".json") && BARE_SHA256_PATTERN.test(name.slice(0, -5)))) { - const other = requireJsonObject(JSON.parse(readFileSync(join(contexts, name), "utf8")), "read receipt"); + let other: JsonObject; + try { other = requireJsonObject(JSON.parse(readFileSync(join(contexts, name), "utf8")), "read receipt"); } + catch { unknown("checkpoint read receipt is unavailable; preserve original Turn artifacts before retrying"); } const owner = jsonObject(other.identity); if (other.purpose === "first_delivery" && other.commit_attempt != null && owner?.effect_id !== identity.effect_id && owner?.agent_id === identity.agent_id && owner?.todo_id === identity.todo_id && !indexed.has(owner?.effect_id)) { diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index 0d7968b166..7678868320 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -61,11 +61,18 @@ def _receipt_path(root: Path, identity: SettlementIdentity, purpose: str = "supp return root / "goals" / identity.goal_id / "checkpoint-contexts" / f"{digest}.json" +def _read_context_receipt(path: Path) -> dict[str, Any]: + receipt = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(receipt, dict): + raise ValueError("checkpoint receipt must be an object") + return receipt + + def first_delivery_context_enrolled(root: Path, identity: SettlementIdentity) -> bool: """Read the persisted protocol selection; enrollment survives caller restart.""" for purpose in ("first_delivery", "delivery_result"): try: - receipt = json.loads(_receipt_path(root, identity, purpose).read_text(encoding="utf-8")) + receipt = _read_context_receipt(_receipt_path(root, identity, purpose)) except FileNotFoundError: continue if receipt.get("purpose") == purpose: @@ -84,28 +91,38 @@ def _inspect_attempt(root: Path, identity: SettlementIdentity) -> dict[str, Any] return result -def first_delivery_progress(root: Path, readback: Any) -> dict[str, Any] | None: +def first_delivery_progress( + root: Path, readback: Any, *, canonical_todos: list[dict[str, Any]] | None = None, + observation_unknown: bool = False, +) -> dict[str, Any] | None: """Observe protocol receipts for quota consumers; this grants no write authority. A snapshot may lag a concurrent writer. Ambiguity is displayed as unknown; only the locked replay path can resolve it or admit another operation. """ identity = readback.identity.value - if identity is None or not first_delivery_context_enrolled(root, identity): + if identity is None: return None from ..coordination.local_authority import read_canonical_todos_if_promoted committed = False - unknown = False + unknown = observation_unknown try: - canonical = read_canonical_todos_if_promoted(runtime_root=root, goal_id=identity.goal_id) - selected = next((todo for todo in (canonical or {}).get("todos", []) if todo["todo_id"] == identity.todo_id), {}) + if not first_delivery_context_enrolled(root, identity): + return None + except (ValueError, OSError): + unknown = True + try: + if canonical_todos is None: + canonical = read_canonical_todos_if_promoted(runtime_root=root, goal_id=identity.goal_id) + canonical_todos = (canonical or {}).get("todos", []) + selected = next((todo for todo in canonical_todos if todo["todo_id"] == identity.todo_id), {}) committed = selected.get("status") == "done" and selected.get("completion_turn_key") in {identity.effect_id, identity.turn_instance_id} direction_path = _receipt_path(root, identity) - direction = json.loads(direction_path.read_text(encoding="utf-8")) if direction_path.exists() else {} + direction = _read_context_receipt(direction_path) if direction_path.exists() else {} checkpoint = (readback.writeback_run or {}).get("vision_checkpoint", {}) direction_committed = checkpoint.get("satisfied") is True and checkpoint.get("read_context", {}).get("purpose") == "first_delivery" - unknown = bool(direction.get("commit_attempt")) and not direction_committed + unknown = unknown or (bool(direction.get("commit_attempt")) and not direction_committed) except (ValueError, OSError): direction_committed = False unknown = True @@ -118,21 +135,66 @@ def first_delivery_progress(root: Path, readback: Any) -> dict[str, Any] | None: def pending_first_delivery_progress(root: Path, goal_id: str, agent_id: str | None = None) -> dict[str, Any] | None: - """Bounded to this Agent's enrolled local Turns; readback, never admission.""" + """Isolate historical observation faults; admission still uses strict reads. + + An unreadable digest cannot be assigned to an Agent. Keep that Goal-scoped + uncertainty visible without replacing another lane's normal next action. + """ + from ..coordination.local_authority import read_canonical_todos_if_promoted + identities = {} + errors = [] for path in (root / "goals" / goal_id / "checkpoint-contexts").glob("*.json"): - receipt = json.loads(path.read_text(encoding="utf-8")) + try: + receipt = _read_context_receipt(path) + except (ValueError, OSError): + errors.append({"scope": "goal", "code": "checkpoint_receipt_unavailable", "receipt": path.stem}) + continue binding = receipt.get("identity", {}) - if receipt.get("purpose") in {"first_delivery", "delivery_result"} and (agent_id is None or binding.get("agent_id") == agent_id): - identities[binding["effect_id"]] = binding + if receipt.get("purpose") not in ("first_delivery", "delivery_result"): + continue + observed_agent = binding.get("agent_id") if isinstance(binding, dict) else None + if isinstance(observed_agent, str) and observed_agent and agent_id is not None and observed_agent != agent_id: + continue + try: + identity = SettlementIdentity.from_runtime_payload(binding) + if (receipt.get("schema_version") != "checkpoint_read_context_v2" or identity.goal_id != goal_id + or path != _receipt_path(root, identity, receipt["purpose"])): + raise ValueError("checkpoint receipt binding mismatch") + except (ValueError, RuntimeError): + errors.append({"scope": "goal", "code": "checkpoint_receipt_invalid", "receipt": path.stem}) + continue + identities[identity.effect_id] = identity.as_dict() + # Share the complete current authority snapshot across observed identities. + # Do not cap the historical scan or silently lose older pending work. + canonical_todos = None + if identities: + try: + canonical = read_canonical_todos_if_promoted(runtime_root=root, goal_id=goal_id) + canonical_todos = (canonical or {}).get("todos", []) + except (ValueError, OSError): + errors.append({"scope": "goal", "code": "checkpoint_authority_unavailable"}) for binding in reversed(list(identities.values())): - readback = read_heartbeat_settlement(root, goal_id=goal_id, agent_id=binding["agent_id"], - todo_id=binding.get("todo_id"), replan_obligation_id=binding.get("replan_obligation_id"), - turn_instance_id=binding["turn_instance_id"]) - if readback is not None: - progress = first_delivery_progress(root, readback) - if progress is not None and progress["stage"] != "settled": - return {**progress, "settlement_identity": binding} + try: + identity = SettlementIdentity.from_runtime_payload(binding) + observation_unknown = any(error.get("receipt") in { + _receipt_path(root, identity, purpose).stem for purpose in ("first_delivery", "delivery_result") + } for error in errors) + readback = read_heartbeat_settlement(root, goal_id=goal_id, agent_id=binding["agent_id"], + todo_id=binding.get("todo_id"), replan_obligation_id=binding.get("replan_obligation_id"), + turn_instance_id=binding["turn_instance_id"]) + if readback is None or canonical_todos is None: + raise ValueError("enrolled checkpoint readback unavailable") + progress = first_delivery_progress(root, readback, canonical_todos=canonical_todos, + observation_unknown=observation_unknown) + except (ValueError, OSError): + progress = effect_runtime_result("turn.first_delivery.evaluate", {"phase": "project", "unknown": True}) + if progress is not None and progress["stage"] != "settled": + return {**progress, "settlement_identity": binding, **({"observation_errors": errors} if errors else {})} + if errors: + return {**effect_runtime_result("turn.first_delivery.evaluate", { + "phase": "project", "observation_unavailable": True}), + "goal_id": goal_id, "agent_id": agent_id, "observation_errors": errors} return None diff --git a/loopx/control_plane/status/first_delivery.py b/loopx/control_plane/status/first_delivery.py index 76b6f0c15f..b8d85ab65e 100644 --- a/loopx/control_plane/status/first_delivery.py +++ b/loopx/control_plane/status/first_delivery.py @@ -15,7 +15,7 @@ def attach_first_delivery_status(payload: dict[str, Any], *, runtime_root: Path, if progress is None: continue item["first_delivery_progress"] = progress - if quota_item_is_paused(item) or item.get("requires_user_action") is True: + if not progress.get("settlement_identity") or quota_item_is_paused(item) or item.get("requires_user_action") is True: continue action = progress["next_action"] item["recommended_action"] = action diff --git a/loopx/control_plane/turn_driver/first_delivery.ts b/loopx/control_plane/turn_driver/first_delivery.ts index 11cad37a30..29d0d6759d 100644 --- a/loopx/control_plane/turn_driver/first_delivery.ts +++ b/loopx/control_plane/turn_driver/first_delivery.ts @@ -7,6 +7,11 @@ import {normalizeVisionUnchangedReason} from "../goals/vision_checkpoint.ts"; export function evaluateFirstDelivery(value: unknown): JsonObject { const request = requireJsonObject(value, "first delivery"); if (request.phase === "project") { + if (request.observation_unavailable === true) { + return {schema_version: "first_delivery_progress_v0", stage: "observation_unavailable", + goal_completion_certified: false, + next_action: "Inspect unavailable checkpoint observations before resuming the affected Turn."}; + } const stage = request.unknown === true ? "operation_unknown" : request.direction_committed !== true ? "direction_pending" : request.settlement_complete === true ? "settled" : "settlement_pending"; diff --git a/loopx/quota.py b/loopx/quota.py index 21b36a3de0..9857bbafaf 100644 --- a/loopx/quota.py +++ b/loopx/quota.py @@ -963,7 +963,7 @@ def build_quota_should_run( if delivery_progress is not None: payload["first_delivery_progress"] = delivery_progress # Observation cannot grant work or override an owner pause/health hold. - if payload.get("ok") and payload.get("state") != "paused": + if delivery_progress.get("settlement_identity") and payload.get("ok") and payload.get("state") != "paused": payload["recommended_action"] = delivery_progress["next_action"] return payload diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 6c685cb007..73b035012b 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -1183,7 +1183,7 @@ }, { "site": "loopx/control_plane/goals/checkpoint_commit.ts::::direct_json_read:readFileSync#1", - "line": 332, + "line": 334, "column": 34, "kind": "direct_json_read", "api": "readFileSync", @@ -1191,7 +1191,7 @@ }, { "site": "loopx/control_plane/goals/checkpoint_context_io.py::._local_source_facts::codec_read:load_registry#1", - "line": 237, + "line": 299, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -1199,7 +1199,7 @@ }, { "site": "loopx/control_plane/goals/checkpoint_context_io.py::.read_checkpoint_context::codec_read:load_registry#1", - "line": 262, + "line": 324, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/tests/control_plane/test_first_delivery_observation.py b/tests/control_plane/test_first_delivery_observation.py new file mode 100644 index 0000000000..522ddec0fe --- /dev/null +++ b/tests/control_plane/test_first_delivery_observation.py @@ -0,0 +1,92 @@ +"""Historical observation faults cannot become cross-lane admission failures.""" +from __future__ import annotations + +import hashlib +import json + +import pytest + +from loopx.control_plane.goals import checkpoint_context_io as context_io +from loopx.control_plane.quota.settlement import SettlementIdentity +from tests.control_plane.test_checkpoint_provider_fence import fixture +from tests.control_plane.test_quota_settlement_cli import AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _run_cli + + +def _status(project, runtime, registry, agent=AGENT_ID): + rc, payload = _run_cli(registry, runtime, "status", "--goal-id", GOAL_ID, + "--agent-id", agent, cwd=project) + assert rc == 0 and payload["ok"], payload + return payload["attention_queue"]["items"][0] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("corruption", ["{", "[]", '{"purpose":"first_delivery","identity":[]}']) +def test_unenrolled_lane_retains_status_and_scoped_unavailable_evidence(tmp_path, monkeypatch, provider, corruption): + project, runtime, registry, state, read, _ = fixture(tmp_path, monkeypatch, provider) + read() # Existing supplement receipts remain ordinary feature-off history. + before = _status(project, runtime, registry) + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + prior = (state.read_bytes(), index.read_bytes()) + directory = runtime / "goals" / GOAL_ID / "checkpoint-contexts" + damaged = directory / (hashlib.sha256(b"historical-context").hexdigest() + ".json") + damaged.write_text(corruption, encoding="utf-8") + after = _status(project, runtime, registry) + assert after["recommended_action"] == before["recommended_action"] + progress = after["first_delivery_progress"] + assert progress["stage"] == "observation_unavailable" + assert progress["observation_errors"][0]["scope"] == "goal" + assert progress["observation_errors"][0]["receipt"] == damaged.stem + assert "settlement_identity" not in progress + assert "result_committed" not in progress # Unavailable does not assert absence. + assert progress["goal_completion_certified"] is False + assert (state.read_bytes(), index.read_bytes()) == prior + assert damaged.read_text(encoding="utf-8") == corruption + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_other_agent_observation_does_not_override_current_lane(tmp_path, monkeypatch, provider): + project, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + read() + peer = "independent-peer" + registration = json.loads(registry.read_text(encoding="utf-8")) + registration["goals"][0]["coordination"]["registered_agents"].append(peer) + registry.write_text(json.dumps(registration), encoding="utf-8") + before = _status(project, runtime, registry, peer) + identity = SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) + receipt = context_io._receipt_path(runtime, identity) + receipt.write_text("{", encoding="utf-8") + after = _status(project, runtime, registry, peer) + assert after["recommended_action"] == before["recommended_action"] + assert after["first_delivery_progress"]["stage"] == "observation_unavailable" + # An intact other-Agent binding can be excluded even if its body is invalid. + receipt.write_text(json.dumps({"purpose": "first_delivery", "identity": identity.as_dict()}), encoding="utf-8") + assert context_io.pending_first_delivery_progress(runtime, GOAL_ID, peer) is None + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_enrolled_corruption_stays_unknown_and_cannot_admit_another_write(tmp_path, monkeypatch, provider): + project, runtime, registry, _, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + read() + result = context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, + purpose="delivery_result") + rc, completed = _run_cli(registry, runtime, "todo", "complete", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID, + "--delivery-read-context", result["read_context_id"], + "--task-lease-idempotency-key", f"checkpoint-{TODO_ID}", "--task-lease-expected-version", "1", + "--note", "Validated the selected output.", cwd=project) + assert rc == 0, json.dumps(completed) + identity = SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) + damaged = context_io._receipt_path(runtime, identity) + damaged.write_text("{", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = index.read_bytes() + progress = _status(project, runtime, registry)["first_delivery_progress"] + assert progress["stage"] == "operation_unknown" + assert progress["result_committed"] is True + assert progress["settlement_identity"] == identity.as_dict() + with pytest.raises(context_io.CheckpointReadContextRejected) as rejected: + read() + assert rejected.value.code == "checkpoint_commit_unknown" + assert index.read_bytes() == before + assert damaged.read_text(encoding="utf-8") == "{" From 14ae1063f284273d13b1619d3a219bb036b95c6a Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 6 Oct 2026 11:01:07 +0800 Subject: [PATCH 10/18] fix(build): normalize Turn contract source newlines Signed-off-by: Tartar --- .../turn_driver/turn_contract_generated.py | 2 +- .../turn_driver/turn_contract_generated.ts | 2 +- scripts/generate_turn_contract.py | 7 +++++-- .../test_turn_contract_generation.py | 16 ++++++++++++++++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/loopx/control_plane/turn_driver/turn_contract_generated.py b/loopx/control_plane/turn_driver/turn_contract_generated.py index 4e2ae8ff0b..0e43ba2a38 100644 --- a/loopx/control_plane/turn_driver/turn_contract_generated.py +++ b/loopx/control_plane/turn_driver/turn_contract_generated.py @@ -1,6 +1,6 @@ """Generated by scripts/generate_turn_contract.py; do not edit. Source: loopx/control_plane/turn_loop_controller_contract_v0.json -SHA256: 252c7ea204f59809779aa2fed4a7998d5b0579d46bb141bd95f3711553b750c4""" +SHA256: 184046656dada524b5412be128424b840dc1d490eef5bf8a0c9e9e5bb786492a""" from __future__ import annotations from enum import Enum diff --git a/loopx/control_plane/turn_driver/turn_contract_generated.ts b/loopx/control_plane/turn_driver/turn_contract_generated.ts index 311bf21e37..601d47dc43 100644 --- a/loopx/control_plane/turn_driver/turn_contract_generated.ts +++ b/loopx/control_plane/turn_driver/turn_contract_generated.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate_turn_contract.py; do not edit. // Source: loopx/control_plane/turn_loop_controller_contract_v0.json -// SHA256: 252c7ea204f59809779aa2fed4a7998d5b0579d46bb141bd95f3711553b750c4 +// SHA256: 184046656dada524b5412be128424b840dc1d490eef5bf8a0c9e9e5bb786492a export const TURN_RESULT_KINDS = [ "validated_progress", diff --git a/scripts/generate_turn_contract.py b/scripts/generate_turn_contract.py index be772b9bad..7c4b48fae9 100644 --- a/scripts/generate_turn_contract.py +++ b/scripts/generate_turn_contract.py @@ -215,8 +215,11 @@ def require(condition, message): def build_artifacts(): - contract = validate_contract(read_contract()) - digest = hashlib.sha256(CONTRACT_PATH.read_bytes()).hexdigest() + # Universal-newline decoding gives LF and CRLF checkouts the same content + # identity. Parse and hash this one snapshot so provenance matches the data. + source = CONTRACT_PATH.read_text(encoding="utf-8") + contract = validate_contract(json.loads(source, object_pairs_hook=_unique_object)) + digest = hashlib.sha256(source.encode("utf-8")).hexdigest() header = f"Generated by scripts/generate_turn_contract.py; do not edit.\nSource: loopx/control_plane/turn_loop_controller_contract_v0.json\nSHA256: {digest}" py = [ '"""' + header + '"""', diff --git a/tests/architecture/test_turn_contract_generation.py b/tests/architecture/test_turn_contract_generation.py index 9bd8cf1e28..d04d102e1d 100644 --- a/tests/architecture/test_turn_contract_generation.py +++ b/tests/architecture/test_turn_contract_generation.py @@ -1,6 +1,7 @@ """Independent acceptance of Turn generation, rejection and source adoption.""" from copy import deepcopy +import hashlib import json import runpy import subprocess @@ -105,6 +106,21 @@ def test_generator_check_is_deterministic_and_does_not_repair(tmp_path, monkeypa assert path.read_text() == "stale\n" +def test_generation_has_one_content_identity_across_checkout_newlines(tmp_path, monkeypatch): + source = generator.CONTRACT_PATH.read_text(encoding="utf-8").encode("utf-8") + contract = tmp_path / "contract.json" + monkeypatch.setattr(generator, "CONTRACT_PATH", contract) + contract.write_bytes(source) + lf = generator.build_artifacts() + contract.write_bytes(source.replace(b"\n", b"\r\n")) + assert generator.build_artifacts() == lf + digest = hashlib.sha256(source).hexdigest() + assert all(f"SHA256: {digest}" in artifact for artifact in lf.values()) + # Newline normalization must not exempt other changes from provenance. + contract.write_bytes(source + b" ") + assert generator.build_artifacts() != lf + + def test_typescript_settlement_reexports_generated_result_set(): result = subprocess.run( [ From ef285a989c8fbeb99d0a74200ecbfa3a5801761a Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 6 Oct 2026 11:01:08 +0800 Subject: [PATCH 11/18] fix(host): preserve controller guards for staged delivery Signed-off-by: Tartar --- loopx/control_plane/turn_driver/codex_cli.py | 27 ++++++++++++-------- loopx/kunluncode_goal_mode/guards.py | 6 +++++ tests/test_kunluncode_goal_mode.py | 5 ++++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index 1ed1e74e4c..4e61f9e52f 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -650,6 +650,21 @@ def _codex_command( return command +def _checked_codex_session_id(planned_action: str, binding: Mapping[str, Any] | None) -> str | None: + """Keep executable session admission together before starting the process.""" + if planned_action == "resume" and binding is None: + raise RuntimeError("Codex CLI resume binding disappeared after planning") + if planned_action == "start_new" and binding is not None: + raise RuntimeError("Codex CLI session binding changed after planning") + if planned_action not in {"resume", "start_new"}: + raise ValueError("Codex CLI host request has no executable session action") + if binding and binding.get("operation_transport"): + raise ValueError( + "operation-equipped session requires its original managed transport; select a fresh iteration explicitly to change it" + ) + return str(binding.get("session_id")) if binding else None + + def run_codex_cli_host( request: Mapping[str, Any], *, @@ -711,17 +726,7 @@ def run_codex_cli_host( ) if not approved_write_resume: require_codex_session_profile(binding, profile_digest) - if planned_action == "resume" and binding is None: - raise RuntimeError("Codex CLI resume binding disappeared after planning") - if planned_action == "start_new" and binding is not None: - raise RuntimeError("Codex CLI session binding changed after planning") - if planned_action not in {"resume", "start_new"}: - raise ValueError("Codex CLI host request has no executable session action") - session_id = str(binding.get("session_id")) if binding else None - if binding and binding.get("operation_transport"): - raise ValueError( - "operation-equipped session requires its original managed transport; select a fresh iteration explicitly to change it" - ) + session_id = _checked_codex_session_id(planned_action, binding) goal_ref = request.get("goal_ref") exact_goal_ref = dict(goal_ref) if isinstance(goal_ref, Mapping) else None diff --git a/loopx/kunluncode_goal_mode/guards.py b/loopx/kunluncode_goal_mode/guards.py index 7779fe96df..330e3062ab 100644 --- a/loopx/kunluncode_goal_mode/guards.py +++ b/loopx/kunluncode_goal_mode/guards.py @@ -44,6 +44,9 @@ def blocked_complete( successor_todo_ids: list[str] | None = None, agent_vision: dict | None = None, vision_unchanged_reason: str = "", + first_delivery: bool = False, + delivery_read_context_id: str = "", + read_context_id: str = "", ) -> str: del ( next_agent_todo, @@ -53,6 +56,9 @@ def blocked_complete( successor_todo_ids, agent_vision, vision_unchanged_reason, + first_delivery, + delivery_read_context_id, + read_context_id, ) return _native_controller_rejection("complete_task") diff --git a/tests/test_kunluncode_goal_mode.py b/tests/test_kunluncode_goal_mode.py index fec72b0ccb..316ff4d90f 100644 --- a/tests/test_kunluncode_goal_mode.py +++ b/tests/test_kunluncode_goal_mode.py @@ -1249,8 +1249,10 @@ def test_strict_native_goal_requires_verification_passed_event() -> None: ) +@pytest.mark.parametrize("first_delivery", [False, True]) def test_native_controller_blocks_model_visible_mcp_mutations( monkeypatch: pytest.MonkeyPatch, + first_delivery: bool, ) -> None: server, control = create_fastmcp_server( GoalModeMCPConfig( @@ -1283,6 +1285,9 @@ def test_native_controller_blocks_model_visible_mcp_mutations( "evidence": "premature evidence", "task_lease_idempotency_key": "lease-fixture", "task_lease_expected_version": 7, + "first_delivery": first_delivery, + "delivery_read_context_id": "original-result", + "read_context_id": "original-direction", }, ) ) From 8743d79d514ca3e18fffac5f04b926dbe28ff69b Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 6 Oct 2026 12:24:23 +0800 Subject: [PATCH 12/18] fix(codex): preserve implementation binding on direction failure Signed-off-by: Tartar --- loopx/control_plane/turn_driver/codex_cli.py | 2 + tests/test_loopx_turn_codex_cli.py | 74 ++++++++++++++++++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index 4e61f9e52f..adf38f55ba 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -749,6 +749,8 @@ def commit() -> None: goal_admission.accept_result(commit) def discard_session() -> None: + if direction_only: + return def commit() -> None: _discard_codex_cli_session( runtime_root, diff --git a/tests/test_loopx_turn_codex_cli.py b/tests/test_loopx_turn_codex_cli.py index 022cb8e434..4d8a69eaf1 100644 --- a/tests/test_loopx_turn_codex_cli.py +++ b/tests/test_loopx_turn_codex_cli.py @@ -131,7 +131,7 @@ def _fake_codex(tmp_path: Path) -> tuple[Path, Path]: turn_key = re.search(r'"turn_key":"([^"]+)"', prompt).group(1) print(json.dumps({ "type": "thread.started", - "thread_id": "session-fixture-0001", + "thread_id": os.environ.get("FAKE_CODEX_SESSION_ID", "session-fixture-0001"), "raw_trajectory": "must-not-persist", "private_material": "must-not-persist" }), flush=True) @@ -194,6 +194,8 @@ def _fake_codex(tmp_path: Path) -> tuple[Path, Path]: "vision_unchanged_reason": "The fixture objective remains unchanged.", "summary": "One public fixture advanced." }), encoding="utf-8") +if os.environ.get("FAKE_CODEX_RESULT"): + output_path.write_text(os.environ["FAKE_CODEX_RESULT"], encoding="utf-8") """ executable.write_text( source.replace( @@ -1243,9 +1245,16 @@ def test_codex_cli_host_fails_closed_when_output_observation_is_incomplete( assert error.recovery_kind is None -def test_codex_cli_host_discards_missing_resume_session( +@pytest.mark.parametrize(("diagnostic", "category"), [ + ("This model requires a newer version of Codex.", "model_requires_newer_codex"), + ("Session not found.", "session_missing"), + ("invalid_json_schema", "output_schema_rejected"), +]) +def test_codex_cli_host_discards_invalid_resume_session( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + diagnostic: str, + category: str, ) -> None: executable, log_path = _fake_codex(tmp_path) monkeypatch.setenv("FAKE_CODEX_LOG", str(log_path)) @@ -1262,8 +1271,8 @@ def test_codex_cli_host_discards_missing_resume_session( ) monkeypatch.setenv("FAKE_CODEX_FAIL", "1") - monkeypatch.setenv("FAKE_CODEX_FAILURE_CATEGORY", "session") - with pytest.raises(RuntimeError, match="codex_cli_session_missing"): + monkeypatch.setenv("FAKE_CODEX_FAILURE_STDERR", diagnostic) + with pytest.raises(BuiltInHostError, match=f"codex_cli_{category}"): run_codex_cli_host( _request( turn_key="sha256:" + "f" * 64, @@ -1280,6 +1289,63 @@ def test_codex_cli_host_discards_missing_resume_session( assert codex_cli_session_binding(runtime_root, envelope) is None +@pytest.mark.parametrize(("diagnostic", "category"), [ + (None, None), + ("This model requires a newer version of Codex.", "model_requires_newer_codex"), + ("Session not found.", "session_missing"), + ("invalid_json_schema", "output_schema_rejected"), +]) +def test_direction_session_never_mutates_implementation_binding( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + diagnostic: str | None, category: str | None, +) -> None: + executable, log_path = _fake_codex(tmp_path) + monkeypatch.setenv("FAKE_CODEX_LOG", str(log_path)) + project = tmp_path / "project" + project.mkdir() + runtime_root = tmp_path / "runtime" + options = dict(runtime_root=runtime_root, project=project, + codex_bin=str(executable), sandbox="workspace-write", timeout_seconds=5) + first = _request() + run_codex_cli_host(first, **options) + binding_path, = runtime_root.glob("goals/*/turn-sessions/*.json") + original = binding_path.read_bytes() + resumed = _request(turn_key="sha256:" + "b" * 64, session_action="resume") + direction = {**first, "direction_review": {"context": {"read_context_id": "direction-context"}}} + response = {"read_context_id": "direction-context", "decision": "continue", + "vision_unchanged_reason": "The current direction remains applicable."} + with monkeypatch.context() as direction_environment: + direction_environment.setenv("FAKE_CODEX_SESSION_ID", "temporary-direction-session") + direction_environment.setenv("FAKE_CODEX_RESULT", json.dumps(response)) + if diagnostic: + direction_environment.setenv("FAKE_CODEX_FAIL", "1") + direction_environment.setenv("FAKE_CODEX_FAILURE_STDERR", diagnostic) + with pytest.raises(BuiltInHostError, match=f"codex_cli_{category}"): + run_codex_cli_host(direction, **options) + else: + assert run_codex_cli_host(direction, **options) == response + assert binding_path.read_bytes() == original + assert list(runtime_root.glob("goals/*/turn-sessions/*.json")) == [binding_path] + # A new interpreter must recover from the persisted binding, not in-memory state. + cold_resume = subprocess.run([ + sys.executable, "-c", """ +import json, sys +from pathlib import Path +from loopx.control_plane.turn_driver.codex_cli import run_codex_cli_host +result = run_codex_cli_host(json.loads(sys.argv[1]), runtime_root=Path(sys.argv[2]), + project=Path(sys.argv[3]), codex_bin=sys.argv[4], sandbox="workspace-write", timeout_seconds=5) +print(json.dumps(result)) +""", json.dumps(resumed), str(runtime_root), str(project), str(executable), + ], cwd=Path(__file__).resolve().parents[1], text=True, capture_output=True, timeout=15) + assert cold_resume.returncode == 0, cold_resume.stderr + assert json.loads(cold_resume.stdout)["turn_key"] == resumed["turn_key"] + calls = [json.loads(line) for line in log_path.read_text().splitlines()] + assert len(calls) == 3 + assert "resume" not in calls[1] + assert calls[1][calls[1].index("--sandbox") + 1] == "read-only" + assert "resume" in calls[2] and "session-fixture-0001" in calls[2] + + def test_public_e2e_smoke_runs_n_transactions_on_one_session() -> None: root = Path(__file__).resolve().parents[1] result = subprocess.run( From 5088408de11425b4ef8a2cdd1a30b8bb9f17f897 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 6 Oct 2026 12:52:49 +0800 Subject: [PATCH 13/18] test(dashboard): preserve authoritative packaged fixture readback Signed-off-by: Tartar --- .../personal-workspace-browser/fixture.mjs | 6 +++++- .../typed-actions.mjs | 20 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index ee49febc26..510fe66c4c 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -1877,7 +1877,11 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true if (apply) { state.actionApplies.push(apply[1]); if (actionKinds.get(apply[1]) === "heartbeat.bind" && !state.allowNextHeartbeatApply) { - await route.fulfill({ contentType: "application/json", json: { ok: false, schema_version: "loopx_chat_action_gate_v1", error: "Host activation required", error_code: "protected_action", gate: { kind: "host_activation_required", summary: "需要 Codex App 宿主创建 Heartbeat 自动化。", next_action: "确认宿主自动化后重新验证。" }, write_attempted: false }, status: 409 }); + const gate = { kind: "host_activation_required", summary: "需要 Codex App 宿主创建 Heartbeat 自动化。", next_action: "确认宿主自动化后重新验证。" }; + // The production HTTP handler persists mark_gated before returning 409. + const proposal = { ...actionProposals.get(apply[1]), status: "gated", gate, updated_at: new Date().toISOString() }; + actionProposals.set(apply[1], proposal); + await route.fulfill({ contentType: "application/json", json: { ok: false, schema_version: "loopx_chat_action_gate_v1", error: "Host activation required", error_code: "protected_action", gate, proposal, write_attempted: false }, status: 409 }); return; } if (actionKinds.get(apply[1]) === "heartbeat.bind") state.allowNextHeartbeatApply = false; diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index 20092bfb88..c6f703d7a3 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -1306,6 +1306,10 @@ export const typedActionsScenario = { assert.equal(await reviewUnit.inputValue(), "completed_todos"); assert.equal(await reviewCount.inputValue(), "3"); assert.deepEqual(await reviewUnit.locator("option").evaluateAll((options) => options.map((option) => option.value).filter(Boolean)), ["completed_todos", "effective_turns"]); + await reviewUnit.selectOption("effective_turns"); + if (await reviewCount.inputValue() !== "3" || api.machineConfigurationRequests.length !== requestsBeforeReadOnly) { + throw new Error("Editing the review unit changed its count or wrote without a reviewed preview"); + } await page.getByText(/不会创建 Turn、消耗配额或授予权限/u).waitFor({ state: "visible" }); await machineCatalog.getByRole("button", { name: /^变更质量验证/ }).click(); for (const label of [/^启用$/u, /^允许一次有界安全修复$/u, /^要求精确 diff 回执$/u]) { @@ -1746,8 +1750,22 @@ export const typedActionsScenario = { await page.getByRole("button", {name: "设置 Heartbeat", exact: true}).click(); await page.getByRole("dialog", {name: "Goal Heartbeat", exact: true}).getByRole("button", {name: "检查配置"}).click(); await page.getByText("确认执行").waitFor({ state: "visible" }); + const heartbeatApplyResponse = page.waitForResponse(response => /\/api\/actions\/.+\/apply$/.test(new URL(response.url()).pathname)); await page.getByRole("button", { name: "确认并应用", exact: true }).click(); - await page.getByText("需要宿主确认").waitFor({ state: "visible" }); + const heartbeatGate = await heartbeatApplyResponse; + const heartbeatGatePayload = await heartbeatGate.json(); + assert.equal(heartbeatGate.status(), 409, JSON.stringify(heartbeatGatePayload)); + assert.equal(heartbeatGatePayload.gate?.kind, "host_activation_required"); + const storedHeartbeat = await page.evaluate(async proposalId => { + const response = await fetch("/api/actions"); + return (await response.json()).proposals.find(proposal => proposal.proposal_id === proposalId); + }, api.actionApplies.at(-1)); + assert.equal(storedHeartbeat?.status, "gated", "Heartbeat gate must survive authoritative readback"); + try { + await page.getByText("需要宿主确认").waitFor({ state: "visible" }); + } catch (error) { + throw new Error(`Heartbeat gate readback: ${await page.locator('.personal-context-drawer').innerText()}; response=${JSON.stringify(heartbeatGatePayload)}`, { cause: error }); + } if (api.durableWriteCount !== writesBeforeHeartbeat) throw new Error("Protected heartbeat gate wrote durable state"); pass(8, "Agent semantic protected intent creates only a typed preview, while discussion and targetless requests remain conversational and all protected-gate paths perform zero durable writes before confirmation."); pass(11, "Heartbeat apply surfaced an explicit host-activation gate."); From 4bceb4142fc3538afb9d0bc763ced0b82f251809 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 6 Oct 2026 15:05:09 +0800 Subject: [PATCH 14/18] fix(todos): recover ordinary completion before optional enrollment errors Signed-off-by: Tartar --- .../coordination/local_authority_runtime.ts | 2 +- .../coordination/todo_delivery_context.ts | 33 +++++- .../coordination/todo_terminal_lifecycle.ts | 2 +- .../goals/checkpoint_context_io.py | 11 +- .../project_registry_io_manifest_v1.json | 4 +- .../test_checkpoint_read_context.py | 10 ++ .../test_first_delivery_terminal.py | 103 ++++++++++++++++++ 7 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 tests/control_plane/test_first_delivery_terminal.py diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 16a9e2e68e..254ff59008 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1301,7 +1301,7 @@ export async function terminalLifecycleLocalCoordinationTodo( sourceAuthority = sourceAuthorityFor(store); providerEvidence.source_authority = sourceAuthority; const execute = () => executeCoordinationTodoTerminalLifecycle(store, { - ...(input.delivery_context == null ? {} : {delivery_read_context_id: + ...(input.delivery_context == null || requireJsonObject(input.delivery_context, "delivery context").read_context_id == null ? {} : {delivery_read_context_id: requireAuthorityStoreId(requireJsonObject(input.delivery_context, "delivery context").read_context_id, "delivery read context id")}), ...(input.delivery_context == null || requireJsonObject(input.delivery_context, "delivery context").direction_read_context_id == null ? {} : { delivery_direction_context_id: requireAuthorityStoreId(requireJsonObject(input.delivery_context, "delivery context").direction_read_context_id, "direction read context id")}), diff --git a/loopx/control_plane/coordination/todo_delivery_context.ts b/loopx/control_plane/coordination/todo_delivery_context.ts index b5c4471064..53860eedea 100644 --- a/loopx/control_plane/coordination/todo_delivery_context.ts +++ b/loopx/control_plane/coordination/todo_delivery_context.ts @@ -16,6 +16,8 @@ import {legacyCoordinationTodoLockPath} from "./legacy_writer_lock_paths.ts"; import {checkpointProviderFacts} from "../goals/checkpoint_authority.ts"; import {evaluateCheckpointReadContext} from "../goals/checkpoint_read_context.ts"; import {inspectCheckpointReplay} from "../goals/checkpoint_commit.ts"; +import {QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, readQuotaSettlementFromSnapshot, + readQuotaSettlementSnapshot} from "../quota/settlement_readback.ts"; const digest = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); function bytes(path: string): Buffer { @@ -53,13 +55,38 @@ export function terminalDeliveryBasisCheck(root: string, input: JsonObject, stor const rejected = (code: string, error: string): JsonObject => ({ok: false, error_code: code, error, reread_required: true, next_action: "Read delivery_result context for the original Turn; recheck the candidate and validation before retrying."}); return async head => { - if (context.capture_error != null) return rejected("delivery_source_unavailable", String(context.capture_error)); - const facts = requireJsonObject(context.facts, "delivery source facts"); if (canonicalAuthoritySha256(binding) !== canonicalAuthoritySha256(settlementIdentityPayload(identity)) || identity.goal_id !== input.goal_id || identity.todo_id !== input.todo_id || identity.agent_id !== input.actor_agent_id || ![identity.effect_id, identity.turn_instance_id].includes(String(input.requested_completion_turn_key))) { return rejected("delivery_identity_mismatch", "Delivery basis belongs to another Goal, Agent, Todo or Turn."); } + const receiptPath = join(root, "goals", identity.goal_id, "checkpoint-contexts", + `${createHash("sha256").update(identity.effect_id + ":delivery_result").digest("hex")}.json`); + if (context.read_context_id == null) { + // A malformed shared supplement is optional only when the original + // writeback proves the ordinary path. A first-delivery read cannot be + // admitted after that writeback; absent history is not such proof. + const unknown = () => rejected("checkpoint_commit_unknown", + "Cannot establish optional enrollment from the original Turn; preserve its receipts and reconcile before retrying."); + try { + try { readFileSync(receiptPath); return unknown(); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const snapshot = await readQuotaSettlementSnapshot(root, identity.goal_id); + const readback = readQuotaSettlementFromSnapshot({ + ...binding, schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, runtime_root: root, + infer_turn_instance_id: false, allow_unbound_binding: false, + }, snapshot); + const prior = jsonObject(readback.writeback_run); + const writeback = jsonObject(jsonObject(readback.writeback)?.result); + const purpose = jsonObject(jsonObject(prior?.vision_checkpoint)?.read_context)?.purpose; + if (prior === null || writeback?.failure !== null || + (purpose != null && purpose !== "supplement_checkpoint")) return unknown(); + inspectCheckpointReplay({runtime_root: root, goal_id: identity.goal_id, prior}); + return {ok: true}; + } catch { return unknown(); } + } + if (context.capture_error != null) return rejected("delivery_source_unavailable", String(context.capture_error)); + const facts = requireJsonObject(context.facts, "delivery source facts"); const authority = authorityStoreSourceAuthority(store); if (authority !== "file_v0" && authority !== "sqlite_v0") { return rejected("delivery_provider_unsupported", "Delivery freshness requires File or SQLite authority."); @@ -73,8 +100,6 @@ export function terminalDeliveryBasisCheck(root: string, input: JsonObject, stor } const storeId = await store.storeIdentity(); if (storeId.status !== "available") return rejected("delivery_store_unavailable", "Cannot establish the current authority store identity."); - const receiptPath = join(root, "goals", identity.goal_id, "checkpoint-contexts", - `${createHash("sha256").update(identity.effect_id + ":delivery_result").digest("hex")}.json`); let receipt: JsonObject | null = null; try { receipt = jsonObject(JSON.parse(readFileSync(receiptPath, "utf8"))); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index ac98a00d1b..0862c89f3d 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -1100,7 +1100,7 @@ export async function executeCoordinationTodoTerminalLifecycle( } head = observation.authority; } - if (input.delivery_read_context_id !== undefined) { + if (input.delivery_read_context_id !== undefined || deliveryBasisCheck !== undefined) { if (deliveryBasisCheck === undefined) return terminalFailure("delivery_basis_check_required", "Protected completion requires its source and provider basis check.", {}, "decision_rejection"); const basis = await deliveryBasisCheck(head); diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index 7678868320..99e74e475b 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -208,10 +208,17 @@ def delivery_result_context_input( The native owner recovers a historical receipt before checking capture errors. Every validation continuation recaptures sources under short locks. """ - if not read_context_id and not direction_read_context_id and not first_delivery_context_enrolled(runtime_root, identity): - return None result: dict[str, Any] = {"identity": identity.as_dict(), "read_context_id": read_context_id or "missing", "state_file": str(state_file.resolve())} + if not read_context_id and not direction_read_context_id: + try: + if not first_delivery_context_enrolled(runtime_root, identity): + return None + except (ValueError, OSError) as error: + # Preserve the ordinary request fingerprint for receipt-first + # recovery. Only the native owner can prove an unreadable shared + # supplement optional; this is not evidence of non-enrollment. + return {**result, "read_context_id": None, "capture_error": str(error)} if direction_read_context_id: result["direction_read_context_id"] = direction_read_context_id index = runtime_root / "goals" / identity.goal_id / "runs" / "index.jsonl" diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 73b035012b..cd1d7399d5 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -1191,7 +1191,7 @@ }, { "site": "loopx/control_plane/goals/checkpoint_context_io.py::._local_source_facts::codec_read:load_registry#1", - "line": 299, + "line": 306, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -1199,7 +1199,7 @@ }, { "site": "loopx/control_plane/goals/checkpoint_context_io.py::.read_checkpoint_context::codec_read:load_registry#1", - "line": 324, + "line": 331, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/tests/control_plane/test_checkpoint_read_context.py b/tests/control_plane/test_checkpoint_read_context.py index 27782bb53a..0ad4ce8d2e 100644 --- a/tests/control_plane/test_checkpoint_read_context.py +++ b/tests/control_plane/test_checkpoint_read_context.py @@ -98,6 +98,7 @@ def test_first_delivery_context_cli_rejects_stale_and_replays_success(tmp_path, @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_delivery_result_basis_precedes_cas_and_replays_before_current_freshness(tmp_path, monkeypatch, provider): from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection isolate_sqlite_runtime(tmp_path, monkeypatch) @@ -118,11 +119,20 @@ def test_delivery_result_basis_precedes_cas_and_replays_before_current_freshness rc, context = _run_cli(registry, runtime, *read, cwd=project) assert rc == 0, context complete = ("todo", "complete", *binding, "--note", "Validated the candidate; direction review remains.") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + def committed_sources(): + return read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID), index.read_bytes() if index.exists() else None, _spend_run_count(runtime) + before = committed_sources() rc, missing = _run_cli(registry, runtime, *complete, cwd=project) assert rc == 1 and missing.get("error_code") == "checkpoint_read_context_unknown_or_replaced", missing + assert committed_sources() == before + rc, unknown = _run_cli(registry, runtime, *complete, "--delivery-read-context", "unknown-context", cwd=project) + assert rc == 1 and unknown.get("error_code") == "checkpoint_read_context_unknown_or_replaced", unknown + assert committed_sources() == before state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance\n\nAdditional acceptance requirement.\n", encoding="utf-8") rc, stale = _run_cli(registry, runtime, *complete, "--delivery-read-context", context["read_context_id"], cwd=project) assert rc == 1 and stale.get("error_code") == "checkpoint_read_context_stale", json.dumps(stale) + assert committed_sources() == before rc, context = _run_cli(registry, runtime, *read, cwd=project) assert rc == 0, context commit = (*complete, "--delivery-read-context", context["read_context_id"]) diff --git a/tests/control_plane/test_first_delivery_terminal.py b/tests/control_plane/test_first_delivery_terminal.py new file mode 100644 index 0000000000..a821ce1e2f --- /dev/null +++ b/tests/control_plane/test_first_delivery_terminal.py @@ -0,0 +1,103 @@ +"""Optional enrollment must not obstruct ordinary terminal commits or replay.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.goals import checkpoint_context_io as context_io +from loopx.control_plane.quota.settlement import SettlementIdentity +from tests.control_plane.test_checkpoint_provider_fence import fixture +from tests.control_plane.checkpoint_process import refresh +from tests.control_plane.test_quota_settlement_cli import AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _run_cli, _spend_run_count + + +def _complete(project, runtime, registry, *extra): + return _run_cli(registry, runtime, "todo", "complete", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID, + "--task-lease-idempotency-key", f"checkpoint-{TODO_ID}", "--task-lease-expected-version", "1", + "--note", "Validated the selected output.", *extra, cwd=project) + + +def _authority(runtime): + return read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + + +def _identity(): + return SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("history", ["none", "supplement", "damaged", "damaged_after_commit", "damaged_after_commit_missing_writeback"]) +def test_ordinary_completion_and_cold_replay_survive_optional_history(tmp_path, monkeypatch, provider, history): + project, runtime, registry, _, read, original = fixture(tmp_path, monkeypatch, provider) + receipt = context_io._receipt_path(runtime, _identity()) + if history != "none": + read() + if history == "damaged": + receipt.write_text("{", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before_index = index.read_bytes() + rc, saved = _complete(project, runtime, registry) + assert rc == 0 and saved["completed"], json.dumps(saved) + committed = _authority(runtime) + assert next(todo for todo in committed["todos"] if todo["todo_id"] == TODO_ID)["status"] == "done" + if history.startswith("damaged_after_commit"): + receipt.write_text("{", encoding="utf-8") + if history == "damaged_after_commit_missing_writeback": + Path(original["json_path"]).unlink() + # Every CLI call is a fresh process against the same real provider/files. + rc, replay = _complete(project, runtime, registry) + assert rc == 0 and replay["idempotent_replay"], replay + assert _authority(runtime) == committed + assert index.read_bytes() == before_index + assert _spend_run_count(runtime) == 0 + if history.startswith("damaged"): + assert receipt.read_text(encoding="utf-8") == "{" + rc, conflict = _complete(project, runtime, registry, "--task-lease-idempotency-key", "different-request") + assert rc == 1, conflict + assert _authority(runtime) == committed + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("enrollment", ["direction", "result", "both"]) +def test_unreadable_enrollment_without_ordinary_writeback_stays_closed(tmp_path, monkeypatch, provider, enrollment): + project, runtime, registry, state, read, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + if enrollment != "result": + read() + context_io._receipt_path(runtime, _identity()).write_text("{", encoding="utf-8") + if enrollment != "direction": + context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, purpose="delivery_result") + context_io._receipt_path(runtime, _identity(), "delivery_result").write_text("{", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = (_authority(runtime), state.read_bytes(), index.read_bytes()) + rc, rejected = _complete(project, runtime, registry) + assert rc == 1, rejected + assert rejected["error_code"] == "checkpoint_commit_unknown", rejected + assert (_authority(runtime), state.read_bytes(), index.read_bytes()) == before + assert _spend_run_count(runtime) == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("uncertainty", ["writeback_damaged", "result_empty", "result_damaged", "enrolled_writeback"]) +def test_unreadable_enrollment_requires_verified_ordinary_history(tmp_path, monkeypatch, provider, uncertainty): + enrolled = uncertainty == "enrolled_writeback" + project, runtime, registry, state, read, original = fixture(tmp_path, monkeypatch, provider, first_delivery=enrolled) + token = read()["read_context_id"] + if enrolled: + refresh(registry, runtime, token, first_delivery=True) + context_io._receipt_path(runtime, _identity()).write_text("{", encoding="utf-8") + if uncertainty == "writeback_damaged": + Path(original["json_path"]).write_text("{", encoding="utf-8") + elif uncertainty.startswith("result_"): + context_io._receipt_path(runtime, _identity(), "delivery_result").write_text( + "" if uncertainty == "result_empty" else "{", encoding="utf-8") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = (_authority(runtime), state.read_bytes(), index.read_bytes()) + rc, rejected = _complete(project, runtime, registry) + assert rc == 1 and rejected["error_code"] == "checkpoint_commit_unknown", rejected + assert (_authority(runtime), state.read_bytes(), index.read_bytes()) == before + assert _spend_run_count(runtime) == 0 From ba69d4ef6ea918e33f872b326af136fa35161196 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 6 Oct 2026 15:05:11 +0800 Subject: [PATCH 15/18] docs: clarify recovery of unreadable optional enrollment Signed-off-by: Tartar --- docs/reference/protocols/goal-vision-replan-contract-v0.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 99f8d97fdf..feceb2a6f1 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -495,6 +495,13 @@ recovery writes remain blocked until the original artifacts can be reconciled. Observation reuses one complete canonical Todo snapshot across the discovered identities; it does not truncate older pending work. +Ordinary Todo completion recovers an exact terminal receipt before checking an +unreadable optional enrollment source, without changing its request fingerprint. +For a new completion, the original Turn's verified ordinary writeback can prove +that a damaged shared supplement is optional. Missing or inconsistent history, +an enrolled writeback, or any result enrollment receipt remains +`checkpoint_commit_unknown`; unreadable JSON alone never proves non-enrollment. + After a lost response, retry the original request. `checkpoint-context` for its first-delivery identity verifies an indexed direction and artifacts before returning `committed`. A proved empty append can retry after freshness validation. From a10fe871e39304842071d978149788c352b7e5ae Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 6 Oct 2026 17:50:32 +0800 Subject: [PATCH 16/18] refactor(delivery): scope PR1 to caller-authored CLI and MCP recovery Signed-off-by: Tartar --- .../personal-workspace-browser/fixture.mjs | 6 +- .../typed-actions.mjs | 20 +-- loopx/cli_commands/todo.py | 1 - .../cli_commands/todo_argument_validation.py | 4 - loopx/cli_commands/todo_registration.py | 1 - loopx/cli_commands/turn_registration.py | 2 - loopx/cli_commands/turn_run_once.py | 25 +--- loopx/cli_commands/turn_todo_writeback.py | 2 - .../coordination/local_authority_runtime.ts | 2 - .../coordination/todo_delivery_context.ts | 34 +---- .../coordination/todo_terminal_lifecycle.ts | 9 -- .../control_plane/goals/checkpoint_commit.ts | 7 - .../goals/checkpoint_context_io.py | 12 +- .../todos/provider_terminal_lifecycle.py | 6 +- loopx/control_plane/turn_driver/codex_cli.py | 52 ++----- .../turn_driver/direction_review.py | 101 -------------- .../turn_driver/execution_readback.py | 12 -- loopx/control_plane/turn_driver/executor.py | 75 +--------- .../turn_driver/first_delivery.ts | 39 ++---- .../turn_driver/host_todo_completion.ts | 5 +- loopx/extensions/lark/goal_topic_runtime.py | 16 +-- .../project_registry_io_manifest_v1.json | 10 +- loopx/semantics/vocabulary_v0.json | 1 - loopx/todos.py | 1 - .../test_first_delivery_managed.py | 128 ------------------ .../control_plane/test_first_delivery_mcp.py | 35 +++-- .../test_first_delivery_terminal.py | 27 +++- .../content_digest_single_owner.test.ts | 3 +- .../first_delivery_projection.test.ts | 35 +++++ .../host_todo_completion.test.ts | 7 + tests/test_loopx_turn_codex_cli.py | 74 +--------- 31 files changed, 151 insertions(+), 601 deletions(-) delete mode 100644 loopx/control_plane/turn_driver/direction_review.py delete mode 100644 tests/control_plane/test_first_delivery_managed.py create mode 100644 tests/control_plane_ts/first_delivery_projection.test.ts diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 510fe66c4c..ee49febc26 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -1877,11 +1877,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true if (apply) { state.actionApplies.push(apply[1]); if (actionKinds.get(apply[1]) === "heartbeat.bind" && !state.allowNextHeartbeatApply) { - const gate = { kind: "host_activation_required", summary: "需要 Codex App 宿主创建 Heartbeat 自动化。", next_action: "确认宿主自动化后重新验证。" }; - // The production HTTP handler persists mark_gated before returning 409. - const proposal = { ...actionProposals.get(apply[1]), status: "gated", gate, updated_at: new Date().toISOString() }; - actionProposals.set(apply[1], proposal); - await route.fulfill({ contentType: "application/json", json: { ok: false, schema_version: "loopx_chat_action_gate_v1", error: "Host activation required", error_code: "protected_action", gate, proposal, write_attempted: false }, status: 409 }); + await route.fulfill({ contentType: "application/json", json: { ok: false, schema_version: "loopx_chat_action_gate_v1", error: "Host activation required", error_code: "protected_action", gate: { kind: "host_activation_required", summary: "需要 Codex App 宿主创建 Heartbeat 自动化。", next_action: "确认宿主自动化后重新验证。" }, write_attempted: false }, status: 409 }); return; } if (actionKinds.get(apply[1]) === "heartbeat.bind") state.allowNextHeartbeatApply = false; diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index c6f703d7a3..20092bfb88 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -1306,10 +1306,6 @@ export const typedActionsScenario = { assert.equal(await reviewUnit.inputValue(), "completed_todos"); assert.equal(await reviewCount.inputValue(), "3"); assert.deepEqual(await reviewUnit.locator("option").evaluateAll((options) => options.map((option) => option.value).filter(Boolean)), ["completed_todos", "effective_turns"]); - await reviewUnit.selectOption("effective_turns"); - if (await reviewCount.inputValue() !== "3" || api.machineConfigurationRequests.length !== requestsBeforeReadOnly) { - throw new Error("Editing the review unit changed its count or wrote without a reviewed preview"); - } await page.getByText(/不会创建 Turn、消耗配额或授予权限/u).waitFor({ state: "visible" }); await machineCatalog.getByRole("button", { name: /^变更质量验证/ }).click(); for (const label of [/^启用$/u, /^允许一次有界安全修复$/u, /^要求精确 diff 回执$/u]) { @@ -1750,22 +1746,8 @@ export const typedActionsScenario = { await page.getByRole("button", {name: "设置 Heartbeat", exact: true}).click(); await page.getByRole("dialog", {name: "Goal Heartbeat", exact: true}).getByRole("button", {name: "检查配置"}).click(); await page.getByText("确认执行").waitFor({ state: "visible" }); - const heartbeatApplyResponse = page.waitForResponse(response => /\/api\/actions\/.+\/apply$/.test(new URL(response.url()).pathname)); await page.getByRole("button", { name: "确认并应用", exact: true }).click(); - const heartbeatGate = await heartbeatApplyResponse; - const heartbeatGatePayload = await heartbeatGate.json(); - assert.equal(heartbeatGate.status(), 409, JSON.stringify(heartbeatGatePayload)); - assert.equal(heartbeatGatePayload.gate?.kind, "host_activation_required"); - const storedHeartbeat = await page.evaluate(async proposalId => { - const response = await fetch("/api/actions"); - return (await response.json()).proposals.find(proposal => proposal.proposal_id === proposalId); - }, api.actionApplies.at(-1)); - assert.equal(storedHeartbeat?.status, "gated", "Heartbeat gate must survive authoritative readback"); - try { - await page.getByText("需要宿主确认").waitFor({ state: "visible" }); - } catch (error) { - throw new Error(`Heartbeat gate readback: ${await page.locator('.personal-context-drawer').innerText()}; response=${JSON.stringify(heartbeatGatePayload)}`, { cause: error }); - } + await page.getByText("需要宿主确认").waitFor({ state: "visible" }); if (api.durableWriteCount !== writesBeforeHeartbeat) throw new Error("Protected heartbeat gate wrote durable state"); pass(8, "Agent semantic protected intent creates only a typed preview, while discussion and targetless requests remain conversational and all protected-gate paths perform zero durable writes before confirmation."); pass(11, "Heartbeat apply surfaced an explicit host-activation gate."); diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index e964f1ee75..a69329dff1 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -632,7 +632,6 @@ def handle_todo_command( completion_result_file=Path(args.result_file).expanduser() if args.result_file else None, completion_turn_key=completion_turn_key, delivery_read_context_id=getattr(args, "delivery_read_context", None), - delivery_direction_context_id=getattr(args, "direction_read_context", None), delivery_settlement_identity=settlement_identity.as_dict() if settlement_identity else None, completion_identity_source=completion_identity_source, completion_delivery_workspace=completion_delivery_workspace, diff --git a/loopx/cli_commands/todo_argument_validation.py b/loopx/cli_commands/todo_argument_validation.py index 0d4b826a0c..4632918f99 100644 --- a/loopx/cli_commands/todo_argument_validation.py +++ b/loopx/cli_commands/todo_argument_validation.py @@ -479,8 +479,6 @@ def validate_todo_update_options(args: argparse.Namespace) -> None: def validate_todo_complete_options(args: argparse.Namespace) -> None: - if getattr(args, "direction_read_context", None) and (not args.no_follow_up or not args.turn_instance_id): - raise ValueError("--direction-read-context requires Turn-scoped --no-follow-up") if getattr(args, "delivery_read_context", None) and not args.turn_instance_id: raise ValueError("--delivery-read-context requires the original --turn-instance-id") if not args.todo_id: @@ -570,8 +568,6 @@ def validate_shared_todo_options(args: argparse.Namespace) -> None: raise ValueError("--operation-id is supported only by todo receipt and canonical todo add") if getattr(args, "delivery_read_context", None) and args.todo_command != "complete": raise ValueError("--delivery-read-context is supported only by todo complete") - if getattr(args, "direction_read_context", None) and args.todo_command != "complete": - raise ValueError("--direction-read-context is supported only by todo complete") if args.result_file and args.todo_command != "complete": raise ValueError("--result-file is supported only by todo complete") agent_id_allowed_for_user_authoring = ( diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index faed026d47..aa12e02480 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -108,7 +108,6 @@ def register_todo_command( todo_parser.add_argument("--evidence", help="Public-safe evidence pointer or short result for complete/update.") todo_parser.add_argument("--result-file", help="For todo complete with bound Goal acceptance criteria, bind a bounded local .json, .md or .txt result. A Todo validator alone is insufficient; use --evidence for a local artifact pointer.") todo_parser.add_argument("--delivery-read-context", help="Original delivery_result read identity for protected Turn completion.") - todo_parser.add_argument("--direction-read-context", help="Committed Goal-scope direction read identity for protected --no-follow-up closeout.") todo_parser.add_argument( "--validation-command", help=( diff --git a/loopx/cli_commands/turn_registration.py b/loopx/cli_commands/turn_registration.py index 70555e5258..ff582534b2 100644 --- a/loopx/cli_commands/turn_registration.py +++ b/loopx/cli_commands/turn_registration.py @@ -313,8 +313,6 @@ def register_turn_commands( ) run_once.add_argument("--timeout-seconds", type=float, default=None, help="Optional execution deadline; by default wait for host completion or cancellation.") - run_once.add_argument("--first-delivery", action="store_true", - help="Opt into File/SQLite result freshness and a separately metered direction review for this Turn.") run_once.add_argument( "--retry-failed-turn", action="store_true", diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index a56018efeb..5cb123a72c 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -79,13 +79,6 @@ def execute_turn_run_once( execution_started = False try: project = Path(args.project).expanduser().resolve() - protected_delivery = bool(getattr(args, "first_delivery", False) or payload.get("first_delivery_freshness")) - if protected_delivery: - if args.host not in {"generic-cli", "codex-cli"} or getattr(args, "codex_operation_tools", False): - raise ValueError("First delivery currently supports generic-cli and ordinary codex-cli hosts.") - if goal_admission is not None and goal_admission.enabled: - raise ValueError("First delivery managed inference is not yet supported by the source-session effect profile.") - payload["first_delivery_freshness"] = True planned_host = ( payload.get("host") if isinstance(payload.get("host"), dict) else {} ) @@ -186,13 +179,6 @@ def require_effect_ref( f"{step_kind.value} effect ref does not match Turn identity" ) - def first_delivery_context(purpose: str) -> dict[str, Any]: - from ..control_plane.goals.checkpoint_context_io import read_checkpoint_context - return read_checkpoint_context(registry_path=registry_path, runtime_root_override=runtime_root_arg, - goal_id=settlement_identity.goal_id, agent_id=settlement_identity.agent_id, - todo_id=settlement_identity.todo_id, turn_instance_id=settlement_identity.turn_instance_id, - purpose=purpose, decision_scope="goal", goal_ref=goal_ref) - def append_settlement_event( effect_payload: Mapping[str, object], *, @@ -291,8 +277,6 @@ def writeback( project=state_project, state_file=None, classification=str(result["classification"]), - first_delivery=bool(result.get("first_delivery")), - checkpoint_read_context_id=result.get("checkpoint_read_context_id"), recommended_action=str(result["recommended_action"]), # A host's next_action is follow-up guidance, not refresh-state's # explicit within-task step edit (which requires a runnable Todo). @@ -374,11 +358,6 @@ def todo_completion( goal_id=args.goal_id, todo_id=todo_id, completion_turn_key=settlement_identity.turn_instance_id, - delivery_read_context_id=result.get("delivery_read_context_id"), - delivery_direction_context_id=(result.get("checkpoint_read_context_id") - if protected_delivery and effect_ref.endswith("#terminal_closeout") else None), - no_followup=protected_delivery and effect_ref.endswith("#terminal_closeout"), - delivery_settlement_identity=settlement_identity.as_dict() if protected_delivery else None, evidence=( "LoopX Turn validated completion: " + str(result.get("summary") or result["classification"]) @@ -454,7 +433,7 @@ def completion_writeback( *, effect_ref: str, ) -> dict[str, object]: - completion = result.get("_delivery_completion") or todo_completion(result, effect_ref=effect_ref) + completion = todo_completion(result, effect_ref=effect_ref) if not completion.get("ok"): return completion todo_id = str(selected_todo.get("todo_id") or "") @@ -915,8 +894,6 @@ def on_managed_start_admitted() -> None: admit_start=managed_cadence.admit if args.execute else None, confirm_start=managed_cadence.confirm if args.execute else None, goal_admission=goal_admission, - first_delivery_context=first_delivery_context if protected_delivery else None, - first_delivery_completion=todo_completion if protected_delivery else None, ) except Exception as exc: # noqa: BLE001 - CLI boundary renders typed JSON failure from ..usage_ping import capture_failure diff --git a/loopx/cli_commands/turn_todo_writeback.py b/loopx/cli_commands/turn_todo_writeback.py index 4c9a455dbc..04091de09a 100644 --- a/loopx/cli_commands/turn_todo_writeback.py +++ b/loopx/cli_commands/turn_todo_writeback.py @@ -52,7 +52,6 @@ def write_turn_validated_completion( completion_delivery_workspace: Mapping[str, Any] | None = None, completion_validation_workspace_path: Path | None = None, delivery_read_context_id: str | None = None, - delivery_direction_context_id: str | None = None, no_followup: bool = False, delivery_settlement_identity: Mapping[str, Any] | None = None, ) -> dict[str, Any]: @@ -66,7 +65,6 @@ def write_turn_validated_completion( completion_turn_key=completion_turn_key, completion_identity_source="turn_settlement", delivery_read_context_id=delivery_read_context_id, - delivery_direction_context_id=delivery_direction_context_id, no_followup=no_followup, delivery_settlement_identity=delivery_settlement_identity, completion_delivery_workspace=completion_delivery_workspace, diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 254ff59008..7282c067e1 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1303,8 +1303,6 @@ export async function terminalLifecycleLocalCoordinationTodo( const execute = () => executeCoordinationTodoTerminalLifecycle(store, { ...(input.delivery_context == null || requireJsonObject(input.delivery_context, "delivery context").read_context_id == null ? {} : {delivery_read_context_id: requireAuthorityStoreId(requireJsonObject(input.delivery_context, "delivery context").read_context_id, "delivery read context id")}), - ...(input.delivery_context == null || requireJsonObject(input.delivery_context, "delivery context").direction_read_context_id == null ? {} : { - delivery_direction_context_id: requireAuthorityStoreId(requireJsonObject(input.delivery_context, "delivery context").direction_read_context_id, "direction read context id")}), validation_source_provider_revision: input.validation_source_provider_revision == null ? null : requireAuthorityStoreId(input.validation_source_provider_revision, "validation source provider revision"), validation_declaration_sha256: input.validation_declaration_sha256 == null diff --git a/loopx/control_plane/coordination/todo_delivery_context.ts b/loopx/control_plane/coordination/todo_delivery_context.ts index 53860eedea..741d0dea40 100644 --- a/loopx/control_plane/coordination/todo_delivery_context.ts +++ b/loopx/control_plane/coordination/todo_delivery_context.ts @@ -86,6 +86,11 @@ export function terminalDeliveryBasisCheck(root: string, input: JsonObject, stor } catch { return unknown(); } } if (context.capture_error != null) return rejected("delivery_source_unavailable", String(context.capture_error)); + if (input.requested_no_followup === true) { + return {...rejected("delivery_no_followup_unsupported", + "Protected no-follow-up completion is not admitted in this protocol stage; retain the original identity and receipts."), + reread_required: false, next_action: "This protocol stage supports ordinary result delivery; do not retry an unsupported terminal intent or bypass an unresolved Turn."}; + } const facts = requireJsonObject(context.facts, "delivery source facts"); const authority = authorityStoreSourceAuthority(store); if (authority !== "file_v0" && authority !== "sqlite_v0") { @@ -104,35 +109,6 @@ export function terminalDeliveryBasisCheck(root: string, input: JsonObject, stor try { receipt = jsonObject(JSON.parse(readFileSync(receiptPath, "utf8"))); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } const current = checkpointProviderFacts(identity.goal_id, facts, head, storeId.store_identity, authority, true); - if (input.requested_no_followup === true) { - const directionId = context.direction_read_context_id; - if (typeof directionId !== "string") return rejected("delivery_direction_receipt_required", "Terminal closeout requires this Turn's committed direction read identity."); - const directionPath = join(root, "goals", identity.goal_id, "checkpoint-contexts", - `${createHash("sha256").update(identity.effect_id).digest("hex")}.json`); - const direction = requireJsonObject(JSON.parse(readFileSync(directionPath, "utf8")), "direction receipt"); - const prior = requireJsonObject(requireJsonObject(direction.commit_attempt, "committed attempt").index_record, "direction run"); - inspectCheckpointReplay({runtime_root: root, goal_id: identity.goal_id, prior}); - const committedContext = requireJsonObject(requireJsonObject(prior.vision_checkpoint, "direction checkpoint").read_context, "committed direction context"); - if (direction.read_context_id !== directionId || committedContext.read_context_id !== directionId || direction.decision_scope !== "goal") { - return rejected("delivery_direction_receipt_mismatch", "Terminal closeout requires the exact committed Goal-scope direction."); - } - const check = evaluateCheckpointReadContext({phase: "check", purpose: "first_delivery", identity: binding, - read_context_id: directionId, decision_scope: "goal", facts: current, - receipt: {...direction, commit_attempt: null, versions: committedContext.terminal_versions}}); - if (check.ok !== true) return check; - // Deferred terminal paths have not committed the result yet. Preserve its - // original candidate basis as well, allowing only this Turn's own Vision - // which was just proven from its indexed direction receipt. - const selected = (current.todos as JsonObject[]).find(todo => todo.todo_id === identity.todo_id); - if (selected?.status !== "done") { - return evaluateCheckpointReadContext({phase: "check", purpose: "delivery_result", identity: binding, - read_context_id: context.read_context_id, facts: current, receipt: receipt === null ? null : { - ...receipt, versions: {...requireJsonObject(receipt.versions, "result basis versions"), - agent_vision: canonicalAuthoritySha256(current.agent_vision)}, - }}); - } - return check; - } return evaluateCheckpointReadContext({phase: "check", purpose: "delivery_result", identity: binding, read_context_id: context.read_context_id, receipt, facts: current}); diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 0862c89f3d..6e72443415 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -80,7 +80,6 @@ type CompletionIdentitySource = typeof COMPLETION_IDENTITY_SOURCES[number]; interface CoordinationTodoTerminalLifecycleBaseInput { /** Immutable read identity, included in the operation payload commitment. */ readonly delivery_read_context_id?: string; - readonly delivery_direction_context_id?: string; readonly review_basis?: {readonly provider_revision: string; readonly registry_sha256: string}; /** Presence selects source-bound validation; null means no effect issued yet. */ readonly validation_source_provider_revision?: string | null; @@ -359,12 +358,6 @@ function normalizeTerminalInput( throw new AuthorityStoreProtocolError("delivery basis requires an ordinary Turn completion"); } } - if (raw.delivery_direction_context_id !== undefined) { - requireAuthorityStoreId(raw.delivery_direction_context_id, "delivery direction context id"); - if (raw.delivery_read_context_id === undefined || !raw.requested_no_followup) { - throw new AuthorityStoreProtocolError("direction receipt is only valid for protected terminal closeout"); - } - } if (raw.review_basis !== undefined) { const basis = canonicalAuthorityObject(raw.review_basis, "terminal review basis"); if (Object.keys(basis).some(key => !["provider_revision", "registry_sha256"].includes(key)) || @@ -490,7 +483,6 @@ function terminalRequestSha(input: CoordinationTodoTerminalLifecycleInput): stri ...(input.delivery_read_context_id === undefined ? {} : { delivery_read_context_id: input.delivery_read_context_id, note: input.note, evidence: input.evidence, reason: input.reason, - ...(input.delivery_direction_context_id === undefined ? {} : {delivery_direction_context_id: input.delivery_direction_context_id}), }), // Older receipts deliberately retain their original fingerprint. A reviewed // command binds both its approved snapshot and its complete prose intent. @@ -1320,7 +1312,6 @@ export async function executeCoordinationTodoTerminalLifecycle( } if (fence.outcome === "continue" && fence.reason === "same_turn_terminal_upgrade") { const originalInput = {...input, requested_no_followup: false, - delivery_direction_context_id: undefined, operation_id: completionTurnOperationId(input, false)}; const original = await terminalReceipt(originalInput, terminalRequestSha(originalInput)).read(store); if (original === null) return terminalFailure("terminal_completion_receipt_required", diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts index c048275fc2..8748f6457d 100644 --- a/loopx/control_plane/goals/checkpoint_commit.ts +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -341,13 +341,6 @@ export async function commitCheckpoint(value: unknown): Promise { purpose: request.purpose, decision_scope: request.decision_scope, read_context_id: retry.checkpoint_read_context_id, receipt, facts: current}); if (context.ok !== true) return context; - if (firstDelivery) { - context.terminal_versions = checkpointBasisSnapshot({identity: binding, - purpose: "first_delivery", decision_scope: request.decision_scope, - facts: {...current, agent_vision: request.committed_agent_vision ?? current.agent_vision}, - dependency_todo_ids: requireJsonObject(receipt, "read receipt").dependency_todo_ids, - }).versions; - } const record = requireJsonObject(request.record, "checkpoint record"); const row = requireJsonObject(request.index_record, "checkpoint index record"); for (const projected of [record, row]) { diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index 99e74e475b..a707af21ed 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -120,15 +120,12 @@ def first_delivery_progress( committed = selected.get("status") == "done" and selected.get("completion_turn_key") in {identity.effect_id, identity.turn_instance_id} direction_path = _receipt_path(root, identity) direction = _read_context_receipt(direction_path) if direction_path.exists() else {} - checkpoint = (readback.writeback_run or {}).get("vision_checkpoint", {}) - direction_committed = checkpoint.get("satisfied") is True and checkpoint.get("read_context", {}).get("purpose") == "first_delivery" - unknown = unknown or (bool(direction.get("commit_attempt")) and not direction_committed) except (ValueError, OSError): - direction_committed = False + direction = {} unknown = True return effect_runtime_result("turn.first_delivery.evaluate", { "phase": "project", "result_committed": committed, - "direction_committed": direction_committed, "unknown": unknown, + "writeback_run": readback.writeback_run, "direction_receipt": direction, "unknown": unknown, "quota_spent": readback.spend_run is not None, "settlement_complete": readback.terminal_settlement.failure is None, }) @@ -201,7 +198,6 @@ def pending_first_delivery_progress(root: Path, goal_id: str, agent_id: str | No def delivery_result_context_input( *, runtime_root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, read_context_id: str | None, - direction_read_context_id: str | None = None, ) -> dict[str, Any] | None: """Capture IO for the native Todo owner; never refresh the Agent's token. @@ -210,7 +206,7 @@ def delivery_result_context_input( """ result: dict[str, Any] = {"identity": identity.as_dict(), "read_context_id": read_context_id or "missing", "state_file": str(state_file.resolve())} - if not read_context_id and not direction_read_context_id: + if not read_context_id: try: if not first_delivery_context_enrolled(runtime_root, identity): return None @@ -219,8 +215,6 @@ def delivery_result_context_input( # recovery. Only the native owner can prove an unreadable shared # supplement optional; this is not evidence of non-enrollment. return {**result, "read_context_id": None, "capture_error": str(error)} - if direction_read_context_id: - result["direction_read_context_id"] = direction_read_context_id index = runtime_root / "goals" / identity.goal_id / "runs" / "index.jsonl" try: with exclusive_cross_runtime_file_lock(index, operation="delivery-result-capture"): diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index 2f6288c611..bc87c9957c 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -130,7 +130,6 @@ def _route_terminal_call(command: str, call: Mapping[str, Any]) -> dict[str, Any ), review_basis=call.get("terminal_review_basis"), delivery_read_context_id=call.get("delivery_read_context_id"), - delivery_direction_context_id=call.get("delivery_direction_context_id"), delivery_settlement_identity=call.get("delivery_settlement_identity"), completion_delivery_workspace=( call.get("completion_delivery_workspace") if complete else None @@ -304,7 +303,6 @@ def terminal_canonical_todo_if_promoted( dry_run: bool, review_basis: Mapping[str, Any] | None = None, delivery_read_context_id: str | None = None, - delivery_direction_context_id: str | None = None, delivery_settlement_identity: Mapping[str, Any] | None = None, project: Path | None = None, state_file: Path | None = None, @@ -440,7 +438,7 @@ def terminal_canonical_todo_if_promoted( "observed_at": now_local(), } def invoke() -> Any: - if (delivery_read_context_id is not None or delivery_direction_context_id is not None) and delivery_settlement_identity is None: + if delivery_read_context_id is not None and delivery_settlement_identity is None: raise ValueError("delivery read context requires the original settlement identity") if delivery_settlement_identity is not None: from ..goals.checkpoint_context_io import delivery_result_context_input @@ -452,7 +450,6 @@ def invoke() -> Any: runtime_root=runtime_root, registry_path=registry_path, state_file=state_file, identity=SettlementIdentity.from_runtime_payload(delivery_settlement_identity), read_context_id=delivery_read_context_id, - direction_read_context_id=delivery_direction_context_id, ) if context is not None: request["delivery_context"] = context @@ -466,7 +463,6 @@ def invoke() -> Any: runtime_root=runtime_root, registry_path=registry_path, state_file=state_file, identity=SettlementIdentity.from_runtime_payload(delivery_settlement_identity), read_context_id=delivery_read_context_id, - direction_read_context_id=delivery_direction_context_id, ) result = invoke() diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index adf38f55ba..5442e89dd5 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -192,14 +192,6 @@ def _has_subagent_topology(request: Mapping[str, Any] | None) -> bool: def codex_cli_result_schema( request: Mapping[str, Any] | None = None, ) -> dict[str, Any]: - if request is not None and request.get("direction_review") is not None: - properties = { - "read_context_id": {"type": "string"}, - "decision": {"type": "string", "enum": ["continue", "revalidate_result", "terminal_ready"]}, - "agent_vision_json": {"type": "string", "maxLength": HOST_AGENT_VISION_JSON_MAX_CHARS}, - "vision_unchanged_reason": {"type": "string", "maxLength": 240}, - } - return {"type": "object", "properties": properties, "required": list(properties), "additionalProperties": False} text_limits = dict(HOST_RESULT_TEXT_LIMITS) properties: dict[str, Any] = { "schema_version": { @@ -284,12 +276,6 @@ def _prompt(request: Mapping[str, Any]) -> str: request_json = json.dumps( request, ensure_ascii=False, sort_keys=True, separators=(",", ":") ) - if request.get("direction_review") is not None: - return ("Review only the current direction basis in direction_review. Implementation has already run. " - "Do not execute work, write files, call external effects, or settle quota. Return only the direction schema. " - "Echo the exact read_context_id. Use revalidate_result if current requirements invalidate the prepared result. " - "Otherwise author a Vision in agent_vision_json or an unchanged reason, leaving the other string empty. " - "Pending tasks remain pending even when a selected result was committed.\n" + request_json) instructions = [ "Execute exactly one bounded LoopX Turn in the current workspace.", "Use the TurnEnvelope as the source of truth. Perform work only when its contract allows it.", @@ -650,21 +636,6 @@ def _codex_command( return command -def _checked_codex_session_id(planned_action: str, binding: Mapping[str, Any] | None) -> str | None: - """Keep executable session admission together before starting the process.""" - if planned_action == "resume" and binding is None: - raise RuntimeError("Codex CLI resume binding disappeared after planning") - if planned_action == "start_new" and binding is not None: - raise RuntimeError("Codex CLI session binding changed after planning") - if planned_action not in {"resume", "start_new"}: - raise ValueError("Codex CLI host request has no executable session action") - if binding and binding.get("operation_transport"): - raise ValueError( - "operation-equipped session requires its original managed transport; select a fresh iteration explicitly to change it" - ) - return str(binding.get("session_id")) if binding else None - - def run_codex_cli_host( request: Mapping[str, Any], *, @@ -681,9 +652,6 @@ def run_codex_cli_host( ) -> dict[str, Any]: if request.get("schema_version") != LOOPX_TURN_HOST_REQUEST_SCHEMA_VERSION: raise ValueError("unsupported LoopX Turn host request schema") - direction_only = request.get("direction_review") is not None - if direction_only: - sandbox, mcp_server = "read-only", None if sandbox not in CODEX_CLI_SANDBOXES: raise ValueError(f"Codex CLI sandbox must be one of {CODEX_CLI_SANDBOXES}") if reasoning_effort is not None: @@ -694,11 +662,11 @@ def run_codex_cli_host( raise ValueError("Codex CLI executable is unavailable") lineage = _lineage(request) planned_session = _mapping(request.get("session")) - planned_action = "start_new" if direction_only else str(planned_session.get("action") or "") + planned_action = str(planned_session.get("action") or "") context_policy = _mapping(planned_session.get("context_policy")) if context_policy.get("mode") is not None and context_policy["mode"] not in SUPPORTED_ITERATION_CONTEXT_POLICIES: raise ValueError("iteration context policy must be fresh or resume") - fresh_iteration = direction_only or context_policy.get("mode") == "fresh" + fresh_iteration = context_policy.get("mode") == "fresh" session_scope = str(context_policy.get("binding_scope") or "todo") if fresh_iteration and goal_admission is not None: goal_admission.require_current() @@ -726,13 +694,21 @@ def run_codex_cli_host( ) if not approved_write_resume: require_codex_session_profile(binding, profile_digest) - session_id = _checked_codex_session_id(planned_action, binding) + if planned_action == "resume" and binding is None: + raise RuntimeError("Codex CLI resume binding disappeared after planning") + if planned_action == "start_new" and binding is not None: + raise RuntimeError("Codex CLI session binding changed after planning") + if planned_action not in {"resume", "start_new"}: + raise ValueError("Codex CLI host request has no executable session action") + session_id = str(binding.get("session_id")) if binding else None + if binding and binding.get("operation_transport"): + raise ValueError( + "operation-equipped session requires its original managed transport; select a fresh iteration explicitly to change it" + ) goal_ref = request.get("goal_ref") exact_goal_ref = dict(goal_ref) if isinstance(goal_ref, Mapping) else None def store_session(observed_session_id: str) -> None: - if direction_only: - return def commit() -> None: _store_codex_cli_session( runtime_root, @@ -749,8 +725,6 @@ def commit() -> None: goal_admission.accept_result(commit) def discard_session() -> None: - if direction_only: - return def commit() -> None: _discard_codex_cli_session( runtime_root, diff --git a/loopx/control_plane/turn_driver/direction_review.py b/loopx/control_plane/turn_driver/direction_review.py deleted file mode 100644 index d07a720869..0000000000 --- a/loopx/control_plane/turn_driver/direction_review.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Host IO for bounded direction inference using the existing Turn journal. - -Todo/run receipts remain the commit authority. This module records which read -was delivered to which inference, and never manufactures a fresh token for a -previous output. The journal's lane lock serializes inference; source and -provider locks are released before the Host is called. -""" -from __future__ import annotations - -from collections.abc import Callable, Mapping -from typing import Any - -from ..effect_runtime import effect_runtime_result -from .journal_store import journal_committed_effect_id -from .settlement import invoke_result_effect, terminal_closeout_requirement - - -MAX_DIRECTION_ATTEMPTS = 2 - - -def prepare_first_delivery( - *, plan: Mapping[str, Any], request: Mapping[str, Any], result: dict[str, Any], - journal: dict[str, Any], persist: Callable[[Mapping[str, Any]], None], - read_context: Callable[[str], dict[str, Any]], - invoke_host: Callable[[Mapping[str, Any]], dict[str, Any]], - completion_intent: Callable[..., dict[str, Any]] | None, - commit_result: Callable[..., dict[str, Any]] | None, -) -> dict[str, Any]: - """Preserve native result/terminal ordering before bounded direction review.""" - original = journal.get("delivery_result_context") - if not isinstance(original, dict): - raise ValueError("Protected Turn has no result read identity; do not attach a new read to the previous result.") - result = {**result, "delivery_read_context_id": original["read_context_id"]} - terminal_needed = False - if result.get("result_kind") in {"repair_required", "replan_required"}: - raise ValueError("First delivery does not support compound repair or replan mutations; retain the candidate for explicit recovery.") - if result.get("result_kind") == "validated_completion": - if completion_intent is None or commit_result is None: - raise ValueError("Protected completion adapter is unavailable") - terminal_needed, intent_error = terminal_closeout_requirement( - plan=plan, result=result, journal=journal, completion_intent=completion_intent) - if intent_error: - raise ValueError(intent_error) - if not terminal_needed: - completion = invoke_result_effect(commit_result, result, - f"{journal_committed_effect_id(journal)}#durable_writeback") - if completion.get("ok") is not True: - raise ValueError(str(completion.get("reason") or "Protected result commit failed")) - journal["delivery_completion"] = dict(completion) - persist(journal) - result["_delivery_completion"] = dict(completion) - result = review_first_delivery(request=request, result=result, journal=journal, - persist=persist, read_context=read_context, invoke_host=invoke_host) - if terminal_needed and result.get("_direction_decision") != "terminal_ready": - raise ValueError("Current direction does not authorize the requested no-followup closeout; retain the original Turn and resolve the remaining work.") - return result - - -def direction_host_request(request: Mapping[str, Any], context: Mapping[str, Any], result: Mapping[str, Any]) -> dict[str, Any]: - return {**request, "direction_review": { - "context": dict(context), - "candidate_result": {key: result.get(key) for key in ( - "result_kind", "summary", "classification", "delivery_outcome", "next_action")}, - "instructions": "Judge the direction from this current basis. Do not execute implementation, Todo writes, quota spend or external effects. " - "Return read_context_id, decision (continue, revalidate_result, terminal_ready), and exactly one agent_vision or vision_unchanged_reason. " - "Use revalidate_result when the current acceptance or dependency invalidates the prepared result. A committed result is retained; pending work is not Goal completion.", - }} - - -def review_first_delivery( - *, request: Mapping[str, Any], result: dict[str, Any], journal: dict[str, Any], - persist: Callable[[Mapping[str, Any]], None], read_context: Callable[[str], dict[str, Any]], - invoke_host: Callable[[Mapping[str, Any]], dict[str, Any]], -) -> dict[str, Any]: - attempts = journal.setdefault("direction_reviews", []) - current = attempts[-1] if attempts else None - if current is None or current.get("decision") is None: - if len(attempts) >= MAX_DIRECTION_ATTEMPTS: - raise ValueError("Direction review budget exhausted; retain this Turn and its receipts for operator recovery.") - context = read_context("first_delivery") - current = {"read_context_id": context["read_context_id"], "versions": context["versions"], - "purpose": "first_delivery", "attempt": len(attempts) + 1, "response": None} - attempts.append(current) - persist(journal) - observation = invoke_host(direction_host_request(request, context, result)) - current["host_observation"] = {key: observation.get(key) for key in ("ok", "reason", "returncode")} - persist(journal) - if observation.get("ok") is not True: - raise ValueError("Direction Host failed; result receipts remain committed and quota has not been spent.") - decision = effect_runtime_result("turn.first_delivery.evaluate", { - "response": observation["value"], "read_context_id": context["read_context_id"], - }) - current.update(decision=decision["decision"], response=decision) - persist(journal) - if current["decision"] == "revalidate_result": - raise ValueError("Current direction requires result revalidation; keep the original candidate and Turn identity.") - response = current["response"] - return {**result, "agent_vision": response["agent_vision"], - "vision_unchanged_reason": response["vision_unchanged_reason"], - "checkpoint_read_context_id": response["read_context_id"], "first_delivery": True, - "_direction_decision": response["decision"]} diff --git a/loopx/control_plane/turn_driver/execution_readback.py b/loopx/control_plane/turn_driver/execution_readback.py index a1df802e36..34b35338ca 100644 --- a/loopx/control_plane/turn_driver/execution_readback.py +++ b/loopx/control_plane/turn_driver/execution_readback.py @@ -9,7 +9,6 @@ from .host_failure import project_host_failure from .lane_fence import turn_lane_in_flight_projection from .transaction import LOOPX_TURN_EXECUTION_SCHEMA_VERSION -from ..effect_runtime import effect_runtime_result def _mapping(value: Any) -> dict[str, Any]: @@ -35,16 +34,6 @@ def execution_payload( journal.get("completed_phases") or [] ) recovery = journal.get("recovery_audit") - delivery = {} - if plan.get("first_delivery_freshness"): - phases = list(journal.get("completed_phases") or []) - write_attempt = _mapping(_mapping(journal.get("effect_attempts")).get("durable_writeback")) - delivery["first_delivery_progress"] = effect_runtime_result("turn.first_delivery.evaluate", { - "phase": "project", "result_committed": _mapping(journal.get("delivery_completion")).get("ok") is True or bool(todo_completion.get("completed")), - "direction_committed": "durable_writeback" in phases, - "unknown": write_attempt.get("status") == "prepared" and "durable_writeback" not in phases, - "quota_spent": quota_spent, "settlement_complete": journal.get("status") == "committed", - }) return { "ok": journal.get("status") in { @@ -75,7 +64,6 @@ def execution_payload( "scheduler": journal.get("scheduler"), **subagent.subagent_execution_payload_projection(journal), "effects": dict(effects), - **delivery, **({"admission": dict(journal["admission"])} if isinstance(journal.get("admission"), Mapping) else {}), "quota_slot_spend_count": 1 if quota_spent else 0, diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 15e005b695..f70f313efc 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -791,7 +791,6 @@ def _host_result_stage( confirm_start: Callable[[], None] | None = None, usage_runtime_root: Path | None = None, usage_goal_id: str = "", - first_delivery_context: Callable[[str], dict[str, Any]] | None = None, ) -> tuple[dict[str, Any] | None, list[str], dict[str, Any] | None]: completed_phases = list(journal.get("completed_phases") or []) result = ( @@ -800,10 +799,6 @@ def _host_result_stage( else None ) if "typed_result" not in completed_phases: - if first_delivery_context is not None: - context = first_delivery_context("delivery_result") - journal["delivery_result_context"] = {key: context[key] for key in ("read_context_id", "versions", "settlement_identity")} - request = {**request, "delivery_result_context": context} journal["host_attempt_count"] = int(journal.get("host_attempt_count") or 0) + 1 persist_journal(journal) from ...extensions.codex_native_child import configured_native_child_limit @@ -1093,7 +1088,7 @@ def _typed_settlement_stage( ) ) - def perform_writeback(effect_ref: str) -> Mapping[str, Any]: + def writeback_effect(effect_ref: str) -> Mapping[str, Any]: if completion_intent_error: return { "ok": False, @@ -1111,18 +1106,6 @@ def perform_writeback(effect_ref: str) -> Mapping[str, Any]: return invoke_result_effect(completion_writeback, result, effect_ref) return invoke_result_effect(writeback, result, effect_ref) - def writeback_effect(effect_ref: str) -> Mapping[str, Any]: - from ..goals.checkpoint_context_io import CheckpointReadContextRejected - try: - return perform_writeback(effect_ref) - except CheckpointReadContextRejected as error: - if error.code in {"checkpoint_read_context_stale", "checkpoint_read_context_unknown_or_replaced"}: - reviews = journal.get("direction_reviews") - if isinstance(reviews, list) and reviews: - reviews[-1].update(decision=None, rejected=error.code) - persist_journal(journal) - return {"ok": False, "appended": False, "reason": str(error), **error.payload} - journal_adapter = TurnSettlementJournalAdapter( journal=journal, effects=effects, @@ -1283,45 +1266,6 @@ def persist_tail_checkpoint(*, release: bool) -> None: ) -def _first_delivery_stage( - *, plan: Mapping[str, Any], request: Mapping[str, Any], result: dict[str, Any], - journal: dict[str, Any], completed_phases: list[str], persist_journal: Callable[..., None], - effects: Mapping[str, Any], first_delivery_context: Callable[[str], dict[str, Any]] | None, - first_delivery_completion: CompletionWriteback | None, completion_intent: CompletionIntent | None, - runtime_root: Path, goal_id: str, host_runner: HostRunner | None, - argv: Sequence[str] | None, project: Path, timeout_seconds: float, -) -> tuple[dict[str, Any], dict[str, Any] | None]: - """Run the protected result/direction stage, preserving settlement ordering.""" - if first_delivery_context is not None and "durable_writeback" not in completed_phases: - from .direction_review import prepare_first_delivery - from ...usage_goal import observe_goal_execution - - try: - def invoke_direction(host_request: Mapping[str, Any]) -> dict[str, Any]: - with observe_goal_execution(runtime_root, goal_id, - host=str((plan.get("host") or {}).get("kind") or "unknown")): - return (_run_host_runner(host_request, runner=host_runner) if host_runner is not None - else _run_host(host_request, argv=argv or [], project=project, timeout_seconds=timeout_seconds)) - - result = prepare_first_delivery(plan=plan, request=request, result=result, journal=journal, - persist=persist_journal, read_context=first_delivery_context, invoke_host=invoke_direction, - completion_intent=completion_intent, commit_result=first_delivery_completion) - except (ValueError, OSError) as error: - failure = _host_failure(plan, kind=LoopXTurnResultKind.WRITEBACK_FAILED, - completed_phases=completed_phases, failed_phase="durable_writeback", reason=str(error)) - journal.update(status="failed", reason=str(error), result_kind=LoopXTurnResultKind.WRITEBACK_FAILED.value, - receipt=failure["receipt"], completed_phases=completed_phases, - validation_stage="first_delivery_direction") - persist_journal(journal) - return result, execution_payload(plan, journal, execute=True, replayed=False, effects=effects) - - if first_delivery_context is not None and "durable_writeback" in completed_phases: - result = {**result, "delivery_read_context_id": journal["delivery_result_context"]["read_context_id"], - "checkpoint_read_context_id": journal["direction_reviews"][-1]["read_context_id"], "first_delivery": True} - - return result, None - - @single_executor_per_turn_lane(execution_payload) def run_loopx_turn_once( plan: Mapping[str, Any], @@ -1349,13 +1293,7 @@ def run_loopx_turn_once( admit_start: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, confirm_start: Callable[[], None] | None = None, goal_admission: FirstPartyHostGoalAdmission | None = None, - first_delivery_context: Callable[[str], dict[str, Any]] | None = None, - first_delivery_completion: CompletionWriteback | None = None, ) -> dict[str, Any]: - if plan.get("first_delivery_freshness") and first_delivery_context is None: - raise ValueError("Protected Turn recovery requires its first delivery adapter") - if first_delivery_context is not None and goal_admission is not None and goal_admission.enabled: - raise ValueError("First delivery inference cannot run inside source-session effect admission") if host_runner is not None and host_argv is not None: raise ValueError("run-once accepts either host_argv or host_runner, not both") if host_runner is None: @@ -1590,7 +1528,6 @@ def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: host_runner=host_runner, usage_runtime_root=runtime_root, usage_goal_id=goal_id, - first_delivery_context=first_delivery_context, argv=argv, completion_lifecycle_configured=all( callback is not None @@ -1627,16 +1564,6 @@ def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: if terminal is not None: return finish_recovery(terminal) - result, terminal = _first_delivery_stage( - plan=plan, request=request, result=result, journal=journal, completed_phases=completed_phases, - persist_journal=persist_journal, effects=effects, first_delivery_context=first_delivery_context, - first_delivery_completion=first_delivery_completion, completion_intent=completion_intent, - runtime_root=runtime_root, goal_id=goal_id, host_runner=host_runner, - argv=argv, project=project, timeout_seconds=timeout_seconds, - ) - if terminal is not None: - return finish_recovery(terminal) - settled = _typed_settlement_stage( plan, result, diff --git a/loopx/control_plane/turn_driver/first_delivery.ts b/loopx/control_plane/turn_driver/first_delivery.ts index 29d0d6759d..b7c70a6ca3 100644 --- a/loopx/control_plane/turn_driver/first_delivery.ts +++ b/loopx/control_plane/turn_driver/first_delivery.ts @@ -1,8 +1,6 @@ -/** Direction-only Host response. The original result remains immutable. */ +/** Receipt-backed delivery observation; never settlement or direction authority. */ import type {JsonObject} from "../effect_program.ts"; -import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; -import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; -import {normalizeVisionUnchangedReason} from "../goals/vision_checkpoint.ts"; +import {jsonObject, requireJsonObject} from "../runtime_decode.ts"; export function evaluateFirstDelivery(value: unknown): JsonObject { const request = requireJsonObject(value, "first delivery"); @@ -12,34 +10,27 @@ export function evaluateFirstDelivery(value: unknown): JsonObject { goal_completion_certified: false, next_action: "Inspect unavailable checkpoint observations before resuming the affected Turn."}; } - const stage = request.unknown === true ? "operation_unknown" - : request.direction_committed !== true ? "direction_pending" + const checkpoint = jsonObject(jsonObject(request.writeback_run)?.vision_checkpoint); + const notRequired = checkpoint?.decision === "not_required" && checkpoint.required === false && checkpoint.satisfied === true; + const directionCommitted = checkpoint?.satisfied === true && + jsonObject(checkpoint.read_context)?.purpose === "first_delivery"; + const unknown = request.unknown === true || + (jsonObject(request.direction_receipt)?.commit_attempt != null && !directionCommitted); + const stage = unknown ? "operation_unknown" + : request.result_committed !== true && !directionCommitted && !notRequired ? "result_review_pending" + : !directionCommitted && !notRequired ? "direction_pending" : request.settlement_complete === true ? "settled" : "settlement_pending"; return {schema_version: "first_delivery_progress_v0", stage, result_committed: request.result_committed === true, - direction_committed: request.direction_committed === true, + direction_committed: directionCommitted, + direction_required: !notRequired, quota_spent: request.quota_spent === true, goal_completion_certified: false, next_action: stage === "operation_unknown" ? "Read back the original Turn and its run artifacts; retain its identity." + : stage === "result_review_pending" ? "Read the original Turn's result basis and validate the candidate before committing it." : stage === "direction_pending" ? "Result retained; review the current direction using the original Turn, then resume its remaining settlement." : stage === "settlement_pending" ? "Resume the original Turn's remaining settlement; retain successful writeback and quota receipts." : "Turn settlement is complete. Read the current Goal obligations before selecting further work."}; } - const response = requireJsonObject(request.response, "direction response"); - if (Object.keys(response).some(key => !["read_context_id", "decision", "agent_vision", "agent_vision_json", "vision_unchanged_reason"].includes(key)) || - response.read_context_id !== requireNonEmptyString(request.read_context_id, "read context id")) { - throw new EffectRuntimeRequestError("Direction response must echo the delivered read identity and contain only direction fields."); - } - if (!["continue", "revalidate_result", "terminal_ready"].includes(String(response.decision))) { - throw new EffectRuntimeRequestError("Direction decision must be continue, revalidate_result or terminal_ready."); - } - if (response.agent_vision != null && response.agent_vision_json) throw new EffectRuntimeRequestError("Duplicate direction Vision payload."); - const rawVision = response.agent_vision ?? (response.agent_vision_json ? JSON.parse(String(response.agent_vision_json)) : null); - const vision = rawVision == null ? null : requireJsonObject(rawVision, "agent vision"); - const reason = normalizeVisionUnchangedReason(response.vision_unchanged_reason); - if ((vision === null) === (reason === null)) { - throw new EffectRuntimeRequestError("Direction response requires exactly one authored Vision or unchanged reason."); - } - return {read_context_id: response.read_context_id, decision: response.decision, - agent_vision: vision, vision_unchanged_reason: reason}; + throw new TypeError("Delivery observation requires the project phase"); } diff --git a/loopx/control_plane/turn_driver/host_todo_completion.ts b/loopx/control_plane/turn_driver/host_todo_completion.ts index 234c3c4f58..0ee76e98c1 100644 --- a/loopx/control_plane/turn_driver/host_todo_completion.ts +++ b/loopx/control_plane/turn_driver/host_todo_completion.ts @@ -143,6 +143,9 @@ function decodeRequest( const resultContextId = optionalText("delivery_read_context_id"); if ((value.first_delivery === true || resultContextId) && !firstDelivery) throw new EffectRuntimeRequestError("First delivery requires the v2 host contract."); if (firstDelivery && value.first_delivery !== true) throw new EffectRuntimeRequestError("v2 requires explicit first_delivery opt-in."); + if (firstDelivery && value.no_follow_up === true) { + throw new EffectRuntimeRequestError("Protected no-follow-up completion is not supported by this protocol stage; no effects were admitted. Use the existing ordinary contract or a real successor intent, without changing an unresolved original Turn."); + } if (visionPath && unchanged) { throw new EffectRuntimeRequestError("choose a vision patch or an unchanged reason, not both"); } @@ -477,8 +480,6 @@ function providerSteps( step_kind: "terminal_closeout", args: [ ...request.completion_args, - ...(request.first_delivery ? ["--delivery-read-context", request.delivery_read_context_id ?? "missing", - "--direction-read-context", request.checkpoint_read_context_id ?? "missing"] : []), "--turn-instance-id", turnId, ...(request.goal_instance_id diff --git a/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index a4c2cf11c9..4fba2c2507 100644 --- a/loopx/extensions/lark/goal_topic_runtime.py +++ b/loopx/extensions/lark/goal_topic_runtime.py @@ -193,7 +193,7 @@ def _default_process_factory(args: list[str]) -> subprocess.Popen[str]: def _target_for_profile_chat( - target_payload: Mapping[str, object], + target_payload: Mapping[str, Any], *, profile: str, chat_id: str, @@ -201,10 +201,10 @@ def _target_for_profile_chat( active_target_refs: set[str] | None = None, root_id: str = "", binding_payloads: Mapping[str, object] | None = None, -) -> tuple[str, Mapping[str, object]] | None: +) -> tuple[str, Mapping[str, Any]] | None: targets = target_payload.get("targets") targets = targets if isinstance(targets, Mapping) else {} - candidates: list[tuple[str, Mapping[str, object]]] = [] + candidates: list[tuple[str, Mapping[str, Any]]] = [] for target_ref, target in targets.items(): if active_target_refs is not None and str(target_ref) not in active_target_refs: continue @@ -243,7 +243,7 @@ def _target_for_profile_chat( def _topic_roots_for_target( - binding_payloads: Mapping[str, object], *, target_ref: str + binding_payloads: Mapping[str, Any], *, target_ref: str ) -> list[str]: roots: list[str] = [] for goal_id, payload in binding_payloads.items(): @@ -255,7 +255,7 @@ def _topic_roots_for_target( def _topic_roots_for_bindings( - bindings: list[Mapping[str, object]], *, target_ref: str + bindings: list[Mapping[str, Any]], *, target_ref: str ) -> list[str]: roots: list[str] = [] for binding in bindings: @@ -276,9 +276,9 @@ def _topic_roots_for_bindings( def _binding_payloads_for_target( - binding_payloads: Mapping[str, object], *, target_ref: str -) -> dict[str, Mapping[str, object]]: - selected: dict[str, Mapping[str, object]] = {} + binding_payloads: Mapping[str, Any], *, target_ref: str +) -> dict[str, Mapping[str, Any]]: + selected: dict[str, Mapping[str, Any]] = {} for goal_id, payload in binding_payloads.items(): if not isinstance(payload, Mapping): continue diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index cd1d7399d5..ca05d08e9c 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -967,7 +967,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#5", - "line": 675, + "line": 674, "column": 25, "kind": "codec_read", "api": "load_registry", @@ -975,7 +975,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#6", - "line": 746, + "line": 745, "column": 13, "kind": "codec_read", "api": "load_registry", @@ -983,7 +983,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#7", - "line": 791, + "line": 790, "column": 38, "kind": "codec_read", "api": "load_registry", @@ -1191,7 +1191,7 @@ }, { "site": "loopx/control_plane/goals/checkpoint_context_io.py::._local_source_facts::codec_read:load_registry#1", - "line": 306, + "line": 300, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -1199,7 +1199,7 @@ }, { "site": "loopx/control_plane/goals/checkpoint_context_io.py::.read_checkpoint_context::codec_read:load_registry#1", - "line": 331, + "line": 325, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 029c4da4a4..9ba202987b 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -221,7 +221,6 @@ "input_producer": "loopx/control_plane/turn_driver/transaction.py::_result_kind", "producers": [ "loopx/control_plane/turn_driver/executor.py::_host_result_stage", - "loopx/control_plane/turn_driver/executor.py::_first_delivery_stage", "loopx/control_plane/turn_driver/executor.py::_run_task_validator", "loopx/control_plane/turn_driver/executor.py::_task_validation_receipt", "loopx/control_plane/turn_driver/executor.py::_task_validation_stage", diff --git a/loopx/todos.py b/loopx/todos.py index 7e06132696..09c4ee8778 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -1313,7 +1313,6 @@ def complete_goal_todo( completion_identity_source: str | None = None, terminal_review_basis: Mapping[str, Any] | None = None, delivery_read_context_id: str | None = None, - delivery_direction_context_id: str | None = None, delivery_settlement_identity: Mapping[str, Any] | None = None, completion_delivery_workspace: Mapping[str, Any] | None = None, completion_validation_workspace_path: Path | None = None, diff --git a/tests/control_plane/test_first_delivery_managed.py b/tests/control_plane/test_first_delivery_managed.py deleted file mode 100644 index c25e47fc3d..0000000000 --- a/tests/control_plane/test_first_delivery_managed.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Real managed CLI and provider; the deterministic Host tests orchestration, -not model benefit. The separate Agent study must use an actual model.""" -from __future__ import annotations - -import contextlib -import io -import json -import sys - -import pytest - -from loopx.cli import main -from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection -from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime -from tests.test_loopx_turn_driver import ( - _write_live_fixture, _completion_host_and_validation_scripts, _turn_run_once_completion_argv, _turn_journal, -) - - -def _cli(args): - output = io.StringIO() - with contextlib.redirect_stdout(output): - code = main(args) - return code, json.loads(output.getvalue()) - - -@pytest.mark.parametrize("provider", ["file", "sqlite"]) -@pytest.mark.parametrize("interruption", [None, "result_response_lost", "direction_call_failed", "stale_direction", "deferred_terminal", "continuous_direction_change"]) -def test_managed_first_delivery_judges_after_result_and_replays_without_host(tmp_path, monkeypatch, provider, interruption): - isolate_sqlite_runtime(tmp_path, monkeypatch) - project, runtime, registry = _write_live_fixture(tmp_path) - state = project / ".codex/goals/loopx-turn-fixture/ACTIVE_GOAL_STATE.md" - todo = {"schema_version": "todo_item_v0", "todo_id": "todo_fixture0001", "index": 1, - "done": False, "text": "Advance one public fixture.", "role": "agent", "status": "open", - "archive_state": "active", "source_section": "Agent Todo", "task_class": "advancement_task", - "action_kind": "fixture", "claimed_by": "codex-fixture", "priority": "P0"} - if interruption == "deferred_terminal": - todo["no_followup"] = True - initialize_canonical_authority(runtime, "loopx-turn-fixture", - build_todo_runtime_shadow_projection(goal_id="loopx-turn-fixture", handoff_mode="soft_claim", todos=[todo]), - state_path=state, provider=provider) - prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] - code, baseline = _cli([*prefix, "refresh-state", "--goal-id", "loopx-turn-fixture", "--agent-id", "codex-fixture", - "--vision-summary", "Validate the fixture and review remaining work.", "--vision-acceptance", "Fixture validation passes.", - "--no-global-sync", "--suppress-external-sinks"]) - assert code == 0, baseline - host_project = project / "host-workspace" - host_project.mkdir() - host, validation = _completion_host_and_validation_scripts() - host = host.replace("request = json.load(sys.stdin)", '''request = json.load(sys.stdin) -with pathlib.Path("host-calls.txt").open("a") as log: - log.write("direction\\n" if "direction_review" in request else "implementation\\n") -if "direction_review" in request: - context = request["direction_review"]["context"] - assert context["basis"]["todo"]["status"] == "done" - json.dump({"read_context_id": context["read_context_id"], "decision": "continue", - "vision_unchanged_reason": "The fixture passed; select remaining work from the current frontier."}, sys.stdout) - raise SystemExit(0) -assert "delivery_result_context" in request -''') - if interruption == "deferred_terminal": - host = host.replace('== "done"', '== "open"').replace('"decision": "continue"', '"decision": "terminal_ready"') - argv = _turn_run_once_completion_argv(host_project, runtime, registry, host, validation) - host_file = host_project / "host.py" - host_file.write_text(host, encoding="utf-8") - argv[argv.index(json.dumps([sys.executable, "-c", host]))] = json.dumps([sys.executable, str(host_file)]) - argv.insert(-1, "--first-delivery") - if interruption == "result_response_lost": - from loopx.cli_commands import turn_run_once - original = turn_run_once.write_turn_validated_completion - - def lose_once(**kwargs): - original(**kwargs) - monkeypatch.setattr(turn_run_once, "write_turn_validated_completion", original) - raise OSError("injected response loss after result CAS") - - monkeypatch.setattr(turn_run_once, "write_turn_validated_completion", lose_once) - elif interruption in {"stale_direction", "continuous_direction_change"}: - from loopx.cli_commands import turn_run_once - original = turn_run_once.refresh_state_run - - def change_basis_once(**kwargs): - if kwargs.get("first_delivery"): - state.write_text(state.read_text(encoding="utf-8") + "\n## Direction update\nReview the current remaining frontier.\n", encoding="utf-8") - if interruption == "stale_direction": - monkeypatch.setattr(turn_run_once, "refresh_state_run", original) - return original(**kwargs) - - monkeypatch.setattr(turn_run_once, "refresh_state_run", change_basis_once) - elif interruption == "direction_call_failed": - from loopx.control_plane.turn_driver import direction_review as first_delivery - original = first_delivery.review_first_delivery - - def fail_once(**kwargs): - monkeypatch.setattr(first_delivery, "review_first_delivery", original) - raise OSError("injected interruption before direction Host") - - monkeypatch.setattr(first_delivery, "review_first_delivery", fail_once) - code, result = _cli(argv) - if interruption not in {None, "deferred_terminal"}: - assert result["status"] == "failed", result - assert result["first_delivery_progress"]["stage"] in {"direction_pending", "operation_unknown"} - code, result = _cli([*argv[:-1], "--resume-turn-key", result["resume_turn_key"], "--retry-failed-turn", "--execute"]) - if interruption == "continuous_direction_change": - assert result["status"] == "failed", result - code, exhausted = _cli([*argv[:-1], "--resume-turn-key", result["resume_turn_key"], "--retry-failed-turn", "--execute"]) - assert exhausted["status"] == "failed", exhausted - assert "budget exhausted" in json.dumps(exhausted) - assert (host_project / "host-calls.txt").read_text().splitlines() == ["implementation", "direction", "direction"] - rows = [json.loads(line) for line in (runtime / "goals/loopx-turn-fixture/runs/index.jsonl").read_text().splitlines()] - assert not any(row["classification"] == "quota_slot_spent" for row in rows) - assert not any(row.get("vision_checkpoint", {}).get("read_context") for row in rows) - return - assert code == 0 and result["status"] == "committed", json.dumps(result) - assert result["first_delivery_progress"]["stage"] == "settled" - calls = ["implementation", "direction"] + (["direction"] if interruption == "stale_direction" else []) - assert (host_project / "host-calls.txt").read_text().splitlines() == calls - journal = _turn_journal(runtime) - assert len(journal["direction_reviews"]) == len(calls) - 1 - if interruption != "deferred_terminal": - assert journal["delivery_completion"]["ok"] - assert journal["direction_reviews"][-1]["response"]["read_context_id"] == journal["direction_reviews"][-1]["read_context_id"] - code, replay = _cli([*argv[:-1], "--resume-turn-key", result["resume_turn_key"], "--execute"]) - assert code == 0 and replay["replayed"], replay - assert (host_project / "host-calls.txt").read_text().splitlines() == calls - rows = [json.loads(line) for line in (runtime / "goals/loopx-turn-fixture/runs/index.jsonl").read_text().splitlines()] - assert sum(row["classification"] == "quota_slot_spent" for row in rows) == 1 - assert sum(bool(row.get("vision_checkpoint", {}).get("read_context")) for row in rows) == 1 diff --git a/tests/control_plane/test_first_delivery_mcp.py b/tests/control_plane/test_first_delivery_mcp.py index 652c8ab017..d22c9c3900 100644 --- a/tests/control_plane/test_first_delivery_mcp.py +++ b/tests/control_plane/test_first_delivery_mcp.py @@ -1,4 +1,4 @@ -"""MCP -> real CLI -> local authority phased result/direction settlement.""" +"""Caller-authored direction, real CLI recovery, and one original settlement.""" from __future__ import annotations import json @@ -14,7 +14,7 @@ @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("new_obligation", [False, True]) -def test_mcp_first_delivery_and_terminal_recheck(tmp_path, monkeypatch, provider, new_obligation): +def test_mcp_first_delivery_recovers_stale_direction_without_repeating_result(tmp_path, monkeypatch, provider, new_obligation): isolate_sqlite_runtime(tmp_path, monkeypatch) registry, state = _write_fixture(tmp_path) registration = json.loads(registry.read_text(encoding="utf-8")) @@ -32,10 +32,10 @@ def test_mcp_first_delivery_and_terminal_recheck(tmp_path, monkeypatch, provider rc, baseline = _run_cli(registry, "refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--vision-summary", "Verify the bounded output.", "--vision-acceptance", "Output verification passes.", "--no-global-sync", "--suppress-external-sinks") - assert rc == 0, baseline + assert rc == 0, json.dumps(baseline) control = _control(registry) intent = dict(todo_id=todo_id, agent_id=AGENT_ID, evidence="The bounded output passed verification.", - no_follow_up=True, first_delivery=True) + next_agent_todo="Verify the remaining bounded output.", first_delivery=True) first = json.loads(control.complete_task(**intent)) assert first["ok"] and first["stage"] == "result_review_pending", first result_id = first["context"]["read_context_id"] @@ -63,12 +63,12 @@ def test_mcp_first_delivery_and_terminal_recheck(tmp_path, monkeypatch, provider injected = [] def provider_call(args, **kwargs): - output = run_cli(args, **kwargs) - if new_obligation and args[:2] == ["quota", "spend-slot"] and not injected: - injected.append(add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", - text="Verify the newly required output.", task_class="advancement_task", - claimed_by=AGENT_ID, continuation_policy="same_agent_non_delivery")) - return output + if new_obligation and args[:1] == ["refresh-state"] and not injected: + # Change the relevant basis before the native direction commit. + # The original result is already durable; only direction is retried. + state.write_text(state.read_text(encoding="utf-8") + "\n## Acceptance update\nVerify an additional bounded output.\n", encoding="utf-8") + injected.append(True) + return run_cli(args, **kwargs) control.run_cli = provider_call decision = dict(delivery_read_context_id=result_id, read_context_id=direction_id, @@ -76,12 +76,17 @@ def provider_call(args, **kwargs): final = json.loads(control.complete_task(**intent, **decision)) assert final["ok"] is (not new_obligation), json.dumps(final) if new_obligation: - assert final["settlement"]["failed_stage"] == "terminal_closeout", final + assert final["settlement"]["failed_stage"] == "durable_writeback", final assert "checkpoint_read_context_stale" in final["settlement"]["reason"], final - else: - assert final["settlement"]["terminal_closeout"]["completion_continuation"] == "no_followup", final - replay = json.loads(control.complete_task(**intent, **decision)) - assert replay["ok"], replay + control.run_cli = run_cli + reread = json.loads(control.review_task_vision(todo_id, AGENT_ID, first_delivery=True)) + assert reread["ok"], reread + decision["read_context_id"] = reread["read_context_id"] + decision["vision_unchanged_reason"] = "The updated acceptance was reviewed; the successor retains the additional work." + final = json.loads(control.complete_task(**intent, **decision)) + assert final["ok"], final + replay = json.loads(control.complete_task(**intent, **decision)) + assert replay["ok"] and replay["settlement_identity"] == final["settlement_identity"], replay rows = [json.loads(line) for line in (runtime / f"goals/{GOAL_ID}/runs/index.jsonl").read_text().splitlines()] assert sum(row["classification"] == "quota_slot_spent" for row in rows) == 1 assert sum(bool(row.get("vision_checkpoint", {}).get("read_context")) for row in rows) == 1 diff --git a/tests/control_plane/test_first_delivery_terminal.py b/tests/control_plane/test_first_delivery_terminal.py index a821ce1e2f..ea596f7437 100644 --- a/tests/control_plane/test_first_delivery_terminal.py +++ b/tests/control_plane/test_first_delivery_terminal.py @@ -6,9 +6,10 @@ import pytest -from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted, LocalCoordinationAuthorityRejection from loopx.control_plane.goals import checkpoint_context_io as context_io from loopx.control_plane.quota.settlement import SettlementIdentity +from loopx.todos import complete_goal_todo from tests.control_plane.test_checkpoint_provider_fence import fixture from tests.control_plane.checkpoint_process import refresh from tests.control_plane.test_quota_settlement_cli import AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _run_cli, _spend_run_count @@ -29,6 +30,30 @@ def _identity(): return SettlementIdentity(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_protected_terminal_is_rejected_before_new_effects(tmp_path, monkeypatch, provider): + project, runtime, registry, state, _, _ = fixture(tmp_path, monkeypatch, provider, first_delivery=True) + context = context_io.read_checkpoint_context(registry_path=registry, runtime_root_override=str(runtime), + goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id=TURN_ID, purpose="delivery_result") + index = runtime / "goals" / GOAL_ID / "runs/index.jsonl" + before = (_authority(runtime), state.read_bytes(), index.read_bytes()) + rc, rejected = _complete(project, runtime, registry, "--no-follow-up", + "--delivery-read-context", context["read_context_id"]) + # CLI keeps the existing settlement prerequisite before reaching its owner. + assert rc == 1 and rejected["settlement_blocked_completion"] is True, rejected + with pytest.raises(LocalCoordinationAuthorityRejection) as failure: + complete_goal_todo(registry_path=registry, runtime_root_arg=str(runtime), + goal_id=GOAL_ID, todo_id=TODO_ID, agent_id=AGENT_ID, + completion_turn_key=_identity().effect_id, completion_identity_source="turn_settlement", + delivery_read_context_id=context["read_context_id"], delivery_settlement_identity=_identity().as_dict(), + task_lease_idempotency_key=f"checkpoint-{TODO_ID}", task_lease_expected_version=1, + note="Validated the selected output.", no_followup=True) + assert failure.value.code == "delivery_no_followup_unsupported" + assert failure.value.payload["delivery_read_context"]["reread_required"] is False + assert (_authority(runtime), state.read_bytes(), index.read_bytes()) == before + assert _spend_run_count(runtime) == 0 + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("history", ["none", "supplement", "damaged", "damaged_after_commit", "damaged_after_commit_missing_writeback"]) def test_ordinary_completion_and_cold_replay_survive_optional_history(tmp_path, monkeypatch, provider, history): diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 1dce481fd8..d9fa404840 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; import ts from "typescript"; @@ -13,7 +14,7 @@ import { delegationInventoryQuery } from "../../loopx/control_plane/collaboratio import { normalizeCollaborationBrief } from "../../loopx/control_plane/collaboration/semantic_request.ts"; import { decodeOutboxCursor } from "../../loopx/control_plane/coordination/local_authority_shadow_outbox.ts"; -const PACKAGE_ROOT = new URL("../../loopx", import.meta.url).pathname; +const PACKAGE_ROOT = fileURLToPath(new URL("../../loopx", import.meta.url)); const OWNER_FILE = "control_plane/content_digest.ts"; const CANONICAL_EXPORTS = ["BARE_SHA256_PATTERN", "ENVELOPED_SHA256_PATTERN"]; const HEX_CHARS = [..."0123456789abcdef"]; diff --git a/tests/control_plane_ts/first_delivery_projection.test.ts b/tests/control_plane_ts/first_delivery_projection.test.ts new file mode 100644 index 0000000000..4839de22f0 --- /dev/null +++ b/tests/control_plane_ts/first_delivery_projection.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {evaluateFirstDelivery} from "../../loopx/control_plane/turn_driver/first_delivery.ts"; + +test("an owner-confirmed exemption continues settlement without claiming a direction commit", () => { + const writeback_run = {vision_checkpoint: {required: false, satisfied: true, decision: "not_required"}}; + const pending = evaluateFirstDelivery({phase: "project", writeback_run, result_committed: true}); + assert.equal(pending.stage, "settlement_pending"); + assert.equal(pending.direction_required, false); + assert.equal(pending.direction_committed, false); + assert.equal(evaluateFirstDelivery({phase: "project", writeback_run, settlement_complete: true}).stage, "settled"); + for (const checkpoint of [undefined, {decision: "not_required"}, + {...writeback_run.vision_checkpoint, required: true}]) { + assert.equal(evaluateFirstDelivery({phase: "project", result_committed: true, + writeback_run: {vision_checkpoint: checkpoint}}).stage, "direction_pending"); + } +}); + +test("an uncommitted result asks for result validation without claiming retained output", () => { + const projected = evaluateFirstDelivery({phase: "project", result_committed: false}); + assert.equal(projected.stage, "result_review_pending"); + assert.equal(projected.result_committed, false); + assert.equal(projected.direction_committed, false); +}); + +test("native committed direction wins over a missing projection flag, but unresolved attempts remain unknown", () => { + const writeback_run = {vision_checkpoint: {satisfied: true, required: true, + read_context: {purpose: "first_delivery"}}}; + const direction_receipt = {commit_attempt: {}}; + const projected = evaluateFirstDelivery({phase: "project", writeback_run, direction_receipt}); + assert.equal(projected.stage, "settlement_pending"); + assert.equal(projected.direction_committed, true); + assert.equal(evaluateFirstDelivery({phase: "project", direction_receipt}).stage, "operation_unknown"); + assert.equal(evaluateFirstDelivery({phase: "project", writeback_run, unknown: true}).stage, "operation_unknown"); +}); diff --git a/tests/control_plane_ts/host_todo_completion.test.ts b/tests/control_plane_ts/host_todo_completion.test.ts index 459fcc7db8..d446312303 100644 --- a/tests/control_plane_ts/host_todo_completion.test.ts +++ b/tests/control_plane_ts/host_todo_completion.test.ts @@ -13,6 +13,13 @@ import { const todoId = "todo_abc123"; +test("PR1 rejects protected terminal intent before preparing provider effects", () => { + assert.throws(() => prepare({schema_version: "loopx_host_todo_completion_transaction_v2", + first_delivery: true}), /no effects were admitted/); + const ordinary = prepare(); + assert.ok(ordinary.provider_effect); +}); + test("vision refresh shares the original delivery command and identity without a spend", () => { const authored = {schema_version: HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION, vision_path: "fixture-vision.json"}; diff --git a/tests/test_loopx_turn_codex_cli.py b/tests/test_loopx_turn_codex_cli.py index 4d8a69eaf1..022cb8e434 100644 --- a/tests/test_loopx_turn_codex_cli.py +++ b/tests/test_loopx_turn_codex_cli.py @@ -131,7 +131,7 @@ def _fake_codex(tmp_path: Path) -> tuple[Path, Path]: turn_key = re.search(r'"turn_key":"([^"]+)"', prompt).group(1) print(json.dumps({ "type": "thread.started", - "thread_id": os.environ.get("FAKE_CODEX_SESSION_ID", "session-fixture-0001"), + "thread_id": "session-fixture-0001", "raw_trajectory": "must-not-persist", "private_material": "must-not-persist" }), flush=True) @@ -194,8 +194,6 @@ def _fake_codex(tmp_path: Path) -> tuple[Path, Path]: "vision_unchanged_reason": "The fixture objective remains unchanged.", "summary": "One public fixture advanced." }), encoding="utf-8") -if os.environ.get("FAKE_CODEX_RESULT"): - output_path.write_text(os.environ["FAKE_CODEX_RESULT"], encoding="utf-8") """ executable.write_text( source.replace( @@ -1245,16 +1243,9 @@ def test_codex_cli_host_fails_closed_when_output_observation_is_incomplete( assert error.recovery_kind is None -@pytest.mark.parametrize(("diagnostic", "category"), [ - ("This model requires a newer version of Codex.", "model_requires_newer_codex"), - ("Session not found.", "session_missing"), - ("invalid_json_schema", "output_schema_rejected"), -]) -def test_codex_cli_host_discards_invalid_resume_session( +def test_codex_cli_host_discards_missing_resume_session( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - diagnostic: str, - category: str, ) -> None: executable, log_path = _fake_codex(tmp_path) monkeypatch.setenv("FAKE_CODEX_LOG", str(log_path)) @@ -1271,8 +1262,8 @@ def test_codex_cli_host_discards_invalid_resume_session( ) monkeypatch.setenv("FAKE_CODEX_FAIL", "1") - monkeypatch.setenv("FAKE_CODEX_FAILURE_STDERR", diagnostic) - with pytest.raises(BuiltInHostError, match=f"codex_cli_{category}"): + monkeypatch.setenv("FAKE_CODEX_FAILURE_CATEGORY", "session") + with pytest.raises(RuntimeError, match="codex_cli_session_missing"): run_codex_cli_host( _request( turn_key="sha256:" + "f" * 64, @@ -1289,63 +1280,6 @@ def test_codex_cli_host_discards_invalid_resume_session( assert codex_cli_session_binding(runtime_root, envelope) is None -@pytest.mark.parametrize(("diagnostic", "category"), [ - (None, None), - ("This model requires a newer version of Codex.", "model_requires_newer_codex"), - ("Session not found.", "session_missing"), - ("invalid_json_schema", "output_schema_rejected"), -]) -def test_direction_session_never_mutates_implementation_binding( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - diagnostic: str | None, category: str | None, -) -> None: - executable, log_path = _fake_codex(tmp_path) - monkeypatch.setenv("FAKE_CODEX_LOG", str(log_path)) - project = tmp_path / "project" - project.mkdir() - runtime_root = tmp_path / "runtime" - options = dict(runtime_root=runtime_root, project=project, - codex_bin=str(executable), sandbox="workspace-write", timeout_seconds=5) - first = _request() - run_codex_cli_host(first, **options) - binding_path, = runtime_root.glob("goals/*/turn-sessions/*.json") - original = binding_path.read_bytes() - resumed = _request(turn_key="sha256:" + "b" * 64, session_action="resume") - direction = {**first, "direction_review": {"context": {"read_context_id": "direction-context"}}} - response = {"read_context_id": "direction-context", "decision": "continue", - "vision_unchanged_reason": "The current direction remains applicable."} - with monkeypatch.context() as direction_environment: - direction_environment.setenv("FAKE_CODEX_SESSION_ID", "temporary-direction-session") - direction_environment.setenv("FAKE_CODEX_RESULT", json.dumps(response)) - if diagnostic: - direction_environment.setenv("FAKE_CODEX_FAIL", "1") - direction_environment.setenv("FAKE_CODEX_FAILURE_STDERR", diagnostic) - with pytest.raises(BuiltInHostError, match=f"codex_cli_{category}"): - run_codex_cli_host(direction, **options) - else: - assert run_codex_cli_host(direction, **options) == response - assert binding_path.read_bytes() == original - assert list(runtime_root.glob("goals/*/turn-sessions/*.json")) == [binding_path] - # A new interpreter must recover from the persisted binding, not in-memory state. - cold_resume = subprocess.run([ - sys.executable, "-c", """ -import json, sys -from pathlib import Path -from loopx.control_plane.turn_driver.codex_cli import run_codex_cli_host -result = run_codex_cli_host(json.loads(sys.argv[1]), runtime_root=Path(sys.argv[2]), - project=Path(sys.argv[3]), codex_bin=sys.argv[4], sandbox="workspace-write", timeout_seconds=5) -print(json.dumps(result)) -""", json.dumps(resumed), str(runtime_root), str(project), str(executable), - ], cwd=Path(__file__).resolve().parents[1], text=True, capture_output=True, timeout=15) - assert cold_resume.returncode == 0, cold_resume.stderr - assert json.loads(cold_resume.stdout)["turn_key"] == resumed["turn_key"] - calls = [json.loads(line) for line in log_path.read_text().splitlines()] - assert len(calls) == 3 - assert "resume" not in calls[1] - assert calls[1][calls[1].index("--sandbox") + 1] == "read-only" - assert "resume" in calls[2] and "session-fixture-0001" in calls[2] - - def test_public_e2e_smoke_runs_n_transactions_on_one_session() -> None: root = Path(__file__).resolve().parents[1] result = subprocess.run( From 8cbccc0452c4ed794f9dd27500ce263b3d787b0e Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 6 Oct 2026 17:53:01 +0800 Subject: [PATCH 17/18] docs(delivery): define PR1 prerequisite and deferred terminal boundaries Signed-off-by: Tartar --- .../rfcs/loopx-overall-roadmap-v0.md | 21 +++--- .../goal-vision-replan-contract-v0.md | 65 +++++++++++-------- 2 files changed, 51 insertions(+), 35 deletions(-) diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 670427de88..ebb5e10e86 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -956,16 +956,19 @@ independent admission without weakening task-level validation. See the [acceptance contract](../../reference/goal-acceptance-observations.md#owner-authorized-contract-v0). This narrows a local recovery gap, not the full R1/R2 coordination acceptance. -**R1 first delivery checkpoint.** The optional local-registry File/SQLite path -binds Todo result commits and subsequent direction decisions to separate read -bases. CLI, managed execution and MCP share the existing typed owners and -original-Turn receipts; managed reasoning occurs after allowed result commits, -while deferred no-followup preserves refresh/spend/terminal ordering. Frontier -membership, relevant dependencies, acceptance and source identity are checked at -the owning commit. See the [operating protocol](../../reference/protocols/goal-vision-replan-contract-v0.md#opt-in-first-delivery-freshness). +**R1 first delivery checkpoint.** The opt-in local-registry File/SQLite +CLI/MCP prerequisite binds ordinary Todo result commits and subsequent +caller-authored direction decisions to separate read bases. Existing typed +owners retain original-Turn receipt recovery, same-head checks and provider CAS. +Relevant dependencies, frontier membership, acceptance and source identity are +checked at the owning commit. See the [operating protocol](../../reference/protocols/goal-vision-replan-contract-v0.md#opt-in-first-delivery-freshness). Unindexed artifacts remain unknown and require original-identity reconciliation. -This is local staged delivery, not distributed atomicity, source-session profile -qualification, independent Goal acceptance or evidence of improved Agent outcomes. +Protected no-followup is rejected before new effects; ordinary closeout remains +unchanged. Default owning-Agent continuation, committed-result repair and +protected terminal redirection are subsequent delivery; an optional independent +reviewer requires a separately registered contract. This prerequisite does not +close the frontend/Agent journey, distributed atomicity, independent Goal +acceptance or model-outcome acceptance. **R1 transaction checkpoint.** Team-plan admission and whole-batch planning now live in `work_items/team_plan.ts`. Confirmation assigns all admitted lanes in one write with a durable operation receipt; identity is proposal + lane, never Todo text. File/SQLite authority uses the existing CAS and receipt owner; legacy Markdown writes the records and immutable receipt together under its existing fence and lock. Exact replay reads historical results even after a receiver changes, completes or deletes work. A precommit failure creates no lane prefix, and pending canonical display delivery requires recovery before Chat reports verified success. The card names partial assignments and gaps; quota/stop remain advisory and an explicit enforcement claim is rejected. Agent-originated settlement binds the same state basis at its journal's first write and re-reads it at settlement; a plan whose basis is missing or moved, or whose every lane is a gap, is a typed failed receipt that creates no Todo and replays unchanged. diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index feceb2a6f1..3df699c341 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -412,14 +412,16 @@ First delivery protection extends the built-in Todo, checkpoint and Turn owners; it introduces no new provider or actor authority. It is off by default. Supported profiles are local-registry Goals with File or SQLite canonical Todo authority and an admitted, named Turn. Source-session GoalRef admission, other providers, -managed DSH/operation-tools execution and compound repair/replan effects are -rejected on this path. Existing non-opted-in behavior and checkpoint-only +managed execution, protected no-followup closeout and compound repair/replan +effects are outside this protocol stage. Existing non-opted-in behavior and checkpoint-only recovery retain their separate admission rules. The stages are independent: prepare and validate the candidate, commit the allowed result operation, read the resulting state, judge the direction outside -source/provider locks, commit that direction, then finish settlement. Managed -no-followup completion remains deferred until after refresh and quota spend. +source/provider locks, commit that direction, then finish ordinary settlement. +This stage is a CLI/MCP backend prerequisite. Default owning-Agent continuation +and its product journey remain a subsequent stage; an independent reviewer +requires its own registered input, result and recovery contract. Neither Todo completion nor a satisfied direction checkpoint certifies Goal completion. @@ -449,38 +451,28 @@ loopx --format json checkpoint-context --goal-id example --agent-id agent-a \ Read the returned basis and produce a new Vision or unchanged reason. Submit it using the existing delivery fields plus `refresh-state --first-delivery --checkpoint-read-context ID --progress-scope goal`. Both `agent_lane` and `goal` -scopes bind complete membership and dependency closure; no-followup requires -Goal scope. Reading more work grants no authority to write another Agent's work. +scopes bind complete membership and dependency closure. Reading more work grants no authority to write another Agent's work. Final submission protects registry, Goal state, index and the real provider through append. It rejects `--next-action` and Codex session usage booking as compound effects; explicit usage observations can accompany the run. Lock order: index, registry, maintenance, Todo projection, state, then provider. Model calls and validation commands stay outside this section. -Managed callers add `--first-delivery` to their existing qualified -`turn run-once --execute` command. The persisted plan retains enrollment on -`--resume-turn-key`; an unprotected adapter cannot recover it. The first Host -receives `delivery_result_context`. After validation and any allowed completion, -a separate direction-only Host receives current context and echoes its identity. -At most two direction attempts are allowed per Turn; these are real inference -calls, independently of the single quota debit. Failure, exhaustion or -`revalidate_result` retains the candidate and receipts for recovery. No-followup -also requires `terminal_ready`. Codex direction calls use an ephemeral read-only -session without MCP write tools. Generic adapters must honor the direction-only -IO contract. Retry a failed stage with the original resume key and -`--retry-failed-turn`; do not reexecute the completed implementation. - MCP callers use `complete_task(first_delivery=true)`. The v2 protocol first returns `result_review_pending` with a result context. Validate, then call again with `delivery_read_context_id`; it commits the ordinary completion and returns `direction_pending`. Judge that context and call again with both `delivery_read_context_id` and `read_context_id`, plus the new Vision/reason. `review_task_vision(first_delivery=true)` can reread a rejected direction; it -never attaches a new token automatically to an old Vision. Final no-followup -binds the committed direction and checks current work after spend. Existing -v0/v1 callers retain their contracts. - -Execution, status and quota readbacks expose `first_delivery_progress`: result and +never attaches a new token automatically to an old Vision. Protected +`no_follow_up=true` is rejected before any effects are admitted. Native protected +no-followup also rejects before a new commit, after historical receipt recovery. +Ordinary v0/v1 closeout and its refresh/spend/terminal order remain unchanged. +A future protected terminal stage must provide the legal transition for a changed +frontier after checkpoint or spend; retrying a refused terminal intent is not +that transition. + +Status and quota readbacks expose `first_delivery_progress`: result and direction commits, quota spend, pending stage and recovery action. Shared and Agent-scoped status mirror recovery text into the existing next-action field used by the dashboard and channel projections. These receipt observations are neither new @@ -510,12 +502,33 @@ JSON/Markdown without a complete consistent index remains operator reconciliation; another Turn cannot bypass that unresolved append. The files, provider, quota and Git are not one transaction. -To disable, omit the opt-in for **new** Turns. Finish or explicitly isolate -enrolled pending Turns with a compatible runtime before downgrading. Preserve +To disable, omit the opt-in for **new** Turns. Reconcile enrolled pending Turns +with their original identity and a compatible runtime before downgrading. Preserve receipts and successful artifacts. Existing integration receipts, exact candidate SHA validation and ref CAS still own code publication. This adds no cross-host, PostgreSQL or model-quality guarantee. +### 中文:本阶段边界与恢复 + +PR1 仅提供显式选择的本地 File/SQLite CLI/MCP 协议与后端前置路径。 +先读取结果依据并验证候选,由 Todo owner 检查原回执、当前依据及 CAS; +结果提交后再读取方向依据,由调用方提交当前 Vision 或 unchanged reason, +最后完成原 Turn 的 refresh/spend。共享机制不改变各 owner 的失败策略, +也不改变 Post-Writeback optional hook 的 isolate 与 quota 结算定义。 + +相关依据变化时,未提交结果必须重新验证候选;结果已经提交时只重新读取和 +判断方向。原 Turn、已提交结果和回执保持原身份,不能把旧判断贴上新 token。 +索引不完整或关键提交未知时保留原 artifacts,先协调原回执,不能换 Turn 绕过。 +明确的 `required=false/satisfied=true/decision=not_required` 沿用既有 checkpoint +豁免;缺少 checkpoint 记录不能被观察器猜成无需判断。 + +本阶段不接纳带保护的 no-followup;在新副作用前拒绝,历史精确回执仍优先恢复。 +普通旧路径及 refresh→spend→terminal 顺序保留。默认 owning Agent 的原会话续接、 +已提交结果要求修复及 protected terminal 改向出口由后续阶段闭合;独立 reviewer +另行登记完整契约。本阶段没有新模型调用、方向尝试预算或通用回滚协议。 +status/quota 的回执投影可供既有前端及 Lark 读取,但不代表默认产品旅程已交付, +也不代表 Goal 已完成或模型效果已提升。 + Code publication remains a separate operation. Qualify ref CAS against an integration target that is not checked out; this protocol does not wrap direct Git writes or promise concurrent working-directory isolation. If publication From 8979f570df27a6bf03bfb3e044fdcbcb4f143483 Mon Sep 17 00:00:00 2001 From: Tartar Date: Tue, 6 Oct 2026 18:17:44 +0800 Subject: [PATCH 18/18] refactor(checkpoint): remove deferred terminal-only payload Signed-off-by: Tartar --- loopx/control_plane/goals/checkpoint_context_io.py | 1 - 1 file changed, 1 deletion(-) diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index a707af21ed..d2d2fb2bbd 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -438,7 +438,6 @@ def commit_checkpoint_run( first_delivery=purpose == "first_delivery", ), "refresh_retry": refresh_retry, "record": record, "index_record": index_record, "markdown": markdown, - "committed_agent_vision": latest_agent_vision_from_runs([index_record], goal_id=identity.goal_id, agent_id=identity.agent_id), **({"goal_ref": dict(goal_ref)} if goal_ref is not None else {}), **( {"source_admission": dict(source_admission)}