From de548a6d843f989922a2572f0fdee1fce0e65940 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:31:24 +0800 Subject: [PATCH 1/3] fix(delegation): validate independent tasks through canonical Todo authority Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/collaboration_mcp.py | 49 +++---- .../control_plane/collaboration/delegation.ts | 47 +++++++ .../collaboration/delegation_validation.py | 60 ++++++++ .../coordination/todo_terminal_lifecycle.ts | 11 +- .../control_plane/effect_runtime_handlers.ts | 3 +- loopx/control_plane/goals/acceptance.py | 18 ++- .../goals/acceptance_authority.ts | 11 +- .../goals/acceptance_contract.ts | 10 ++ tests/control_plane_ts/delegation.test.ts | 49 ++++++- .../goal_acceptance_authority.test.ts | 9 ++ tests/test_delegation_preflight.py | 9 +- .../test_independent_delegation_validation.py | 129 ++++++++++++++++++ 12 files changed, 354 insertions(+), 51 deletions(-) create mode 100644 loopx/control_plane/collaboration/delegation_validation.py create mode 100644 tests/test_independent_delegation_validation.py diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py index c302dc9132..4c2ef7346e 100644 --- a/loopx/collaboration_mcp.py +++ b/loopx/collaboration_mcp.py @@ -26,9 +26,7 @@ from mcp.server.fastmcp import FastMCP from .file_lock import exclusive_file_lock, LockAcquisitionPolicy, LockAcquireTimeoutError -from .todos import list_goal_todos from .control_plane.effect_runtime import effect_runtime_result, EffectRuntimeRemoteError -from .control_plane.goals.acceptance import inspect_goal_acceptance, validate_goal_task_acceptance, goal_task_validation_files_current from .control_plane.coordination.local_authority import local_authority_is_promoted from .control_plane.todos.handoff_mode import show_goal_handoff_mode from .control_plane.turn_driver.journal_store import ( @@ -40,7 +38,7 @@ from .control_plane.collaboration.inbox import _hash, _read, _write, _root, _receipt from .control_plane.collaboration.peers import return_result from .control_plane.collaboration.inbox import acknowledge, _entry, normalize_request -from .control_plane.collaboration import delegation_results +from .control_plane.collaboration import delegation_results, delegation_validation from .control_plane.collaboration.peers import ( _goal, consume_return, @@ -303,10 +301,7 @@ def inspect(self, binding_id: str) -> dict: if not Path(binding["workspace"]).is_dir(): raise ValueError("delegation workspace unavailable") try: - acceptance = inspect_goal_acceptance(registry_path=self.registry, goal_id=self.goal_id, - runtime_root=str(self.root)) - files_current = goal_task_validation_files_current(registry_path=self.registry, - runtime_root=str(self.root), goal_id=self.goal_id, agent_id=binding["agent_id"], todo_id=binding["todo_id"]) + acceptance = delegation_validation.capture(self, binding) except (OSError, ValueError) as exc: # Authority admission is a readiness observation, not a reason for # inspection to invent a provider launch or collapse into a raw CLI error. @@ -363,19 +358,14 @@ def inspect(self, binding_id: str) -> dict: preview = self._cli(binding, *arguments) if preview.get("status") != "preview": raise ValueError(f"delegation Turn preflight unavailable: {preview.get('error') or preview.get('status')}") - current = inspect_goal_acceptance(registry_path=self.registry, goal_id=self.goal_id, - runtime_root=str(self.root)) - if (acceptance != current or self.binding(binding_id, require_active=True) != binding - or files_current != goal_task_validation_files_current(registry_path=self.registry, - runtime_root=str(self.root), goal_id=self.goal_id, - agent_id=binding["agent_id"], todo_id=binding["todo_id"])): + current = delegation_validation.capture(self, binding) + if acceptance != current or self.binding(binding_id, require_active=True) != binding: raise ValueError("delegation preflight source changed; retry inspection") - task = next((row for row in (acceptance.get("goal_acceptance_contract") or {}).get("tasks", []) - if row.get("todo_id") == binding["todo_id"]), None) return effect_runtime_result("collaboration.delegation.preflight", { "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, "authority": {"ready": True, "reason": None}, - "preview": preview, "acceptance": task, "validation_files_current": files_current, + "preview": preview, "acceptance": acceptance["plan"], + "validation_files_current": acceptance["files_current"], }) def start(self, binding_id: str, operation_id: str, brief: dict, @@ -592,22 +582,12 @@ def _cli(self, binding: dict, *args: str, timeout: int = 60) -> dict: ) return value - def _validate(self, binding: dict) -> None: - value = validate_goal_task_acceptance(registry_path=self.registry, runtime_root=str(self.root), - goal_id=self.goal_id, agent_id=binding["agent_id"], todo_id=binding["todo_id"]) - if not value["passed"]: - raise ValueError("delegation task acceptance rejected") + def _validate(self, binding: dict) -> dict: + return delegation_validation.validate(self, binding) def _accepted(self, binding: dict) -> list[dict]: - self._validate(binding) - todos = list_goal_todos(registry_path=self.registry, goal_id=self.goal_id, runtime_root_arg=str(self.root)) - basis = inspect_goal_acceptance(registry_path=self.registry, goal_id=self.goal_id, runtime_root=str(self.root)) - if todos.get("authority_read", {}).get("provider_revision") != basis.get("provider_revision"): - raise ValueError("delegation canonical snapshot changed; retry readback") - todo = next((row for row in todos["todos"] if row["todo_id"] == binding["todo_id"]), {}) - guard = next((row for row in basis["goal_acceptance_contract"]["tasks"] - if row["todo_id"] == binding["todo_id"]), {}) - if not todo.get("done") or todo.get("status") != "done" or guard.get("state") != "ready": + validation = self._validate(binding) + if not validation["plan"]["canonical_done"]: raise ValueError("delegation requires current canonical completion") workspace = Path(binding["workspace"]).resolve() artifacts = [] @@ -867,8 +847,10 @@ def _complete_delegated_todo(self, row: dict, binding: dict) -> None: "--task-lease-expected-version", str(lease["version"]), ] - else: - arguments.append("--no-follow-up") + # A bounded member task returns to its requester; it is not terminal + # Goal intent. Ordinary completion may precede its original Turn's + # accounting (controller validation requires that order). Do not add + # no-follow-up, which correctly requires already-settled receipts. result = self._cli(binding, *arguments) if result.get("ok") is not True: raise ValueError( @@ -881,6 +863,9 @@ def _execute(self, path: Path, row: dict, binding: dict) -> None: execution = self._execution_arguments(binding, row["identity"]["operation_id"]) try: if row["status"] == "prepared": + acceptance = delegation_validation.capture(self, binding) + if acceptance["plan"]["state"] != "ready" or not acceptance["files_current"]: + raise ValueError("delegation task acceptance rejected before host launch") row["turn_instance_id"] = self._turn_instance_id(row) self._write_delegation_bootstrap(row, binding) self._acquire_delegation_lease(path, row, binding) diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts index f38451962e..be8ffe982b 100644 --- a/loopx/control_plane/collaboration/delegation.ts +++ b/loopx/control_plane/collaboration/delegation.ts @@ -3,6 +3,9 @@ import type {JsonObject} from "../effect_program.ts"; import {requireJsonObject} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; +import {acceptanceValidationEffects, type AcceptanceCompletionRequirements} from "../goals/acceptance_contract.ts"; +import {normalizeTodoCompletionValidationDeclaration} from "../todos/completion_validation_declaration.ts"; function requireThat(ok: unknown, message: string): asserts ok { if (!ok) throw new EffectRuntimeRequestError(message); @@ -10,6 +13,50 @@ function requireThat(ok: unknown, message: string): asserts ok { function text(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value.length <= 4096; } + +/** The canonical acceptance reader resolves owner scope before this plan. + * A null requirement is out of scope/disabled, never an unbound-task fallback. + * Private declarations must match the current Todo authority, also on readback. */ +export function delegationValidationPlan(params: JsonObject): JsonObject { + const binding = requireJsonObject(params.binding, "delegation binding"); + const basis = requireJsonObject(params.basis, "canonical validation basis"); + const todo = requireJsonObject(basis.todo, "canonical delegation Todo"); + requireThat(basis.status === "loaded" && text(basis.provider_revision) + && todo.todo_id === binding.todo_id, "delegation requires a current matching canonical Todo"); + requireThat(Object.hasOwn(basis, "completion_requirements"), "canonical acceptance scope required"); + const requirements = basis.completion_requirements === null ? null + : requireJsonObject(basis.completion_requirements, "canonical acceptance requirements"); + if (requirements !== null) requireThat(requirements.todo_id === todo.todo_id + && Array.isArray(requirements.criteria) && requirements.criteria.length > 0, + "delegation requires matching owner acceptance criteria"); + const unavailable = (reason: string) => ({todo_id: todo.todo_id, state: "unbound", + source: null, reason, effects: [], canonical_done: false}); + const effects: JsonObject[] = requirements === null ? [] + : acceptanceValidationEffects(requirements as AcceptanceCompletionRequirements, todo) + .map(row => ({...requireJsonObject(row.effect, "acceptance validation effect"), criterion_id: row.criterion_id})); + if (todo.completion_validation_required === true) { + if (params.declaration === null) return unavailable("completion_validation_declaration_unavailable"); + const declaration = requireJsonObject(params.declaration, "private validation declaration"); + const normalized = normalizeTodoCompletionValidationDeclaration(declaration, { + strict_fields: true, require_command: true, require_canonical_input: true, + }); + if (!normalized.ok || canonicalAuthoritySha256(declaration) !== todo.completion_validation_sha256) + return unavailable("completion_validation_declaration_mismatch"); + effects.push({kind: "caller_validation", validation_command: normalized.value.validation_command, + validation_argv: normalized.value.validation_command_argv, + validation_label: normalized.value.validation_label, + validation_timeout_seconds: normalized.value.validation_timeout_seconds, + validation_declaration_sha256: todo.completion_validation_sha256, + task_repository: todo.task_repository ?? null}); + } else { + requireThat(params.declaration === null && todo.completion_validation_sha256 == null, + "Todo without canonical validation authority cannot supply a declaration"); + if (requirements === null) return unavailable("independent_delegation_validation_required"); + } + return {todo_id: todo.todo_id, state: "ready", + source: requirements === null ? "todo_validation" : "goal_acceptance", + effects, canonical_done: todo.done === true && todo.status === "done"}; +} export function selectDelegationBinding(params: JsonObject): JsonObject { const config = requireJsonObject(params.config, "delegation configuration"); requireThat(config.schema_version === "loopx_local_delegation_v0", "unsupported delegation configuration"); diff --git a/loopx/control_plane/collaboration/delegation_validation.py b/loopx/control_plane/collaboration/delegation_validation.py new file mode 100644 index 0000000000..36fe637195 --- /dev/null +++ b/loopx/control_plane/collaboration/delegation_validation.py @@ -0,0 +1,60 @@ +"""Host IO for delegation's single typed acceptance/validation plan. + +The canonical reader resolves Goal scope; TypeScript selects validation effects. +Python resolves private declarations and executes only those authorized effects. +No validator output or successful declaration read completes a canonical Todo. +""" + +from pathlib import Path + +from ...agent_registry import load_goal_from_registry +from ...materials import goal_state_path +from ..effect_runtime import effect_runtime_result +from ..goals.acceptance import ( + inspect_goal_acceptance, + run_goal_acceptance_validation_effect, + validation_effect_files_current, +) +from ..todos.completion_validation import resolve_private_completion_validation_declaration + + +def capture(service, binding: dict) -> dict: + basis = inspect_goal_acceptance( + registry_path=service.registry, runtime_root=str(service.root), + goal_id=service.goal_id, agent_id=binding["agent_id"], todo_id=binding["todo_id"], + ) + todo = basis.get("todo") + if not isinstance(todo, dict): + raise ValueError("delegation canonical Todo unavailable") + goal = load_goal_from_registry(service.registry, service.goal_id) + state_file = goal_state_path(goal) if goal is not None else None + if state_file is None: + raise ValueError("delegation validation workspace unavailable") + declaration = resolve_private_completion_validation_declaration( + canonical_todo=todo, state_file=state_file, runtime_root=service.root, + registry_path=service.registry, goal_id=service.goal_id, + todo_id=binding["todo_id"], role=todo.get("role"), persist_if_resolved=False, + ) + plan = effect_runtime_result("collaboration.delegation.validation_plan", { + "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, + "basis": basis, "declaration": declaration, + }) + files_current = plan["state"] == "ready" and validation_effect_files_current( + effects=plan["effects"], registry_path=service.registry, goal_id=service.goal_id, + ) + return {"basis": basis, "plan": plan, "files_current": files_current} + + +def validate(service, binding: dict) -> dict: + before = capture(service, binding) + if before["plan"]["state"] != "ready" or not before["files_current"]: + raise ValueError("delegation task acceptance rejected") + results = [run_goal_acceptance_validation_effect( + effect=effect, registry_path=service.registry, goal_id=service.goal_id, + ) for effect in before["plan"]["effects"]] + after = capture(service, binding) + if after != before: + raise ValueError("delegation task acceptance changed during validation") + if not all(result["passed"] for result in results): + raise ValueError("delegation task acceptance rejected") + return after diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 947a2df720..5b269bbd80 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -4,7 +4,7 @@ import {normalizeTodoUpdateInput, prepareUpdatedTodo, type CoordinationTodoUpdat import {todoUpdateAdmissionRejection} from "./todo_update_admission.ts"; import { createHash } from "node:crypto"; import {acceptanceWorkGuard, acceptanceCompletionRequirements, validateAcceptanceCompletion, - acceptanceRequire, type AcceptanceCompletionRequirements} from "../goals/acceptance_contract.ts"; + acceptanceRequire, acceptanceValidationEffects, type AcceptanceCompletionRequirements} from "../goals/acceptance_contract.ts"; import {CoordinationCommandReceipt, commandReceiptResult} from "./command_receipt.ts"; import type { JsonObject } from "../effect_program.ts"; @@ -635,15 +635,6 @@ function acceptanceSourceBinding(input: ResolvedCoordinationTodoTerminalLifecycl contract_digest: requirements.contract_digest, todo_semantic_digest: requirements.todo_semantic_digest}; } -function acceptanceValidationEffects(requirements: AcceptanceCompletionRequirements, todo: JsonObject): JsonObject[] { - return requirements.criteria.map(criterion => ({criterion_id: criterion.id, effect: { - kind: "caller_validation", validation_command: null, validation_argv: criterion.validation_argv, - validation_label: criterion.id, validation_timeout_seconds: criterion.validation_timeout_seconds, - ...(criterion.validation_files == null ? {} : {validation_files: criterion.validation_files}), - task_repository: todo.task_repository ?? null, - }})); -} - /** Only the trusted execution adapter supplies these fresh, structured runner * receipts. Save the public-safe criterion results, never command output. */ function acceptanceCompletionEvidence(head: JsonObject, input: ResolvedCoordinationTodoTerminalLifecycleInput, diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 49dab8b839..8150dd2c85 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -13,7 +13,7 @@ import {evaluateUserCompletion} from "./todos/user_completion.ts"; import {projectTodoSuccession} from "./todos/succession.ts"; import {projectLegacyTodoWorkCounts} from "./todos/summary_lanes.ts"; import {sealProjectionEnvelope} from "./projection_envelope.ts"; -import {recordDelegationAdoption, delegationInventoryItem, delegationInventoryQuery, delegationPreflight, delegationTurnPlanDecision, recoverValidatedDelegationSettlement, selectDelegationBinding, transitionDelegationObservation} from "./collaboration/delegation.ts"; +import {recordDelegationAdoption, delegationInventoryItem, delegationInventoryQuery, delegationPreflight, delegationTurnPlanDecision, delegationValidationPlan, recoverValidatedDelegationSettlement, selectDelegationBinding, transitionDelegationObservation} from "./collaboration/delegation.ts"; import {planChatMode} from "./collaboration/chat_mode.ts"; import {resolveConversationScope} from "./collaboration/conversation_scope.ts"; import {previewTeamPlan, planTeamTransaction, teamTransactionIdentity} from "./work_items/team_plan.ts"; @@ -714,6 +714,7 @@ export function createEffectRuntimeHandlers( ], ["collaboration.delegation.binding", selectDelegationBinding], ["collaboration.delegation.preflight", delegationPreflight], + ["collaboration.delegation.validation_plan", delegationValidationPlan], ["collaboration.delegation.turn_plan", delegationTurnPlanDecision], ["collaboration.delegation.inventory_query", delegationInventoryQuery], ["collaboration.delegation.inventory_item", delegationInventoryItem], diff --git a/loopx/control_plane/goals/acceptance.py b/loopx/control_plane/goals/acceptance.py index 0463cf6ce6..e2c4dcd0fb 100644 --- a/loopx/control_plane/goals/acceptance.py +++ b/loopx/control_plane/goals/acceptance.py @@ -72,11 +72,13 @@ def inspect_goal_acceptance( goal_id: str, runtime_root: str | None = None, agent_id: str | None = None, + todo_id: str | None = None, ) -> dict[str, Any]: """Read one canonical basis; command declarations stay inside the host.""" return _result( _INSPECT_METHOD, - _routing(registry_path, goal_id, runtime_root, agent_id), + {**_routing(registry_path, goal_id, runtime_root, agent_id), + **({"todo_id": todo_id} if todo_id is not None else {})}, ) @@ -102,8 +104,18 @@ def goal_task_validation_files_current( basis = _result(_INSPECT_METHOD, route).get("completion_requirements") if not isinstance(basis, dict) or not basis.get("criteria"): return False - for effect in _criterion_effects(basis["criteria"]): - pins = effect["validation_files"] + return validation_effect_files_current( + effects=_criterion_effects(basis["criteria"]), registry_path=registry_path, + goal_id=goal_id, + ) + + +def validation_effect_files_current( + *, effects: list[dict[str, Any]], registry_path: Path, goal_id: str, +) -> bool: + """Observe declared verifier assets for an existing typed validation plan.""" + for effect in effects: + pins = effect.get("validation_files", []) workspace = None if pins: workspace, failure = _resolve_completion_validation_workspace( diff --git a/loopx/control_plane/goals/acceptance_authority.ts b/loopx/control_plane/goals/acceptance_authority.ts index 099a3c2681..409d36b10b 100644 --- a/loopx/control_plane/goals/acceptance_authority.ts +++ b/loopx/control_plane/goals/acceptance_authority.ts @@ -169,7 +169,8 @@ export async function inspectGoalAcceptance(store: AuthorityStore, goalId: strin return source(store, {status: "loaded", provider_revision: head.provider_revision, revision: state?.revision ?? null, contract_digest: state?.digest ?? null, contract: state?.enabled ? state.document : null, tasks, - ...(todoId === undefined ? {} : {completion_requirements: acceptanceCompletionRequirements(head.head, goalId, todoId)}), + ...(todoId === undefined ? {} : {todo: todos.get(todoId) ?? null, + completion_requirements: acceptanceCompletionRequirements(head.head, goalId, todoId)}), goal_acceptance_contract: projectGoalAcceptance(head.head, goalId)}); } @@ -188,7 +189,13 @@ async function local(value: unknown, kind: "inspect" | "configure" | "verify"): }; return kind === "inspect" ? await run() : await withCanonicalWriter(root, goalId, request.dry_run === true, run); } catch (error) { - const result = {...failure(error instanceof AuthorityStoreProtocolError ? "goal_acceptance_invalid_request" : "goal_acceptance_effect_failed", + // Preserve the canonical task guard's diagnosis on exact private reads. + // An unbound/stale task must never look like an absent contract eligible + // for independent validation, or an authority that needs re-promotion. + const taskReason = kind === "inspect" && error instanceof AuthorityStoreProtocolError + && ["goal_acceptance_unbound", "goal_acceptance_stale"].includes(error.message) + ? error.message : null; + const result = {...failure(taskReason ?? (error instanceof AuthorityStoreProtocolError ? "goal_acceptance_invalid_request" : "goal_acceptance_effect_failed"), error instanceof Error ? error.message : "acceptance effect failed"), decision_read_from_provider: false, legacy_fallback_used: false, ...localAuthorityOpenFailure(error)}; return store ? {...result, source_authority: authorityStoreSourceAuthority(store)} : result; diff --git a/loopx/control_plane/goals/acceptance_contract.ts b/loopx/control_plane/goals/acceptance_contract.ts index ee409c58e6..d4f9afdc05 100644 --- a/loopx/control_plane/goals/acceptance_contract.ts +++ b/loopx/control_plane/goals/acceptance_contract.ts @@ -70,6 +70,16 @@ export interface AcceptanceCompletionRequirements extends JsonObject { criteria: AcceptanceCriterion[]; } +/** Shared by terminal completion and read-only delegation validation. */ +export function acceptanceValidationEffects(requirements: AcceptanceCompletionRequirements, todo: JsonObject): JsonObject[] { + return requirements.criteria.map(criterion => ({criterion_id: criterion.id, effect: { + kind: "caller_validation", validation_command: null, validation_argv: criterion.validation_argv, + validation_label: criterion.id, validation_timeout_seconds: criterion.validation_timeout_seconds, + ...(criterion.validation_files == null ? {} : {validation_files: criterion.validation_files}), + task_repository: todo.task_repository ?? null, + }})); +} + export function acceptanceRequire(condition: unknown, message: string): asserts condition { if (!condition) throw new AuthorityStoreProtocolError(message); } diff --git a/tests/control_plane_ts/delegation.test.ts b/tests/control_plane_ts/delegation.test.ts index c95006ea4d..eba606ec9c 100644 --- a/tests/control_plane_ts/delegation.test.ts +++ b/tests/control_plane_ts/delegation.test.ts @@ -1,12 +1,59 @@ import test from "node:test"; import assert from "node:assert/strict"; -import {recordDelegationAdoption, delegationInventoryItem, delegationInventoryQuery, delegationPreflight, delegationTurnPlanDecision, recoverValidatedDelegationSettlement, selectDelegationBinding, transitionDelegationObservation} from "../../loopx/control_plane/collaboration/delegation.ts"; +import {recordDelegationAdoption, delegationInventoryItem, delegationInventoryQuery, delegationPreflight, delegationTurnPlanDecision, delegationValidationPlan, recoverValidatedDelegationSettlement, selectDelegationBinding, transitionDelegationObservation} from "../../loopx/control_plane/collaboration/delegation.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; const binding = {id: "review", agent_id: "reviewer", todo_id: "todo_review", workspace: "/fixture", requesters: ["coordinator", "analyst"], host_args: ["--host", "dsh"], timeout_seconds: 60, output_refs: ["output.json"]}; const params = {agent_id: "coordinator", binding_id: "review", config: {schema_version: "loopx_local_delegation_v0", bindings: [binding]}}; +const declaration = {validation_command: null, validation_command_argv: ["node", "validate.ts"], + validation_label: "Independent verification", validation_timeout_seconds: 5}; +const validationTodo = {todo_id: binding.todo_id, done: false, status: "open", + completion_validation_required: true, completion_validation_sha256: canonicalAuthoritySha256(declaration)}; +const validationBasis = {status: "loaded", provider_revision: "fixture:1", todo: validationTodo, + completion_requirements: null}; + +test("independent delegation requires the current canonical declaration, not a Goal-wide contract", () => { + const plan = delegationValidationPlan({binding, basis: validationBasis, declaration}); + assert.equal(plan.state, "ready"); + assert.equal(plan.source, "todo_validation"); + assert.equal(plan.canonical_done, false); + assert.deepEqual((plan.effects as Record[])[0].validation_argv, ["node", "validate.ts"]); + const completed = delegationValidationPlan({binding, declaration, basis: {...validationBasis, + todo: {...validationTodo, status: "done", done: true}}}); + assert.equal(completed.state, "ready"); + assert.equal(completed.canonical_done, true); + for (const value of [null, {...declaration, validation_command_argv: ["node", "other.ts"]}, + {...declaration, validation_command_argv: []}]) { + assert.equal(delegationValidationPlan({binding, basis: validationBasis, declaration: value}).state, "unbound"); + } + const undeclared = {...validationBasis, todo: {todo_id: binding.todo_id, status: "open", done: false}}; + assert.equal(delegationValidationPlan({binding, basis: undeclared, declaration: null}).state, "unbound"); + assert.throws(() => delegationValidationPlan({binding, basis: undeclared, declaration}), /without canonical/); + assert.throws(() => delegationValidationPlan({binding, basis: {...validationBasis, + todo: {...validationTodo, todo_id: "other"}}, declaration}), /matching canonical/); + assert.throws(() => delegationValidationPlan({binding, basis: {...validationBasis, + completion_requirements: undefined}, declaration})); +}); + +test("owner acceptance and ordinary Todo validation remain cumulative", () => { + const criteria = [{id: "review", description: "Check the result", validation_argv: ["node", "owner.ts"], + validation_timeout_seconds: 5, validation_files: [{path: "owner.ts", sha256: "a".repeat(64)}]}]; + const basis = {...validationBasis, completion_requirements: {todo_id: binding.todo_id, criteria}}; + const plan = delegationValidationPlan({binding, basis, declaration}); + assert.equal(plan.source, "goal_acceptance"); + assert.equal((plan.effects as unknown[]).length, 2); + assert.equal(delegationValidationPlan({binding, basis, declaration: null}).state, "unbound"); + const onlyOwner = {...basis, todo: {todo_id: binding.todo_id, status: "open", done: false}}; + assert.equal(delegationValidationPlan({binding, basis: onlyOwner, declaration: null}).state, "ready"); + for (const requirements of [{todo_id: "other", criteria}, {todo_id: binding.todo_id, criteria: []}]) { + assert.throws(() => delegationValidationPlan({binding, declaration, + basis: {...basis, completion_requirements: requirements}}), /matching owner/); + } +}); + test("same explicit grant contract applies to a coordinator and an ordinary member", () => { assert.deepEqual(selectDelegationBinding(params), binding); assert.deepEqual(selectDelegationBinding({...params, agent_id: "analyst"}), binding); diff --git a/tests/control_plane_ts/goal_acceptance_authority.test.ts b/tests/control_plane_ts/goal_acceptance_authority.test.ts index 477bb7a6db..09343f6dcb 100644 --- a/tests/control_plane_ts/goal_acceptance_authority.test.ts +++ b/tests/control_plane_ts/goal_acceptance_authority.test.ts @@ -566,6 +566,15 @@ for (const provider of ["file", "sqlite"] as const) { const inspect = await inspectLocalGoalAcceptance({runtime_root: root, goal_id: goal, todo_id: "todo_first"}); assert.equal(inspect.source_authority, `${provider}_v0`); assert.equal((inspect.tasks as JsonObject[]).length, 1); + assert.equal((inspect.todo as JsonObject).todo_id, "todo_first"); + const heldDoc = {...document(), bindings: [{todo_id: "todo_first", criterion_ids: ["prerequisite"]}]}; + assert.equal((await commitLocalGoalAcceptance({...await configureRequest(store, {document: heldDoc}), runtime_root: root})).status, "applied"); + assert.equal((await inspectLocalGoalAcceptance({runtime_root: root, goal_id: goal, todo_id: "todo_second"})).reason_code, + "goal_acceptance_unbound", "canonical task diagnosis survives the private effect adapter"); + await update(store, "todo_first", {text: "Changed owner-bound work"}); + assert.equal((await inspectLocalGoalAcceptance({runtime_root: root, goal_id: goal, todo_id: "todo_first"})).reason_code, + "goal_acceptance_stale"); + assert.equal((await commitLocalGoalAcceptance({...await configureRequest(store), runtime_root: root})).status, "applied"); const verified = await commitLocalGoalAcceptanceVerification({...await verifyRequest(store), runtime_root: root}); assert.equal((verified.goal_acceptance_contract as JsonObject).status, "accepted"); assert.equal((await loadLegacyCoordinationWriterFence(root, goal)).status, "missing", "acceptance never promotes a provider"); diff --git a/tests/test_delegation_preflight.py b/tests/test_delegation_preflight.py index f99b8f3c47..389f2a1645 100644 --- a/tests/test_delegation_preflight.py +++ b/tests/test_delegation_preflight.py @@ -130,7 +130,7 @@ def unavailable(**_kwargs): "activation never promotes a provider" ) - monkeypatch.setattr(delegation, "inspect_goal_acceptance", unavailable) + monkeypatch.setattr(delegation.delegation_validation, "inspect_goal_acceptance", unavailable) monkeypatch.setattr(runner, "_cli", lambda *args, **kwargs: calls.append(args)) result = runner.inspect("analysis") assert result["state"] == "authority_unavailable" @@ -457,7 +457,8 @@ def test_preflight_does_not_call_an_invalidated_acceptance_ready(service): assert result["state"] in {"turn_blocked", "acceptance_unavailable"} -def test_http_team_readback_uses_original_scope_without_a_new_turn(service): +@pytest.mark.parametrize("validation_basis", ["goal_acceptance", "independent"]) +def test_http_team_readback_uses_original_scope_without_a_new_turn(service, validation_basis): import http.client import threading from loopx.chat_runtime import ChatRuntimeController @@ -465,6 +466,10 @@ def test_http_team_readback_uses_original_scope_without_a_new_turn(service): from loopx.chat_store import ChatSessionStore root, runner = service + if validation_basis == "independent": + from test_independent_delegation_validation import independent_binding + + independent_binding(service) from loopx.agent_registry import load_goal_from_registry from pathlib import Path diff --git a/tests/test_independent_delegation_validation.py b/tests/test_independent_delegation_validation.py new file mode 100644 index 0000000000..0b5e10f492 --- /dev/null +++ b/tests/test_independent_delegation_validation.py @@ -0,0 +1,129 @@ +"""Independent delegation uses the canonical Todo's own validation contract.""" + +import json +import sys + +import pytest + +from loopx.control_plane.goals.acceptance import configure_goal_acceptance, inspect_goal_acceptance +from test_delegation_cli import cli +from test_local_delegation import brief, demo, service as delegation_service, wait + +service = delegation_service + + +def independent_binding(service, *, declared=True): + root, runner = service + basis = inspect_goal_acceptance(registry_path=runner.registry, goal_id=runner.goal_id, + runtime_root=str(runner.root)) + document = basis["contract"] + document["scope"] = {"kind": "selected_work", "todo_ids": ["todo_reviewer-initial"]} + document["bindings"] = [row for row in document["bindings"] + if row["todo_id"] == "todo_reviewer-initial"] + configured = configure_goal_acceptance(registry_path=runner.registry, goal_id=runner.goal_id, + runtime_root=str(runner.root), document=document, + expected_provider_revision=basis["provider_revision"], execute=True) + assert configured["status"] == "applied" + args = ["todo", "add", "--goal-id", runner.goal_id, "--role", "agent", + "--text", "Independently validate the assigned artifact", "--claimed-by", "analyst"] + if declared: + args += ["--validation-command-json", json.dumps([ + sys.executable, "validation/acceptance.py", str(root), "analyst", "initial"])] + created = demo.cli(root, *args) + todo_id = created["todo_id"] + config = json.loads(runner.config.read_text()) + config["bindings"][0]["todo_id"] = todo_id + runner.config.write_text(json.dumps(config)) + return todo_id + + +def test_independent_validator_qualifies_preflight_without_owner_rebinding(service): + todo_id = independent_binding(service) + status, check = cli(service[1], "inspect", "--binding-id", "analysis") + assert status == 0, check + assert check["state"] == "runtime_unverified", check + assert check["acceptance_ready"] and check["turn_eligible"] + assert check["binding"]["todo_id"] == todo_id + assert not any(check["effects"].values()) + + +@pytest.mark.parametrize("handoff_mode", ["soft_claim", "hard_lease"]) +def test_independent_result_reconnects_and_revalidates_without_goal_binding(service, monkeypatch, handoff_mode): + root, runner = service + if handoff_mode == "hard_lease": + for task_id, task in demo.canonical_tasks(root).items(): + demo.cli(root, "todo", "update", "--goal-id", runner.goal_id, + "--todo-id", task_id, "--agent-id", task["claimed_by"], "--clear-claim") + changed = demo.cli(root, "handoff-mode", "set", "--goal-id", runner.goal_id, + "--mode", "hard_lease") + assert changed["ok"], changed + todo_id = independent_binding(service) + monkeypatch.setattr(runner, "_spawn", lambda _: None) + runner.start("analysis", "independent-1", brief()) + runner.execute("independent-1") + result = wait(runner, "independent-1") + assert result["status"] == "accepted", result + assert demo.canonical_tasks(root)[todo_id]["done"] + assert demo.canonical_tasks(root)[todo_id]["completion_continuation"] == "active_goal" + assert not demo.canonical_tasks(root)["todo_reviewer-initial"]["done"] + assert (root / "analyst" / "initial" / "host-invocations").read_text() == "1" + assert runner.read("independent-1")["artifacts"][0]["sha256"] + runner.resume("independent-1") + assert (root / "analyst" / "initial" / "host-invocations").read_text() == "1" + (root / "analyst" / "initial" / "output.json").write_text("{}") + with pytest.raises(ValueError, match="acceptance rejected"): + runner.read("independent-1") + + +def test_missing_independent_validator_cannot_launch(service, monkeypatch): + root, runner = service + independent_binding(service, declared=False) + status, check = cli(runner, "inspect", "--binding-id", "analysis") + assert status == 0 and check["state"] == "acceptance_unavailable", check + monkeypatch.setattr(runner, "_spawn", lambda _: None) + runner.start("analysis", "unvalidated-1", brief()) + runner.execute("unvalidated-1") + assert runner.read("unvalidated-1")["status"] == "rejected" + assert not (root / "analyst" / "initial" / "host-invocations").exists() + + +def test_passing_todo_validator_cannot_waive_failing_owner_criteria(service): + root, runner = service + todo_id = independent_binding(service) + assert runner._validate(runner.binding("analysis", require_active=True))["plan"]["source"] == "todo_validation" + basis = inspect_goal_acceptance(registry_path=runner.registry, goal_id=runner.goal_id, + runtime_root=str(runner.root)) + document = basis["contract"] + document["scope"]["todo_ids"].append(todo_id) + document["bindings"].append({"todo_id": todo_id, "criterion_ids": ["analyst-initial"]}) + for criterion in document["criteria"]: + if criterion["id"] == "analyst-initial": + criterion["validation_argv"] = [sys.executable, "-c", "raise SystemExit(1)"] + configured = configure_goal_acceptance(registry_path=runner.registry, goal_id=runner.goal_id, + runtime_root=str(runner.root), document=document, + expected_provider_revision=basis["provider_revision"], execute=True) + assert configured["status"] == "applied" + with pytest.raises(ValueError, match="acceptance rejected"): + runner._validate(runner.binding("analysis", require_active=True)) + assert not demo.canonical_tasks(root)[todo_id]["done"] + assert not (root / "analyst" / "initial" / "host-invocations").exists() + + +def test_selected_unbound_work_cannot_fall_back_to_its_own_validator(service, monkeypatch): + root, runner = service + todo_id = independent_binding(service) + basis = inspect_goal_acceptance(registry_path=runner.registry, goal_id=runner.goal_id, + runtime_root=str(runner.root)) + document = basis["contract"] + document["scope"]["todo_ids"].append(todo_id) + configure_goal_acceptance(registry_path=runner.registry, goal_id=runner.goal_id, + runtime_root=str(runner.root), document=document, + expected_provider_revision=basis["provider_revision"], execute=True) + status, check = cli(runner, "inspect", "--binding-id", "analysis") + assert status == 0 and not check["acceptance_ready"], check + assert "goal_acceptance_unbound" in check["authority_reason"] + monkeypatch.setattr(runner, "_spawn", lambda _: None) + runner.start("analysis", "selected-unbound", brief()) + runner.execute("selected-unbound") + assert runner.read("selected-unbound")["status"] == "rejected" + assert not (root / "analyst" / "initial" / "host-invocations").exists() From 7d9bab58bc5a367d2524e31bdea6d9e49b52b85b Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:31:24 +0800 Subject: [PATCH 2/3] docs(delegation): clarify validation and member continuation boundaries Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../rfcs/loopx-overall-roadmap-v0.md | 8 ++++ .../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 6 +++ docs/reference/local-delegation.md | 40 ++++++++++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 53220ffe55..fa81e287bc 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -499,6 +499,14 @@ coordinators and ordinary members use the same grant contract. Disabled stdio servers retain their original five non-executing tools. Configuration files compact provider launch arguments without changing default executor selection. +Independent work outside owner-selected acceptance now uses the canonical +Todo's explicit completion validator through the same TS validation plan. +Covered work still requires its current owner association and every applicable +check. Member completion keeps the Goal active, then resumes only the original +Turn's settlement; it does not assert terminal no-follow-up. File/SQLite CLI +and local host regressions qualify this boundary, not real-model research, +requester synthesis or Lark parity. No binding grant or provider is activated. + The [synthetic research example](../../../examples/managed-research-team/README.md) uses a local lead, two DSH members and two Ark members. One cloud reviewer adopts local analysis; another Ark member delegates to DSH before returning to local diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index 19aee042ab..9a5af93e0d 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -395,6 +395,12 @@ P0 首批是负责人路由和真实 2–3-worker 协调:两轮并行汇合、 委派逻辑。主协调员与普通成员使用同一授权合同;未启用执行配置的 stdio 服务保持 原有五个非执行工具。文件形式的 provider 配置缩短启动参数,不改变默认执行器。 +owner 所选验收范围外的独立工作,现可通过同一 TS 校验计划使用规范 Todo 显式声明的 +完成校验。范围内仍须具备当前 owner 关联,且所有适用校验都须通过。成员完成保留 +Goal active,再仅恢复原 Turn 的结算,不声明 terminal no-follow-up。File/SQLite CLI +及本地 host 回归只验收该边界,不代表真实模型投研、请求方综合或 Lark 等价;不激活 +任何 binding grant 或 provider。 + [合成投研示例](../../../examples/managed-research-team/README.md)由本地主 Agent 组织两个 DSH 和两个 Ark 成员:云端核验员采用本地分析,另一 Ark 成员继续委派 DSH 后向本地主 Agent 返回。五个稳定预授权任务一次绑定精确验收;Turn 验证与普通 diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index f1863b87be..c4c51567b8 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -7,8 +7,15 @@ existing Turn entrypoint; there is no steward-specific scheduler or task store. ## Activate -First register the participating Agents and bind the intended canonical Todos -to [owner-configured acceptance](goal-acceptance-observations.md). Prepare an +First register the participating Agents and give each intended canonical Todo +an explicit validation basis. Work covered by +[owner-configured acceptance](goal-acceptance-observations.md) must retain its +current owner binding. Independent work outside that scope (or with Goal +acceptance disabled) instead requires its own canonical Todo completion +validator, declared through the existing `todo add --validation-command-json` +entrypoint. A missing or stale owner association never falls back to that +validator; a Todo validator supplements owner criteria when both apply. +Prepare an operator-owned JSON file **outside every delegated member workspace**. A coordinator may keep it as an ignored file under its Goal project at `.loopx/config/delegations.json`: @@ -36,6 +43,35 @@ select `generic-cli`, `fresh`, and the optional adapter's `--config` invocation. Profiles, executables, workspace isolation and credential custody remain the operator's responsibility. No model tool accepts those values. +Inspection, pre-launch admission, Turn validation and returned-artifact readback +consume this same basis. Private commands must match the canonical Todo's +declaration digest; verifier files declared by Goal acceptance are checked +before and after execution. The ordinary Todo digest pins the command, not +undeclared script dependencies. A successful validator still needs canonical +completion, unchanged artifacts and receiver adoption. Inspection never starts +work or configures owner acceptance. Disable by removing the exact binding +from the operator configuration; existing operations retain their history and +cannot re-execute or return accepted evidence under a revoked grant. + +Member completion uses the ordinary active-Goal continuation, including legacy +non-hard-lease routes. It does not declare terminal `no_followup` for a +requester-owned synthesis. This lets controller validation finish the Todo +before resuming only the original Turn's settlement; the host is not rerun. +Explicit terminal closeout still requires matching writeback/spend receipts. + +中文:受 Goal 验收范围覆盖的 Todo 保留当前 owner 关联;范围外的独立任务,或未启用 +Goal 验收的任务,必须通过既有 `todo add --validation-command-json` 声明规范 Todo +完成校验。范围内关联缺失或过期不能退回普通校验;两者同时存在时须全部通过。 +预检、启动前准入、Turn 校验和结果读回复用同一依据,私有命令必须匹配规范声明 +摘要。普通 Todo 摘要固定命令,不固定未声明的脚本依赖;Goal 声明的校验文件在 +执行前后核对。校验通过仍不等于规范完成、产物未变或接收方采纳。预检不启动工作、 +不配置 owner 验收;移除原配置中的精确 binding 即撤销 grant,保留历史但拒绝重新 +执行或返回已撤权任务的有效结果。 + +成员完成沿用普通 active-Goal 继续状态,旧的非 hard-lease 路径也如此;不会为仍由 +请求方负责的汇总声明 terminal `no_followup`。因此可以先通过 controller 校验完成 +Todo,再仅恢复原 Turn 的结算,不重跑 host。显式终结仍须具备匹配的写回和扣额回执。 + A Codex binding launches an independent, resumable Codex Agent Session through the same governed Turn path. Pin both fields when the worker must use an exact profile: From 4764f169a9784cd85708f53d219cad25dbbe23e7 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:38:08 +0800 Subject: [PATCH 3/3] chore(delegation): remove unused validation adapter import Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/control_plane/collaboration/delegation_validation.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/loopx/control_plane/collaboration/delegation_validation.py b/loopx/control_plane/collaboration/delegation_validation.py index 36fe637195..7e09c93ffc 100644 --- a/loopx/control_plane/collaboration/delegation_validation.py +++ b/loopx/control_plane/collaboration/delegation_validation.py @@ -5,8 +5,6 @@ No validator output or successful declaration read completes a canonical Todo. """ -from pathlib import Path - from ...agent_registry import load_goal_from_registry from ...materials import goal_state_path from ..effect_runtime import effect_runtime_result