From 46e6859a148656e9c4474ec3030fccb1220625de Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:53:44 +0800 Subject: [PATCH 01/13] feat: bind local reports to accepted Todo completions Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../managed-research-team/research_team.py | 1 + loopx/cli_commands/todo.py | 24 ++++- .../cli_commands/todo_argument_validation.py | 13 +++ loopx/cli_commands/todo_registration.py | 2 + .../coordination_state_contract.generated.ts | 1 + .../coordination_state_contract_generated.py | 1 + .../coordination_state_contract_v0.json | 1 + .../coordination/local_authority_runtime.ts | 2 + .../coordination/todo_terminal_lifecycle.ts | 35 +++++- .../goals/acceptance_contract.ts | 1 + .../control_plane/todos/completion_result.py | 100 ++++++++++++++++++ .../todos/provider_terminal_lifecycle.py | 26 +++++ loopx/todos.py | 1 + 13 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 loopx/control_plane/todos/completion_result.py diff --git a/examples/managed-research-team/research_team.py b/examples/managed-research-team/research_team.py index 7e5b1b2e3d..29fae9da26 100644 --- a/examples/managed-research-team/research_team.py +++ b/examples/managed-research-team/research_team.py @@ -134,6 +134,7 @@ def complete(root: Path, actor: str, revision: str) -> dict: result = cli(root, "todo", "complete", "--goal-id", GOAL, "--agent-id", actor, "--todo-id", todo_id(actor, revision), "--no-follow-up", "--note", "Bounded artifact task; synthesis consumes dependencies through its separately bound task.", + *(["--result-file", str(root / "lead" / "report.json")] if actor == "lead" else []), workspace=root / "project") require_completed(canonical_tasks(root), actor, revision) return result diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 08cf6d94b4..64c3227c27 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -2,6 +2,7 @@ import argparse from collections.abc import Callable, Sequence +from operator import itemgetter from pathlib import Path from ..control_plane.coordination.local_authority import ( @@ -24,6 +25,7 @@ from ..control_plane.todos.provider_projection import ( project_current_canonical_todos, ) +from ..control_plane.todos.completion_result import read_completion_result from ..history import load_index, load_registry from ..paths import resolve_runtime_root from ..registry import registry_goals @@ -52,6 +54,7 @@ validate_todo_complete_options, validate_todo_list_options, validate_todo_receipt_options, + validate_todo_result_read_options, validate_todo_project_markdown_options, validate_todo_plan_options, validate_todo_supersede_options, @@ -215,11 +218,13 @@ def handle_todo_command( post_writeback_hooks: Sequence[PostWritebackHookRegistration] | None = None, post_writeback_projection_builder: PostWritebackProjectionBuilder | None = None, ) -> int: - renderer = ( - render_task_planning_packet if args.todo_command == "plan" - else _render_todo_receipt if args.todo_command == "receipt" - else render_todo_markdown - ) + renderer = render_todo_markdown + if args.todo_command == "plan": + renderer = render_task_planning_packet + elif args.todo_command == "receipt": + renderer = _render_todo_receipt + elif args.todo_command == "result-read": + renderer = itemgetter("text") try: if args.todo_command is None: raise ValueError( @@ -266,6 +271,14 @@ def handle_todo_command( raise RuntimeError("canonical operation receipt returned an invalid result") payload = {"ok": result.get("status") in {"found", "missing"}, "command": "receipt", **result} + elif args.todo_command == "result-read": + validate_todo_result_read_options(args) + registry = load_registry(registry_path) + payload = read_completion_result( + registry_path=registry_path, + runtime_root=resolve_runtime_root(registry, runtime_root_arg), + goal_id=args.goal_id, todo_id=args.todo_id, + ) elif args.todo_command == "project-markdown": validate_todo_project_markdown_options(args) registry = load_registry(registry_path) @@ -542,6 +555,7 @@ def handle_todo_command( role=args.role, decision_outcome=args.decision_outcome, evidence=args.evidence, + completion_result_file=Path(args.result_file).expanduser() if args.result_file else None, completion_turn_key=completion_turn_key, 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 76b836ce5d..db764f9a68 100644 --- a/loopx/cli_commands/todo_argument_validation.py +++ b/loopx/cli_commands/todo_argument_validation.py @@ -310,6 +310,15 @@ def validate_todo_receipt_options(args: argparse.Namespace) -> None: raise ValueError("todo receipt requires --operation-id") +def validate_todo_result_read_options(args: argparse.Namespace) -> None: + if not args.todo_id: + raise ValueError("todo result-read requires --todo-id") + _validate_todo_option_subset( + args, {"todo_id"}, + "todo result-read only accepts --goal-id, --todo-id, and --format; unsupported: ", + ) + + def validate_todo_plan_options(args: argparse.Namespace) -> None: _validate_todo_option_subset( args, {"text", "agent_id"}, @@ -424,6 +433,8 @@ def validate_todo_update_options(args: argparse.Namespace) -> None: def validate_todo_complete_options(args: argparse.Namespace) -> None: if not args.todo_id: raise ValueError("todo complete requires --todo-id") + if args.result_file and args.role == "user": + raise ValueError("--result-file requires an Agent Todo") if args.explore_result_node_refs or args.clear_explore_result_node_refs: raise ValueError("todo complete does not update --explore-result-node-ref; use todo update first") if args.claimed_by and args.clear_claim: @@ -505,6 +516,8 @@ 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 != "receipt": raise ValueError("--operation-id is supported only by todo receipt") + 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 = ( args.todo_command == "add" and args.role == "user" diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index 237f284e4a..89ff07270c 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -32,6 +32,7 @@ def register_todo_command( "add", "list", "receipt", + "result-read", "claim", "update", "complete", @@ -104,6 +105,7 @@ def register_todo_command( todo_parser.add_argument("--status", choices=["open", "done", "blocked", "deferred"], help="For todo add/update, set the lifecycle status.") 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, bind a bounded local .json, .md or .txt result to the independently accepted completion.") todo_parser.add_argument( "--validation-command", help=( diff --git a/loopx/control_plane/coordination/coordination_state_contract.generated.ts b/loopx/control_plane/coordination/coordination_state_contract.generated.ts index 999c611571..66abe04a32 100644 --- a/loopx/control_plane/coordination/coordination_state_contract.generated.ts +++ b/loopx/control_plane/coordination/coordination_state_contract.generated.ts @@ -179,6 +179,7 @@ export const COORDINATION_STATE_CONTRACT = deepFreeze({ "reason", "completed_at", "completion_turn_key", + "completion_result", "updated_at", "superseded_by", "completion_validation_required", diff --git a/loopx/control_plane/coordination/coordination_state_contract_generated.py b/loopx/control_plane/coordination/coordination_state_contract_generated.py index 1b26928739..4237581309 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_generated.py +++ b/loopx/control_plane/coordination/coordination_state_contract_generated.py @@ -76,6 +76,7 @@ def _freeze(value: Any) -> Any: 'reason', 'completed_at', 'completion_turn_key', + 'completion_result', 'updated_at', 'superseded_by', 'completion_validation_required', diff --git a/loopx/control_plane/coordination/coordination_state_contract_v0.json b/loopx/control_plane/coordination/coordination_state_contract_v0.json index 62bd4c2db6..9186169ff4 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_v0.json +++ b/loopx/control_plane/coordination/coordination_state_contract_v0.json @@ -65,6 +65,7 @@ "reason", "completed_at", "completion_turn_key", + "completion_result", "updated_at", "superseded_by", "completion_validation_required", diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 63611154a9..c51977ea3f 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1351,6 +1351,8 @@ export async function terminalLifecycleLocalCoordinationTodo( goal_acceptance_source_binding: input.goal_acceptance_source_binding == null ? null : requireJsonObject(input.goal_acceptance_source_binding, "goal_acceptance_source_binding"), goal_acceptance_validation_receipts: input.goal_acceptance_validation_receipts, + completion_result: input.completion_result == null + ? null : requireJsonObject(input.completion_result, "completion_result"), completion_policy_request: input.completion_policy_request === null || input.completion_policy_request === undefined ? null : requireJsonObject(input.completion_policy_request, "completion_policy_request"), diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index ce847030e7..f6e1d14b76 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -104,6 +104,7 @@ interface CoordinationTodoTerminalLifecycleBaseInput { readonly validation_receipt: JsonObject | null; readonly goal_acceptance_source_binding?: JsonObject | null; readonly goal_acceptance_validation_receipts?: unknown; + readonly completion_result?: JsonObject | null; readonly completion_policy_request: JsonObject | null; readonly dry_run: boolean; readonly now: Date; @@ -476,6 +477,7 @@ function terminalRequestSha(input: CoordinationTodoTerminalLifecycleInput): stri successor_intents: input.successor_intents, clear_claim: input.clear_claim, completion_policy_request: completionPolicyIdentity, + ...(input.completion_result == null ? {} : {completion_result: input.completion_result}), validation_declaration_sha256: input.user_update !== undefined ? null : input.validation_declaration_sha256 ?? (input.validation_declaration === null ? null : canonicalAuthoritySha256(input.validation_declaration)), @@ -676,6 +678,27 @@ function acceptanceCompletionEvidence(head: JsonObject, input: ResolvedCoordinat return {source_binding: binding, ...evidence, validation_receipts: validationReceipts}; } +function acceptedCompletionResult(input: ResolvedCoordinationTodoTerminalLifecycleInput, + todo: JsonObject, acceptanceEvidence: JsonObject | null): JsonObject | null { + if (input.completion_result == null) return null; + const row = canonicalAuthorityObject(input.completion_result, "completion result"); + acceptanceRequire(input.command === "complete" && todo.role === "agent" && + acceptanceEvidence !== null && input.actor_agent_id === todo.claimed_by, + "A result requires an independently accepted Agent Todo owned by its producer."); + const fields = ["content_type", "provider", "sha256", "size_bytes"]; + acceptanceRequire(Object.keys(row).length === fields.length && fields.every(field => Object.hasOwn(row, field)) && + row.provider === "local_runtime_v0" && + ["application/json", "text/markdown", "text/plain"].includes(String(row.content_type)) && + typeof row.sha256 === "string" && /^[a-f0-9]{64}$/.test(row.sha256) && + Number.isSafeInteger(row.size_bytes) && Number(row.size_bytes) > 0 && Number(row.size_bytes) <= 128000, + "Completion result must be a bounded local content-addressed object."); + return {...row, schema_version: "loopx_completion_result_v0", + producer_agent_id: input.actor_agent_id, todo_id: input.todo_id, + completion_operation_id: input.operation_id, + acceptance_contract_digest: acceptanceEvidence.contract_digest, + acceptance_contract_revision: acceptanceEvidence.contract_revision}; +} + /** Keep the runner's typed failure at the public boundary without exposing its * command, output, workspace path, or caller-controlled summary. */ function acceptanceCriterionFailure(receipts: unknown): JsonObject | null { @@ -910,6 +933,7 @@ function terminalTarget( input: ResolvedCoordinationTodoTerminalLifecycleInput, completion: ReturnType | null, successorIds: readonly string[], + acceptedResult: JsonObject | null, ): { todo: JsonObject; clear_fields: string[] } { const updatedAt = input.now.toISOString().replace(/\.\d{3}Z$/u, "Z"); const next: JsonObject = { @@ -925,6 +949,7 @@ function terminalTarget( ...(input.decision_outcome === null ? {} : {decision_outcome: input.decision_outcome}), ...(input.requested_no_followup ? {no_followup: true} : {}), ...(successorIds.length === 0 ? {} : {successor_todo_ids: successorIds}), + ...(acceptedResult === null ? {} : {completion_result: acceptedResult}), }; if (input.command === "supersede") { next.note = input.note ?? "superseded"; @@ -1445,7 +1470,14 @@ export async function executeCoordinationTodoTerminalLifecycle( const currentLease = projection.leases.get(input.todo_id); const released = releasedLease(currentLease, authority, input); - const target = terminalTarget(todo, input, completion, successorIds); + let completionResult: JsonObject | null; + try { + completionResult = acceptedCompletionResult(input, todo, acceptanceEvidence); + } catch (error) { + return terminalFailure("completion_result_rejected", + error instanceof Error ? error.message : "Completion result rejected", {}, "decision_rejection"); + } + const target = terminalTarget(todo, input, completion, successorIds, completionResult); if (edit !== null) target.clear_fields = [...new Set([...edit.clearFields, ...target.clear_fields])]; const followthrough = input.command === "complete" && todo.role === "user" ? planUserCompletion(todo, [...projection.todos.values()], input.decision_outcome) : null; @@ -1471,6 +1503,7 @@ export async function executeCoordinationTodoTerminalLifecycle( completion_identity_source: completion === null ? null : completion.completion_identity_source, completed_at: target.todo.completed_at, + ...(completionResult === null ? {} : {completion_result: completionResult}), ...(acceptanceEvidence === null ? {} : {goal_acceptance_completion: acceptanceEvidence}), // A preview that omits this would show an unconditional close for work the // real call still gates. Name the criteria the real call must run; never diff --git a/loopx/control_plane/goals/acceptance_contract.ts b/loopx/control_plane/goals/acceptance_contract.ts index 45eb3a4c20..ee409c58e6 100644 --- a/loopx/control_plane/goals/acceptance_contract.ts +++ b/loopx/control_plane/goals/acceptance_contract.ts @@ -175,6 +175,7 @@ const NON_WORK_FIELDS = new Set([ "schema_version", "source_section", "index", "title", "priority", "status", "done", "archive_state", "claimed_by", "created_by", "last_actor_agent_id", "updated_at", "completed_at", "completion_turn_key", "completion_validation_sha256", "completion_recovery", "completion_continuation", "no_followup", "decision_outcome", + "completion_result", "decision_scope_outcomes", "note", "evidence", "reason", "handoff_note", "resume_ready", "resume_monitor_generation", "last_checked_at", "result_hash", "consecutive_no_change", "material_change", "material_change_generation", "monitor_effect_id", diff --git a/loopx/control_plane/todos/completion_result.py b/loopx/control_plane/todos/completion_result.py new file mode 100644 index 0000000000..9fa73edfff --- /dev/null +++ b/loopx/control_plane/todos/completion_result.py @@ -0,0 +1,100 @@ +"""Local, content-addressed output bytes for accepted Todo completions. + +The canonical Todo owns the binding. This provider owns bytes only and never +turns a file's existence into evidence of completion or acceptance. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +from pathlib import Path +from typing import Any + +MAX_RESULT_BYTES = 128_000 +_CONTENT_TYPES = {".json": "application/json", ".md": "text/markdown", ".txt": "text/plain"} +_DIGEST = re.compile(r"[a-f0-9]{64}\Z") + + +def _object_path(runtime_root: Path, goal_id: str, digest: str) -> Path: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", goal_id) or not _DIGEST.fullmatch(digest): + raise ValueError("invalid completion result identity") + return runtime_root / "goals" / goal_id / "result-objects" / digest + + +def _read_regular(path: Path) -> bytes: + with os.fdopen(os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | + getattr(os, "O_NONBLOCK", 0)), "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise ValueError("completion result must be a regular file") + data = stream.read(MAX_RESULT_BYTES + 1) + if not data or len(data) > MAX_RESULT_BYTES: + raise ValueError("completion result must contain 1..128000 bytes") + data.decode("utf-8") + return data + + +def store_completion_result(*, source: Path, runtime_root: Path, goal_id: str, + persist: bool = True) -> dict[str, Any]: + """Stage exact local bytes before the canonical completion transaction.""" + content_type = _CONTENT_TYPES.get(source.suffix.lower()) + if content_type is None: + raise ValueError("completion result supports .json, .md or .txt") + data = _read_regular(source) + if content_type == "application/json": + json.loads(data) + digest = hashlib.sha256(data).hexdigest() + if persist: + target = _object_path(runtime_root, goal_id, digest) + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | + getattr(os, "O_NOFOLLOW", 0), 0o600) + except FileExistsError: + if _read_regular(target) != data: + raise ValueError("content-addressed completion result changed") + else: + with os.fdopen(fd, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + return {"provider": "local_runtime_v0", "sha256": digest, + "size_bytes": len(data), "content_type": content_type} + + +def read_completion_result(*, registry_path: Path, runtime_root: Path, + goal_id: str, todo_id: str) -> dict[str, Any]: + """Exact owner-local read; absence, stale acceptance and byte drift fail closed.""" + from ..goals.acceptance import inspect_goal_acceptance + from ...todos import list_goal_todos + + todos = list_goal_todos(registry_path=registry_path, goal_id=goal_id, + todo_id=todo_id, runtime_root_arg=str(runtime_root)) + todo = todos.get("todo") + if not isinstance(todo, dict) or todo.get("status") != "done" or todo.get("done") is not True: + raise ValueError("completion result requires a current completed Todo") + binding = todo.get("completion_result") + if not isinstance(binding, dict) or binding.get("schema_version") != "loopx_completion_result_v0": + raise ValueError("Todo has no accepted completion result") + basis = inspect_goal_acceptance(registry_path=registry_path, goal_id=goal_id, + runtime_root=str(runtime_root)) + if todos.get("authority_read", {}).get("provider_revision") != basis.get("provider_revision"): + raise ValueError("completion result canonical snapshot changed; retry readback") + contract = basis.get("goal_acceptance_contract") + if (not isinstance(contract, dict) or contract.get("enabled") is not True or + contract.get("digest") != binding.get("acceptance_contract_digest") or + contract.get("revision") != binding.get("acceptance_contract_revision")): + raise ValueError("completion result acceptance basis is stale") + if binding.get("todo_id") != todo_id or binding.get("producer_agent_id") != todo.get("last_actor_agent_id"): + raise ValueError("completion result producer or Todo identity changed") + digest = binding.get("sha256") + if not isinstance(digest, str) or not _DIGEST.fullmatch(digest): + raise ValueError("completion result digest is invalid") + data = _read_regular(_object_path(runtime_root, goal_id, digest)) + if hashlib.sha256(data).hexdigest() != digest or len(data) != binding.get("size_bytes"): + raise ValueError("completion result bytes no longer match canonical binding") + return {"ok": True, "goal_id": goal_id, "todo_id": todo_id, + "result": binding, "text": data.decode("utf-8"), "audience": "local_operator"} diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index d1f0ba0116..bc3707e5fb 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -39,6 +39,7 @@ from .path_resolution import resolve_todo_state_path from .provider_projection import projection_delivery_requires_ack, settle_canonical_todo_projection from .successor_derivation import build_successor_intents +from .completion_result import store_completion_result _TERMINAL_REQUEST_SCHEMA = "loopx_local_coordination_todo_terminal_lifecycle_request_v3" _ARCHIVE_REQUEST_SCHEMA = "loopx_local_coordination_todo_archive_request_v0" @@ -121,6 +122,7 @@ def _route_terminal_call(command: str, call: Mapping[str, Any]) -> dict[str, Any authority_reason=call.get("authority_reason"), decision_outcome=call.get("decision_outcome") if complete else None, evidence=call.get("evidence") if complete else None, + completion_result_file=call.get("completion_result_file") if complete else None, note=call.get("note") if complete else "superseded", reason=None if complete else call.get("reason"), completion_turn_key=call.get("completion_turn_key") if complete else None, @@ -182,6 +184,8 @@ def routed(*args: Any, **kwargs: Any) -> dict[str, Any]: result = _route_terminal_call(command, bound.arguments) if result is None and bound.arguments.get("terminal_review_basis") is not None: raise ValueError("Reviewed canonical completion cannot fall back to legacy authority; regenerate preview") + if result is None and bound.arguments.get("completion_result_file") is not None: + raise ValueError("Completion results require promoted canonical Todo authority") return result if result is not None else legacy(*args, **kwargs) return routed @@ -254,6 +258,7 @@ def terminal_canonical_todo_if_promoted( authority_reason: str | None, decision_outcome: str | None, evidence: str | None, + completion_result_file: Path | None, note: str | None, reason: str | None, completion_turn_key: str | None, @@ -303,6 +308,12 @@ def terminal_canonical_todo_if_promoted( ) from exc if canonical is None: return None + result_descriptor = None + if completion_result_file is not None: + result_descriptor = store_completion_result( + source=completion_result_file, runtime_root=runtime_root, + goal_id=goal_id, persist=False, + ) todos = [dict(todo) for todo in canonical["todos"]] # The canonical transaction owns missing/role/archive lifecycle decisions. # Keep only the optional local validation facts needed by the host adapter. @@ -381,6 +392,7 @@ def terminal_canonical_todo_if_promoted( "successor_intents": successor_intents, "note": note, "evidence": evidence, + "completion_result": result_descriptor, "reason": reason, "clear_claim": clear_claim, "validation_declaration": None, @@ -418,6 +430,20 @@ def terminal_canonical_todo_if_promoted( delivery_workspace=completion_delivery_workspace, validation_workspace_path=completion_validation_workspace_path, )) + if completion_result_file is not None and not dry_run: + receipts = request.get("goal_acceptance_validation_receipts") + passed = (isinstance(receipts, list) and bool(receipts) and + all(isinstance(row, Mapping) and isinstance(row.get("receipt"), Mapping) and + row["receipt"].get("passed") is True for row in receipts)) + caller_receipt = request.get("validation_receipt") + if passed and (caller_receipt is None or + isinstance(caller_receipt, Mapping) and caller_receipt.get("passed") is True): + staged = store_completion_result( + source=completion_result_file, runtime_root=runtime_root, + goal_id=goal_id, + ) + if staged != result_descriptor: + raise ValueError("completion result changed during acceptance validation") completion_validation_executed = True request["observed_at"] = now_local() result = effect_runtime_result( diff --git a/loopx/todos.py b/loopx/todos.py index 8cee071875..fbb5bc438f 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -1555,6 +1555,7 @@ def complete_goal_todo( role: str | None = None, decision_outcome: str | None = None, evidence: str | None = None, + completion_result_file: Path | None = None, completion_turn_key: str | None = None, completion_identity_source: str | None = None, terminal_review_basis: Mapping[str, Any] | None = None, From adb7e8e45fcacd1c6236ded70932435e1ebc5ef5 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:54:32 +0800 Subject: [PATCH 02/13] test: prove accepted report readback and stale rejection Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- examples/managed-research-team/README.md | 11 ++++- .../goal_acceptance_runtime.test.ts | 41 +++++++++++++++++++ tests/test_managed_research_team.py | 21 +++++++++- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/examples/managed-research-team/README.md b/examples/managed-research-team/README.md index dd75ce46b5..9d0ec60ea0 100644 --- a/examples/managed-research-team/README.md +++ b/examples/managed-research-team/README.md @@ -185,8 +185,15 @@ After reading all configured canonical completions and exact artifact hashes, th lead writes `lead/report.json` with the fields described by `scenario.py` and the acceptance table below. Run `validate-report`, then complete the report through ordinary `todo complete --todo-id todo_lead-report --agent-id lead ---no-follow-up` against this disposable registry/runtime. That command reruns -the bound validator. Retain the original conversation; preparation does not +--no-follow-up --result-file "$DEMO_ROOT/lead/report.json"` against this disposable +registry/runtime. That command reruns the bound validator and binds the exact +report bytes to the canonical completion. The local operator can then run +`todo result-read --goal-id synthetic-managed-research --todo-id todo_lead-report` +with the same registry and runtime to read the accepted report. Changed bytes, +missing completion, or a changed acceptance contract reject the read. This +local CLI read does not grant a remote audience access; Files and conversation +delivery require their own authorized read surface. Retain the original +conversation; preparation does not attach, resume, migrate or impersonate any existing production Agent. ### Member relationships diff --git a/tests/control_plane_ts/goal_acceptance_runtime.test.ts b/tests/control_plane_ts/goal_acceptance_runtime.test.ts index 92d0268ddc..eefa2d3a09 100644 --- a/tests/control_plane_ts/goal_acceptance_runtime.test.ts +++ b/tests/control_plane_ts/goal_acceptance_runtime.test.ts @@ -220,6 +220,47 @@ for (const provider of ["file", ...(process.env.LOOPX_TEST_POSTGRES_URL ? ["post assert.equal((await executeCoordinationTodoTerminalLifecycle(store, terminal)).status, "replayed"); }); + test(`${provider}: accepted result binds to the same Todo transaction and producer`, async t => { + const descriptor = {provider: "local_runtime_v0", sha256: "a".repeat(64), + size_bytes: 5, content_type: "text/plain"}; + const withoutAcceptance = await seeded(t, provider, "off", {claimed_by: "agent-a"}); + assert.equal((await executeCoordinationTodoTerminalLifecycle(withoutAcceptance.store, {...terminal, + completion_result: descriptor})).reason_code, "completion_result_rejected"); + assert.equal(((await loaded(withoutAcceptance.store)).head.todos as JsonObject[])[0]!.done, false); + const unclaimed = await seeded(t, provider, "bound"); + const unclaimedPlan = await executeCoordinationTodoTerminalLifecycle(unclaimed.store, {...terminal, + completion_result: descriptor}); + assert.equal(unclaimedPlan.status, "execute_validation"); + const receipts = [{criterion_id: "criterion-a", receipt: runnerReceipt()}]; + const unclaimedAttempt = await executeCoordinationTodoTerminalLifecycle(unclaimed.store, {...terminal, + completion_result: descriptor, + goal_acceptance_source_binding: unclaimedPlan.goal_acceptance_source_binding as JsonObject, + goal_acceptance_validation_receipts: receipts}); + assert.equal(unclaimedAttempt.reason_code, "completion_result_rejected"); + assert.equal(((await loaded(unclaimed.store)).head.todos as JsonObject[])[0]!.done, false); + + const {store} = await seeded(t, provider, "bound", {claimed_by: "agent-a"}); + const plan = await executeCoordinationTodoTerminalLifecycle(store, {...terminal, completion_result: descriptor}); + assert.equal(plan.status, "execute_validation"); + const attempt = {...terminal, completion_result: descriptor, + goal_acceptance_source_binding: plan.goal_acceptance_source_binding as JsonObject, + goal_acceptance_validation_receipts: receipts}; + const malformed = await executeCoordinationTodoTerminalLifecycle(store, {...attempt, + completion_result: {...descriptor, sha256: "not-a-digest"}}); + assert.equal(malformed.reason_code, "completion_result_rejected"); + const committed = await executeCoordinationTodoTerminalLifecycle(store, attempt); + assert.equal(committed.status, "applied"); + const stored = ((await loaded(store)).head.todos as JsonObject[])[0]!.completion_result as JsonObject; + assert.equal(stored.sha256, descriptor.sha256); + assert.equal(stored.producer_agent_id, "agent-a"); + assert.equal(stored.todo_id, "todo_work"); + assert.equal(stored.acceptance_contract_digest, (await loaded(store)).head.goal_acceptance?.digest); + assert.equal(acceptanceWorkGuard((await loaded(store)).head, "goal-a", "todo_work")?.state, "ready"); + assert.equal((await executeCoordinationTodoTerminalLifecycle(store, attempt)).status, "replayed"); + assert.notEqual((await executeCoordinationTodoTerminalLifecycle(store, {...attempt, + completion_result: {...descriptor, sha256: "b".repeat(64)}})).status, "replayed"); + }); + test(`${provider}: intervening head changes require fresh validation, not an old success`, async t => { const {store} = await seeded(t, provider, "bound"); const plan = await executeCoordinationTodoTerminalLifecycle(store, terminal); diff --git a/tests/test_managed_research_team.py b/tests/test_managed_research_team.py index b843e42b45..6e12da701b 100644 --- a/tests/test_managed_research_team.py +++ b/tests/test_managed_research_team.py @@ -108,10 +108,29 @@ def test_canonical_delivery_requires_completed_current_dependencies(team, monkey with pytest.raises(RuntimeError, match="goal_acceptance_validation_rejected"): demo.complete(root, "lead", "report") report.write_bytes(original_report) - demo.complete(root, "lead", "report") + lead_completion = demo.complete(root, "lead", "report") + assert lead_completion["completion_result"]["sha256"] + result_read = demo.cli(root, "todo", "result-read", "--goal-id", demo.GOAL, + "--todo-id", "todo_lead-report") + assert json.loads(result_read["text"]) == json.loads(original_report) + assert result_read["result"]["sha256"] == lead_completion["completion_result"]["sha256"] + result_object = root / "runtime" / "goals" / demo.GOAL / "result-objects" / result_read["result"]["sha256"] + result_object.write_text("tampered") + with pytest.raises(RuntimeError, match="completion result bytes no longer match"): + demo.cli(root, "todo", "result-read", "--goal-id", demo.GOAL, + "--todo-id", "todo_lead-report") + result_object.write_bytes(original_report) assert all(row["done"] for row in canonical_tasks(root).values()) assert verify_goal_acceptance(**route, execute=True)["acceptance_ready"] assert json.loads((root / "registry.json").read_text())["goals"][0]["status"] == "active" + revised = json.loads((root / "bootstrap.json").read_text())["document"] + revised["objective"] = "Revised owner acceptance basis" + configure_goal_acceptance(**route, document=revised, + expected_provider_revision=inspect_goal_acceptance(**route)["provider_revision"], + execute=True) + with pytest.raises(RuntimeError, match="completion result acceptance basis is stale"): + demo.cli(root, "todo", "result-read", "--goal-id", demo.GOAL, + "--todo-id", "todo_lead-report") def test_bootstrap_refuses_existing_state(team): From f185eec6ab440585d43948849248228ba1cb095e Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:06:15 +0800 Subject: [PATCH 03/13] fix: recover accepted report replay after source removal Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../todos/provider_terminal_lifecycle.py | 24 +++++++++++++------ tests/test_managed_research_team.py | 3 +++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index bc3707e5fb..cba643db08 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -39,7 +39,7 @@ from .path_resolution import resolve_todo_state_path from .provider_projection import projection_delivery_requires_ack, settle_canonical_todo_projection from .successor_derivation import build_successor_intents -from .completion_result import store_completion_result +from .completion_result import read_completion_result, store_completion_result _TERMINAL_REQUEST_SCHEMA = "loopx_local_coordination_todo_terminal_lifecycle_request_v3" _ARCHIVE_REQUEST_SCHEMA = "loopx_local_coordination_todo_archive_request_v0" @@ -308,16 +308,26 @@ def terminal_canonical_todo_if_promoted( ) from exc if canonical is None: return None - result_descriptor = None - if completion_result_file is not None: - result_descriptor = store_completion_result( - source=completion_result_file, runtime_root=runtime_root, - goal_id=goal_id, persist=False, - ) todos = [dict(todo) for todo in canonical["todos"]] # The canonical transaction owns missing/role/archive lifecycle decisions. # Keep only the optional local validation facts needed by the host adapter. target = _todo_by_id(todos, todo_id) or {} + result_descriptor = None + if completion_result_file is not None: + try: + result_descriptor = store_completion_result( + source=completion_result_file, runtime_root=runtime_root, + goal_id=goal_id, persist=False, + ) + except FileNotFoundError: + if target.get("status") != "done": + raise + bound = read_completion_result( + registry_path=registry_path, runtime_root=runtime_root, + goal_id=goal_id, todo_id=todo_id, + )["result"] + result_descriptor = {key: bound[key] for key in + ("provider", "sha256", "size_bytes", "content_type")} with authority_registry_source(registry_path) as registry_source: registered, grants = todo_lifecycle_facts(registry_path, goal_id) successor_intents = build_successor_intents( diff --git a/tests/test_managed_research_team.py b/tests/test_managed_research_team.py index 6e12da701b..f8b3975132 100644 --- a/tests/test_managed_research_team.py +++ b/tests/test_managed_research_team.py @@ -114,6 +114,9 @@ def test_canonical_delivery_requires_completed_current_dependencies(team, monkey "--todo-id", "todo_lead-report") assert json.loads(result_read["text"]) == json.loads(original_report) assert result_read["result"]["sha256"] == lead_completion["completion_result"]["sha256"] + report.unlink() + assert demo.complete(root, "lead", "report")["idempotent_replay"] is True + report.write_bytes(original_report) result_object = root / "runtime" / "goals" / demo.GOAL / "result-objects" / result_read["result"]["sha256"] result_object.write_text("tampered") with pytest.raises(RuntimeError, match="completion result bytes no longer match"): From 4f5e7d5a54255678b1e721000f7a2bb1c3b4cfe4 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:49:40 +0800 Subject: [PATCH 04/13] feat: read accepted managed reports in Goal Files Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 21 ++++ .../personal-workspace/goal-loopx-mode.css | 1 + .../goal-managed-results.tsx | 101 ++++++++++++++++++ .../personal-workspace-page.tsx | 10 +- loopx/chat_completed_todos.py | 96 +++++++++++++++++ loopx/chat_server.py | 4 + 6 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-managed-results.tsx diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index a0403790c2..c5799d52ac 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -923,6 +923,27 @@ export function readLoopXTeamWork(sessionId: string, operationId: string) { method: "POST", body: JSON.stringify({operation: "read", operation_id: operationId}), }); } +export type ManagedGoalResultRow = { + todo_id: string; title: string; producer_agent_id: string; sha256: string; + content_type: string; size_bytes: number; completed_at?: string | null; +}; +export type ManagedGoalResultPage = { + ok: true; items: ManagedGoalResultRow[]; total: number; next_cursor: string | null; +}; +export type ManagedGoalResultRead = { + ok: true; goal_id: string; todo_id: string; text: string; + result: {sha256: string; content_type: string; producer_agent_id: string}; +}; +export function fetchManagedGoalResults(goalId: string, cursor?: string) { + const params = new URLSearchParams({goal_id: goalId}); + if (cursor) params.set("cursor", cursor); + return requestJson(`/api/chat/goal-results?${params}`); +} +export function readManagedGoalResult(goalId: string, todoId: string) { + return requestJson( + `/api/chat/goal-results/${encodeURIComponent(todoId)}?goal_id=${encodeURIComponent(goalId)}`, + ); +} // Keep inventory and selected-operation labels consistent; unknown states stay unknown. export function delegationStateLabel(row: {status: string; worker_active?: boolean; recovery_required: boolean | null}, zh: boolean) { if (row.status === "unavailable") return zh ? "无法核验" : "Unavailable"; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css index 81e9943124..d405af22ac 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css @@ -101,6 +101,7 @@ .goal-team-results p { font-size: 12px; line-height: 1.7; color: var(--pw-muted); } .goal-team-results [role="alert"] { color: var(--pw-red, #b42318); } .goal-team-results button, .goal-team-results select { min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; background: var(--pw-surface, #fff); color: inherit; font: inherit; cursor: pointer; } +.goal-managed-results > header button { flex-shrink: 0; white-space: nowrap; } .goal-team-results button[aria-pressed="true"] { border-color: var(--pw-text); } .goal-team-results button:disabled { opacity: .5; cursor: default; } .goal-team-results summary { cursor: pointer; padding: 12px 0; font-size: 12px; min-height: 44px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-managed-results.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-managed-results.tsx new file mode 100644 index 0000000000..1925a04107 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-managed-results.tsx @@ -0,0 +1,101 @@ +import {useEffect, useRef, useState} from "react"; +import {FileText, RefreshCw} from "lucide-react"; +import { + fetchManagedGoalResults, readManagedGoalResult, + type ManagedGoalResultPage, type ManagedGoalResultRow, type ManagedGoalResultRead, +} from "../../data/chat"; +import {TeamArtifactReport} from "./team-artifact-content"; + +/** Goal-scoped local reports; an inventory row never stands in for exact acceptance readback. */ +export function GoalManagedResults({goalId, zh}: {goalId: string; zh: boolean}) { + const [page, setPage] = useState(null); + const [selected, setSelected] = useState<{row: ManagedGoalResultRow; read: ManagedGoalResultRead} | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const generation = useRef(0); + const chosen = useRef<{todoId: string; sha256: string} | null>(null); + const reader = useRef(null); + + useEffect(() => { + chosen.current = null; + void load(); + return () => {generation.current++;}; + }, [goalId]); + + async function read(row: ManagedGoalResultRow, current: number, focus = false) { + const result = await readManagedGoalResult(goalId, row.todo_id); + if (current !== generation.current) return; + if (result.todo_id !== row.todo_id || result.goal_id !== goalId || + result.result.sha256 !== row.sha256 || result.result.producer_agent_id !== row.producer_agent_id) { + throw new Error(zh ? "报告版本或验收已变化" : "Report version or acceptance changed"); + } + chosen.current = {todoId: row.todo_id, sha256: row.sha256}; + setSelected({row, read: result}); + if (focus) window.requestAnimationFrame(() => reader.current?.focus()); + } + + async function load(cursor?: string) { + const current = ++generation.current; + if (cursor) chosen.current = null; + setBusy(true); setError(""); setSelected(null); + try { + const next = await fetchManagedGoalResults(goalId, cursor); + if (current !== generation.current) return; + setPage(next); + const previous = chosen.current; + const row = previous + ? next.items.find(item => item.todo_id === previous.todoId && item.sha256 === previous.sha256) + : next.items[0]; + if (previous && !row) { + setError(zh ? "上次报告已不在当前验收结果中。" : "The previous report is no longer in current accepted results."); + } else if (row) { + await read(row, current); + } + } catch (failure) { + if (current === generation.current) setError(`${zh ? "无法核验报告;旧内容已清除。" : "Cannot verify report; previous content was cleared."} ${String(failure)}`); + } finally { + if (current === generation.current) setBusy(false); + } + } + + async function select(row: ManagedGoalResultRow) { + const current = ++generation.current; + chosen.current = {todoId: row.todo_id, sha256: row.sha256}; + setBusy(true); setError(""); setSelected(null); + try {await read(row, current, true);} + catch (failure) { + if (current === generation.current) setError(`${zh ? "报告或验收已变化;旧内容已清除。" : "Report or acceptance changed; previous content was cleared."} ${String(failure)}`); + } finally {if (current === generation.current) setBusy(false);} + } + + const artifact = selected ? { + ref: selected.row.content_type === "text/markdown" ? "accepted-report.md" : + selected.row.content_type === "application/json" ? "accepted-report.json" : "accepted-report.txt", + sha256: selected.row.sha256, + text: selected.read.text, + } : null; + return
+

{zh ? "团队报告" : "Team reports"}

+

{zh ? "只有仍能通过当前验收的报告会出现在这里。" : "Only reports that still pass current acceptance appear here."}

+
+ {busy ?

{zh ? "正在核验报告…" : "Verifying reports…"}

: null} + {error ?

{error}

: null} + {page && !busy && !page.items.length ?

{zh ? "暂无可核验的团队报告。" : "No verifiable team reports yet."}

: null} + {page && page.items.length > 0 ?
+ + {artifact && selected ?
+ +

{zh ? "验收任务" : "Accepted Todo"}: {selected.row.todo_id}

+
: null} +
: null} +
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 80ea58cf9a..a5bbf24b08 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -32,6 +32,7 @@ import { import { ChannelHeader } from "./channel-header"; import { GoalLoopXMode } from "./goal-loopx-mode"; import { GoalTeamResults } from "./goal-team-results"; +import { GoalManagedResults } from "./goal-managed-results"; import { sendLoopXMessage, type LoopXModeSnapshot } from "../../data/chat"; import { ChannelTimeline } from "./channel-timeline"; import { ContextDrawer } from "./context-drawer"; @@ -187,12 +188,16 @@ function GoalOutputsView({ onSelect, reportState, teamSessionId, + goalId, + localResults, }: { active: boolean; items: Array>; onSelect: (selection: WorkspaceDrawerSelection) => void; reportState?: WorkspaceModel["periodicReports"]; teamSessionId?: string; + goalId: string; + localResults: boolean; }) { const { locale, t } = useWorkspaceI18n(); const [teamSnapshot, setTeamSnapshot] = useState(null); @@ -226,7 +231,7 @@ function GoalOutputsView({ {reportState?.error ? (

{t("files.reportLoadFailed")}: {reportState.error}

) : null} - {!reportState?.loading && !reportState?.error && items.length === 0 && !teamConfigured + {!reportState?.loading && !reportState?.error && items.length === 0 && !teamConfigured && !localResults && (!teamSessionId || Boolean(teamSnapshot)) ? (

{t("files.empty")}

) : null} @@ -242,6 +247,7 @@ function GoalOutputsView({ ].filter(Boolean).join(" · ")} ))} + {localResults ? : null} {active && teamSessionId && !teamSnapshot && !teamError ?

{t("files.checkingTeam")}

: null} {active && teamSessionId && teamError ?

{t("files.teamLoadFailed")}

: null} @@ -2030,6 +2036,8 @@ export function PersonalWorkspacePage({ onSelect={setSelection} reportState={model.periodicReports} teamSessionId={!readOnly && selectedAgentId === "codex" ? conversationSessionId : undefined} + goalId={selectedGoal.goalId} + localResults={!readOnly && selectedGoalTab === "files"} />), chat: (<> {selectedGoal && activeSessionRun?.goalId === selectedGoal.goalId ? ( diff --git a/loopx/chat_completed_todos.py b/loopx/chat_completed_todos.py index c7389fd72f..f8e039153b 100644 --- a/loopx/chat_completed_todos.py +++ b/loopx/chat_completed_todos.py @@ -10,6 +10,47 @@ from time import monotonic from urllib.parse import parse_qs, urlparse +from .paths import resolve_runtime_root +from .status_server import is_loopback_host + + +def _goal_result_rows(*, registry_path, runtime_root, goal_id): + """Project only results that still pass exact canonical acceptance readback.""" + from .control_plane.coordination.local_authority import read_canonical_todos_if_promoted + from .control_plane.todos.completion_result import read_completion_result + + payload = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id) + if payload is None: + return [] # Only the canonical completion writer can bind result bytes. + candidates = sorted( + ((index, item) for index, item in enumerate(payload["todos"]) + if item.get("role") == "agent" and item.get("status") == "done"), + key=lambda pair: (str(pair[1].get("completed_at") or ""), pair[0]), + reverse=True, + ) + rows = [] + for _, todo in candidates: + todo_id = todo.get("todo_id") + if not todo_id or not isinstance(todo.get("completion_result"), dict): + continue + try: + result = read_completion_result( + registry_path=registry_path, runtime_root=runtime_root, + goal_id=goal_id, todo_id=todo_id, + )["result"] + except (OSError, ValueError): + continue + rows.append({ + "todo_id": todo_id, + "title": str(todo.get("title") or todo.get("text") or todo_id), + "producer_agent_id": result["producer_agent_id"], + "sha256": result["sha256"], + "content_type": result["content_type"], + "size_bytes": result["size_bytes"], + "completed_at": todo.get("completed_at"), + }) + return rows + class CompletedTodoPages: page_size = 40 @@ -55,6 +96,61 @@ def page(self, *, scope, cursor, load): class CompletedTodoRequestMixin: + def _goal_result_scope(self, goal_id): + if not is_loopback_host(str(self.server.server_address[0])): + self._send_error("Goal results require a loopback LoopX Chat server.", status=403) + return None + if not self._require_loopback_origin(): + return None + registry, _goal = self._registry_and_goal(goal_id) + return resolve_runtime_root( + registry, self.server.runtime_root_override, + registry_path=self.server.registry_path, + ) + + def _goal_results(self) -> None: + query = parse_qs(urlparse(self.path).query) + goal_id = query.get("goal_id", [""])[0] + cursor = query.get("cursor", [""])[0] + try: + runtime_root = self._goal_result_scope(goal_id) + if runtime_root is None: + return + self._send_json(self.server.completed_todo_pages.page( + scope=("accepted_goal_results", goal_id), cursor=cursor, + load=lambda: _goal_result_rows( + registry_path=self.server.registry_path, + runtime_root=runtime_root, goal_id=goal_id, + ), + )) + except ValueError as exc: + expired = str(exc) == "history_cursor_expired" + self._send_error("history_cursor_expired" if expired else + "Goal results are unavailable.", status=409 if expired else 400) + except (OSError, RuntimeError): + self._send_error("Goal results could not be loaded.", status=503) + + def _goal_result(self, todo_id: str) -> None: + from .control_plane.todos.completion_result import read_completion_result + + goal_id = parse_qs(urlparse(self.path).query).get("goal_id", [""])[0] + try: + runtime_root = self._goal_result_scope(goal_id) + if runtime_root is None: + return + result = read_completion_result( + registry_path=self.server.registry_path, runtime_root=runtime_root, + goal_id=goal_id, todo_id=todo_id, + ) + self._send_json({ + "ok": True, "goal_id": goal_id, "todo_id": todo_id, + "result": result["result"], "text": result["text"], + }) + except ValueError: + self._send_error("The report or its current acceptance could not be verified.", status=409) + except (OSError, RuntimeError): + self._send_error("The report could not be read.", status=503) + def _completed_todos(self) -> None: # This loopback-only workspace read preserves task text and evidence. # Select display fields without returning the authority's internal metadata. diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 579196c31c..e96f444c28 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1284,6 +1284,7 @@ def do_GET(self) -> None: ) get_dispatch = { "/api/chat/completed-todos": self._completed_todos, + "/api/chat/goal-results": self._goal_results, CHAT_SESSIONS_PATH: self._list_sessions, CHAT_ACTIONS_PATH: self._action_list, CHAT_GOAL_CONTEXTS_PATH: self._goal_contexts, @@ -1298,6 +1299,9 @@ def do_GET(self) -> None: } if path in get_dispatch: return get_dispatch[path]() + result_parts = path.strip("/").split("/") + if len(result_parts) == 4 and result_parts[:3] == ["api", "chat", "goal-results"]: + return self._goal_result(result_parts[3]) setup_parts = path.strip("/").split("/") if len(setup_parts) == 5 and setup_parts[:4] == ["api", "chat", "lark", "app-setups"]: return self._lark_setup_snapshot(setup_parts[4]) From 979da3a5602353b6368124364967a41bf6faa1de Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:50:11 +0800 Subject: [PATCH 05/13] test: verify managed report Files readback Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- examples/managed-research-team/README.md | 11 ++-- examples/personal-workspace-browser-smoke.mjs | 3 +- .../managed-goal-results.mjs | 55 +++++++++++++++++++ tests/test_managed_research_team.py | 36 ++++++++++++ 4 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 examples/personal-workspace-browser/managed-goal-results.mjs diff --git a/examples/managed-research-team/README.md b/examples/managed-research-team/README.md index 9d0ec60ea0..d04a5e2c38 100644 --- a/examples/managed-research-team/README.md +++ b/examples/managed-research-team/README.md @@ -191,10 +191,13 @@ report bytes to the canonical completion. The local operator can then run `todo result-read --goal-id synthetic-managed-research --todo-id todo_lead-report` with the same registry and runtime to read the accepted report. Changed bytes, missing completion, or a changed acceptance contract reject the read. This -local CLI read does not grant a remote audience access; Files and conversation -delivery require their own authorized read surface. Retain the original -conversation; preparation does not -attach, resume, migrate or impersonate any existing production Agent. +local CLI read does not grant a remote audience access. On the same loopback +Chat server, the packaged Goal **Files** tab now lists only reports that pass +current acceptance and opens their exact text after another version check; +stale content is cleared. This Goal-scoped local view does not yet deliver a +reply to the original conversation or grant remote audience access. Retain the +original conversation; preparation does not attach, resume, migrate or +impersonate any existing production Agent. ### Member relationships diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 3afb98a579..b7ea92b92f 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -24,6 +24,7 @@ import { import { navigationSortingScenario } from "./personal-workspace-browser/navigation-sorting.mjs"; import { automationCadenceScenario } from "./personal-workspace-browser/automation-cadence.mjs"; import { teamEvidenceScenario } from "./personal-workspace-browser/team-evidence.mjs"; +import { managedGoalResultsScenario } from "./personal-workspace-browser/managed-goal-results.mjs"; import { loopxModeScenario } from "./personal-workspace-browser/loopx-mode.mjs"; import { progressiveLoadingScenario } from "./personal-workspace-browser/progressive-loading.mjs"; import { stewardJourneyScenario } from "./personal-workspace-browser/steward-journey.mjs"; @@ -31,7 +32,7 @@ import { teamPlanScenario } from "./personal-workspace-browser/team-plan.mjs"; import { typedActionsScenario } from "./personal-workspace-browser/typed-actions.mjs"; import { stewardModelSettingsScenario } from "./personal-workspace-browser/steward-model-settings.mjs"; -const scenarioCatalog = [navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, loopxModeScenario, teamEvidenceScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario]; +const scenarioCatalog = [navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario]; const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/managed-goal-results.mjs b/examples/personal-workspace-browser/managed-goal-results.mjs new file mode 100644 index 0000000000..c8016b8556 --- /dev/null +++ b/examples/personal-workspace-browser/managed-goal-results.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import {resolve} from "node:path"; +import {outputDir} from "./fixture.mjs"; +import {openWorkspacePage} from "./scenario-context.mjs"; + +export const managedGoalResultsScenario = { + id: "managed-goal-results", + async run({browser, collectCoverage, url}) { + const context = await openWorkspacePage(browser, url, {collectCoverage}); + const {page, api} = context; + const digest = "a".repeat(64); + let stale = false; + let reads = 0; + await page.route("**/api/chat/goal-results**", route => { + const request = new URL(route.request().url()); + if (request.pathname.endsWith("/todo_lead-report")) { + reads++; + return stale + ? route.fulfill({status: 409, json: {error: "acceptance changed"}}) + : route.fulfill({json: { + ok: true, goal_id: "product-release", todo_id: "todo_lead-report", + result: {sha256: digest, content_type: "text/markdown", producer_agent_id: "lead"}, + text: "# Revised conclusion\n\n| Measure | Value |\n| --- | --- |\n| Free cash flow | 25 |\n", + }}); + } + return route.fulfill({json: { + ok: true, items: [{todo_id: "todo_lead-report", title: "Revised cash flow", + producer_agent_id: "lead", sha256: digest, content_type: "text/markdown", size_bytes: 80}], + total: 1, next_cursor: null, + }}); + }); + try { + await page.locator(".personal-goal-link", {hasText: "Product Release"}).click(); + await page.getByRole("navigation", {name: "Goal 视图"}).getByRole("button", {name: "成果", exact: true}).click(); + const results = page.getByRole("region", {name: "已验收的团队报告"}); + await results.getByRole("table").waitFor(); + assert.equal(reads, 1, "The selected body must be revalidated after inventory read"); + assert.match(await results.textContent(), /Revised cash flow/); + assert.equal(await results.locator("script,img").count(), 0); + await page.screenshot({path: resolve(outputDir, "managed-goal-results-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + assert(await results.evaluate(el => el.scrollWidth <= el.clientWidth), "Files report must fit a phone"); + await page.screenshot({path: resolve(outputDir, "managed-goal-results-mobile.png"), animations: "disabled"}); + stale = true; + await results.getByRole("button", {name: "刷新", exact: true}).click(); + await results.getByRole("alert").waitFor(); + assert.equal(await results.getByRole("table").count(), 0, "Stale content must be cleared"); + assert.equal(api.turnRequests.length, 0, "Report reading must not start a model"); + await page.screenshot({path: resolve(outputDir, "managed-goal-results-stale.png"), animations: "disabled"}); + return {note: "Packaged Files shows only exact-read reports and clears stale results", coverageEntries: await context.close()}; + } finally { + if (!page.isClosed()) await context.close(); + } + }, +}; diff --git a/tests/test_managed_research_team.py b/tests/test_managed_research_team.py index f8b3975132..09ca7aa439 100644 --- a/tests/test_managed_research_team.py +++ b/tests/test_managed_research_team.py @@ -5,6 +5,9 @@ from pathlib import Path import subprocess import sys +import threading +from urllib.error import HTTPError +from urllib.request import Request, urlopen import pytest @@ -16,6 +19,8 @@ from loopx.control_plane.goals.acceptance import ( # noqa: E402 configure_goal_acceptance, inspect_goal_acceptance, verify_goal_acceptance, ) +from loopx.chat_completed_todos import CompletedTodoPages, _goal_result_rows # noqa: E402 +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler # noqa: E402 @pytest.fixture(params=["file", "sqlite"]) @@ -114,17 +119,47 @@ def test_canonical_delivery_requires_completed_current_dependencies(team, monkey "--todo-id", "todo_lead-report") assert json.loads(result_read["text"]) == json.loads(original_report) assert result_read["result"]["sha256"] == lead_completion["completion_result"]["sha256"] + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path = root / "registry.json" + server.runtime_root_override = str(root / "runtime") + server.completed_todo_pages = CompletedTodoPages() + server.verbose = False + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + listing_url = f"http://127.0.0.1:{server.server_port}/api/chat/goal-results?goal_id={demo.GOAL}" + report_url = f"http://127.0.0.1:{server.server_port}/api/chat/goal-results/todo_lead-report?goal_id={demo.GOAL}" + with urlopen(listing_url) as response: + listed = json.load(response) + assert [row["todo_id"] for row in listed["items"]] == ["todo_lead-report"] + with urlopen(report_url) as response: + assert json.load(response)["text"] == result_read["text"] + with pytest.raises(HTTPError) as forbidden: + urlopen(Request(report_url, headers={"Origin": "https://unrelated.example"})) + assert forbidden.value.code == 403 + finally: + server.shutdown() + server.server_close() + worker.join() report.unlink() assert demo.complete(root, "lead", "report")["idempotent_replay"] is True report.write_bytes(original_report) result_object = root / "runtime" / "goals" / demo.GOAL / "result-objects" / result_read["result"]["sha256"] result_object.write_text("tampered") + assert _goal_result_rows(registry_path=root / "registry.json", runtime_root=root / "runtime", goal_id=demo.GOAL) == [] with pytest.raises(RuntimeError, match="completion result bytes no longer match"): demo.cli(root, "todo", "result-read", "--goal-id", demo.GOAL, "--todo-id", "todo_lead-report") result_object.write_bytes(original_report) assert all(row["done"] for row in canonical_tasks(root).values()) assert verify_goal_acceptance(**route, execute=True)["acceptance_ready"] + demo.cli(root, "todo", "archive-completed", "--goal-id", demo.GOAL, + "--max-active-done", "0", "--execute") + assert [row["todo_id"] for row in _goal_result_rows( + registry_path=root / "registry.json", runtime_root=root / "runtime", goal_id=demo.GOAL, + )] == ["todo_lead-report"] + assert demo.cli(root, "todo", "result-read", "--goal-id", demo.GOAL, + "--todo-id", "todo_lead-report")["text"] == result_read["text"] assert json.loads((root / "registry.json").read_text())["goals"][0]["status"] == "active" revised = json.loads((root / "bootstrap.json").read_text())["document"] revised["objective"] = "Revised owner acceptance basis" @@ -134,6 +169,7 @@ def test_canonical_delivery_requires_completed_current_dependencies(team, monkey with pytest.raises(RuntimeError, match="completion result acceptance basis is stale"): demo.cli(root, "todo", "result-read", "--goal-id", demo.GOAL, "--todo-id", "todo_lead-report") + assert _goal_result_rows(registry_path=root / "registry.json", runtime_root=root / "runtime", goal_id=demo.GOAL) == [] def test_bootstrap_refuses_existing_state(team): From d552e4b15100f4d6e1ebddd89f1e6c5a7a0b9aeb Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:55:51 +0800 Subject: [PATCH 06/13] fix: bound managed report verification to the requested page Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 1 + .../goal-managed-results.tsx | 10 ++++- .../managed-goal-results.mjs | 2 +- loopx/chat_completed_todos.py | 45 +++++++++++++------ tests/test_chat_completed_todos.py | 20 ++++++++- tests/test_managed_research_team.py | 23 +++++++--- 6 files changed, 78 insertions(+), 23 deletions(-) diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index c5799d52ac..4cc7e491e8 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -929,6 +929,7 @@ export type ManagedGoalResultRow = { }; export type ManagedGoalResultPage = { ok: true; items: ManagedGoalResultRow[]; total: number; next_cursor: string | null; + unavailable_count: number; }; export type ManagedGoalResultRead = { ok: true; goal_id: string; todo_id: string; text: string; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-managed-results.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-managed-results.tsx index 1925a04107..f1c01ad0a1 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-managed-results.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-managed-results.tsx @@ -80,7 +80,15 @@ export function GoalManagedResults({goalId, zh}: {goalId: string; zh: boolean}) {busy ?

{zh ? "正在核验报告…" : "Verifying reports…"}

: null} {error ?

{error}

: null} - {page && !busy && !page.items.length ?

{zh ? "暂无可核验的团队报告。" : "No verifiable team reports yet."}

: null} + {page && page.unavailable_count > 0 ?

{zh + ? `本页有 ${page.unavailable_count} 份报告已无法通过当前核验。` + : `${page.unavailable_count} report(s) on this page cannot pass current verification.`}

: null} + {page && !busy && !page.items.length ?

{page.next_cursor + ? (zh ? "本页没有可核验的报告,可继续下一页。" : "No verifiable reports on this page; continue to the next page.") + : (zh ? "暂无可核验的团队报告。" : "No verifiable team reports yet.")}

: null} + {page?.next_cursor && !page.items.length ? : null} {page && page.items.length > 0 ?