From 297c168d39befa3e1141b2230e04e261d5aaf717 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:23:57 +0800 Subject: [PATCH] fix(goals): retain approved completion criteria before first dispatch Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../reference/goal-acceptance-observations.md | 32 ++ loopx/chat_action_normalization.py | 2 + loopx/chat_actions.py | 26 ++ loopx/control_plane/goals/creation_outcome.ts | 22 ++ .../goals/outcome_requirements.ts | 4 +- .../presentation/action_review_plan.ts | 26 ++ .../test_goal_create_completion.py | 345 ++++++++++++++++++ .../action_review_plan.test.ts | 31 ++ .../goal_outcome_capacity.test.ts | 13 + 9 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 loopx/control_plane/goals/creation_outcome.ts create mode 100644 tests/control_plane/test_goal_create_completion.py diff --git a/docs/reference/goal-acceptance-observations.md b/docs/reference/goal-acceptance-observations.md index 7d4020b70c..8bd037d348 100644 --- a/docs/reference/goal-acceptance-observations.md +++ b/docs/reference/goal-acceptance-observations.md @@ -75,6 +75,29 @@ check consumes real status collection over a disposable synthetic Goal; set ## Default supporting outcomes +Creating a Goal with a selected Agent and explicit **Completion criteria** now +retains that reviewed field as a supporting outcome before the first Session +can start. The App and the typed `goal.create` action share this path. Completing +the initial Todos does not erase the criteria: unmet requirements return to the +existing Goal frontier and replan owner. No acceptance-check setup or extra +continuation switch is needed. Criteria without a selected Agent are refused +before creation; an objective alone is not parsed into invented requirements. + +Creation recovery keeps the original declaration operation and provider revision +through a lost response. A known revision conflict holds the creation for +reconciliation instead of silently rebasing it or starting a Host. Already +dispatched historical creations retain their original semantics; this is not a +retroactive migration. Stop, permissions, available budget, independent checks +and natural native Goal completion retain their existing owners. + +中文:创建 Goal 时,已选择执行 Agent 并明确填写的“完成标准”会在首个会话启动前 +保存为持续要求。App 与 `goal.create` 共用这条路径;首批 Todo 做完后,尚未满足的 +要求仍进入现有 Goal frontier 和重规划流程,无需另开续跑开关或配置检查命令。 +未选择 Agent 时先拒绝创建;系统不会从自由文本目标中猜出新要求。响应丢失沿用原 +操作和 provider revision 恢复;已知版本冲突先保留现场,不自动重基或启动模型。 +已经启动过会话的旧创建记录保持原义,不自动迁移。停止、权限、预算、独立检查及 +原生 Goal 的自然结束规则保持有效。 + Goal Overview shows supporting outcomes recorded by a bound Agent: original objective, cumulative requirements, linked work, sources and separate Agent review/check results. The local owner can **Add a requirement** and read the @@ -141,6 +164,15 @@ zero retained review bytes. These are storage observations, not model tokens, pa 64-source limit per outcome remains independent. Omitting `source` on an ordinary review avoids adding provenance but cannot bypass either byte budget. +A requirement description permits 2000 UTF-16 code units so the existing +creation field's full 1000 Unicode code points, including supplementary-plane +characters, survive without clipping. Previously its 512-unit bound rejected +that already supported input. This field alignment does not enlarge either +131072-byte aggregate envelope, source count, outcome count or execution budget; +oversized declarations still fail atomically. 中文:单条要求的描述上限对齐创建表单已有的 +1000 个 Unicode 码点(最多 2000 个 UTF-16 单元),保留完整确认文本;两个聚合字节 +预算及其他容量、执行预算均不扩大,超限仍原子拒绝。 + For review overflow, shorten only the proposed review notes and evidence references while preserving each conclusion and its provenance; keep full evidence in its owning material. For declaration overflow, shortening reviews diff --git a/loopx/chat_action_normalization.py b/loopx/chat_action_normalization.py index 6fa38e9d2e..bb67f17b15 100644 --- a/loopx/chat_action_normalization.py +++ b/loopx/chat_action_normalization.py @@ -577,6 +577,8 @@ def _normalize( for field in ("agent_id", "workspace_ref"): if values.get(field): result[field] = _opaque(values[field], field=field) + if result.get("completion_criteria") and not result.get("agent_id"): + raise ValueError("completion_criteria require the Goal's selected Agent") if result.get("agent_id"): # A creation's Agent is refused as a typed gate, so a fresh install # learns which Agent is missing instead of reading a protocol sentence. diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index 618ff16810..0e2b1ba01d 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -807,6 +807,32 @@ def require_first_turn_readback(turn: dict[str, Any]) -> None: step="todos_created", receipt={"outcome": "todos_created", "todo_ids": todo_ids}, ) + completion_plan = effect_runtime_result("presentation.action_review_plan.compile", { + "proposal": self.store.load(proposal_id), + }) + completion = completion_plan.get("creationOutcome") + completion_progress = completion_plan.get("creationProgress") or {} + if completion and not completion_progress.get("completionRecorded"): + from .control_plane.goals.acceptance import inspect_goal_acceptance, refine_goal_outcome + + basis = completion_progress.get("completionBasis") + if basis is None: + current = inspect_goal_acceptance(registry_path=self.registry_path, + runtime_root=str(runtime_root), goal_id=goal_id, agent_id=agent_id) + basis = {"providerRevision": current["provider_revision"], "operationId": completion["operationId"]} + # Persist the original CAS before sending the canonical write. + # An uncertain response must replay this request, not new intent. + self.store.save_checkpoint(proposal_id, step="goal_completion_prepared", receipt={ + "outcome": "goal_completion_prepared", "goal_id": goal_id, + "operation_id": basis["operationId"], "provider_revision": basis["providerRevision"], + }) + retained = refine_goal_outcome(registry_path=self.registry_path, runtime_root=str(runtime_root), + goal_id=goal_id, agent_id=agent_id, refinement=completion["refinement"], + operation_id=basis["operationId"], expected_provider_revision=basis["providerRevision"], execute=True) + self.store.save_checkpoint(proposal_id, step="goal_completion_recorded", receipt={ + "outcome": "goal_completion_recorded", "goal_id": goal_id, + "operation_id": basis["operationId"], "provider_revision": retained["provider_revision"], + }) projected = self._goal(goal_id) if agent_id and agent_id not in registered_agent_ids_for_goal(projected): raise ValueError( diff --git a/loopx/control_plane/goals/creation_outcome.ts b/loopx/control_plane/goals/creation_outcome.ts new file mode 100644 index 0000000000..11068c8fd4 --- /dev/null +++ b/loopx/control_plane/goals/creation_outcome.ts @@ -0,0 +1,22 @@ +/** Adapt an owner's approved creation fields to the existing outcome owner. + * No prose interpretation, check activation, completion or scheduling policy. */ +export type GoalCreationOutcome = { + operationId: string; + refinement: { + outcome_id: string; owner_agent_id: string; objective: string; + requirements: Array<{id: string; description: string}>; + todo_ids: string[]; source: {ref: string; summary: string}; + }; +}; + +export function goalCreationOutcome(proposalId: string, parameters: Record, + todoIds: string[]): GoalCreationOutcome | undefined { + const criteria = parameters.completion_criteria, agent = parameters.agent_id; + if (typeof criteria !== "string" || !criteria.trim() || typeof agent !== "string" || !agent.trim()) return undefined; + const operationId = `goal-create:${proposalId}:completion`; + return {operationId, refinement: { + outcome_id: "goal-creation", owner_agent_id: agent, objective: String(parameters.title), + requirements: [{id: "completion", description: criteria}], todo_ids: todoIds, + source: {ref: operationId, summary: "Owner-approved Goal creation completion criteria"}, + }}; +} diff --git a/loopx/control_plane/goals/outcome_requirements.ts b/loopx/control_plane/goals/outcome_requirements.ts index 1dad34811c..a79dc241e6 100644 --- a/loopx/control_plane/goals/outcome_requirements.ts +++ b/loopx/control_plane/goals/outcome_requirements.ts @@ -52,7 +52,9 @@ function unique(values: string[], label: string): void {requireValue(new Set(val function requirements(value: unknown): Requirement[] { const result = list(value, "requirements", 32).map(value => { const row = canonicalAuthorityObject(value, "requirement"); keys(row, ["id", "description"]); - return {id: id(row.id, "requirement id"), description: text(row.description, "requirement description")}; + // Goal creation accepts 1,000 Unicode code points (up to 2,000 UTF-16 + // units). Keep that reviewed text intact; aggregate byte limits still hold. + return {id: id(row.id, "requirement id"), description: text(row.description, "requirement description", 2000)}; }); unique(result.map(row => row.id), "requirements"); return result; } diff --git a/loopx/control_plane/presentation/action_review_plan.ts b/loopx/control_plane/presentation/action_review_plan.ts index 8196e8c147..cd6408a622 100644 --- a/loopx/control_plane/presentation/action_review_plan.ts +++ b/loopx/control_plane/presentation/action_review_plan.ts @@ -1,5 +1,6 @@ import type {DecisionOutcome, ResumeState} from "../todos/user_completion_types.js"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; +import {goalCreationOutcome, type GoalCreationOutcome} from "../goals/creation_outcome.ts"; export type ActionReviewIdentity = { schemaVersion: "action_review_plan_v0"; @@ -94,6 +95,7 @@ export type ActionReviewPlan = ActionReviewIdentity & ActionReviewState & { retryOriginal?: true; /** Recorded creation effects, not evidence of a completed Goal or model Turn. */ creationProgress?: GoalCreationProgress; + creationOutcome?: GoalCreationOutcome; }; export type GoalCreationProgress = { @@ -101,6 +103,8 @@ export type GoalCreationProgress = { goalId?: string; agentId?: string; todoIds?: string[]; + completionBasis?: {operationId: string; providerRevision: string}; + completionRecorded?: true; sessionId?: string; firstTurn?: {sessionId: string; turnId: string}; }; @@ -137,6 +141,22 @@ function goalCreationProgress(proposal: Record): GoalCreationPr || !ids.every(id => textValue(id) !== null) || new Set(ids).size !== ids.length) return undefined; progress.todoIds = ids as string[]; } + const completion = objectValue(steps?.goal_completion_prepared); + const recorded = objectValue(steps?.goal_completion_recorded); + if (steps?.goal_completion_prepared != null) { + const declaration = goalCreationOutcome(String(proposal.proposal_id), parameters!, progress.todoIds ?? []); + const providerRevision = textValue(completion?.provider_revision); + if (!progress.todoIds || !declaration || completion?.outcome !== "goal_completion_prepared" + || completion.goal_id !== goalId || completion.operation_id !== declaration.operationId || !providerRevision) return undefined; + progress.completionBasis = {operationId: declaration.operationId, providerRevision}; + } + if (steps?.goal_completion_recorded != null) { + if (!progress.completionBasis || recorded?.outcome !== "goal_completion_recorded" + || recorded.goal_id !== goalId || recorded.operation_id !== progress.completionBasis.operationId + || !textValue(recorded.provider_revision)) return undefined; + progress.completionRecorded = true; + } + if ((session || turn) && progress.completionBasis && !progress.completionRecorded) return undefined; if (steps?.first_turn_started != null) { const sessionId = textValue(turn?.session_id), turnId = textValue(turn?.turn_id); if (!progress.agentId || !progress.todoIds || turn?.outcome !== "first_turn_started" || !sessionId || !turnId @@ -502,6 +522,11 @@ export function compileActionReviewPlan(proposalValue: unknown, nowMs?: number): const reviewCardFrame = compileReviewCardFrame(proposal); const decisionFrame = compileDecisionReviewFrame(proposal); const creationProgress = goalCreationProgress(proposal); + // Older already-dispatched creations retain their original semantics. A + // failed write retries its original CAS even after a response was lost. + const creationOutcome = creationProgress?.todoIds && (!creationProgress.sessionId && !creationProgress.firstTurn + || creationProgress.completionBasis) + ? goalCreationOutcome(identity.proposalId, objectValue(proposal.normalized_parameters) ?? {}, creationProgress.todoIds) : undefined; const finish = (state: ActionReviewState): ActionReviewPlan => ({ ...identity, ...state, @@ -509,6 +534,7 @@ export function compileActionReviewPlan(proposalValue: unknown, nowMs?: number): ...(reviewCardFrame ? { reviewCardFrame } : {}), ...(decisionFrame ? { decisionFrame } : {}), ...(creationProgress ? { creationProgress } : {}), + ...(creationOutcome ? { creationOutcome } : {}), }); const held = ( interaction: "gated" | "refresh" | "repair" | "pending" | "completed" | "inactive", diff --git a/tests/control_plane/test_goal_create_completion.py b/tests/control_plane/test_goal_create_completion.py new file mode 100644 index 0000000000..c03bb20600 --- /dev/null +++ b/tests/control_plane/test_goal_create_completion.py @@ -0,0 +1,345 @@ +"""Approved Goal criteria outlive the first local task without check setup.""" +import json +import shlex +import subprocess +import sys +from pathlib import Path + +import pytest + +from test_new_goal_storage_defaults import environment as environment, app as app +from cli_output_fixture import read_cli_context_for_oracle +from loopx.control_plane.goals.acceptance import inspect_goal_acceptance + + +CRITERIA = "Keep the introduction and include the supporting appendix." + + +def preview_goal(request, *, expected_status=201, **changes): + parameters = {"goal_id": "report", "title": "Report", "objective": "Deliver the report.", + "completion_criteria": CRITERIA, "agent_id": "codex", "workspace_ref": "current", + "heartbeat": {"enabled": False}, "initial_todos": ["Write the introduction"], **changes} + status, preview = request("/api/actions/preview", { + "action_kind": "goal.create", "summary": "Create the report Goal", + "idempotency_key": "report-with-completion", + "context": {"kind": "goal_channel", "goal_id": "workspace"}, "normalized_parameters": parameters}) + assert status == expected_status, preview + return preview["proposal"] if status == 201 else preview + + +def inspect(project, runtime): + return inspect_goal_acceptance(registry_path=project / ".loopx/registry.json", + runtime_root=str(runtime), goal_id="report", agent_id="codex") + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_approved_goal_criteria_are_retained_before_execution(environment, app, provider): + configure, _, _, project, runtime = environment + _, request, _ = app + configure(provider, mode="hard_lease") + criteria = CRITERIA + proposal = preview_goal(request) + status, applied = request(f"/api/actions/{proposal['proposal_id']}/apply", {}) + assert status == 200, applied + result = inspect_goal_acceptance(registry_path=project / ".loopx/registry.json", + runtime_root=str(runtime), goal_id="report", agent_id="codex") + contract = result["goal_acceptance_contract"] + outcomes = contract.get("outcome_requirements", {}).get("outcomes", []) + assert len(outcomes) == 1, "approved completion criteria must reach the normal Goal requirement owner" + outcome = outcomes[0] + assert outcome["requirements"][0]["description"] == criteria + assert not outcome["requirements"][0]["planning_satisfied"] + assert outcome["requirements"][0]["status"] == "unverified" + assert outcome["todo_ids"] == applied["proposal"]["receipt"]["resource_ids"]["todo_ids"] + assert not any(key in contract for key in ("enabled", "scope", "criteria")), "no feature activation or check contract" + before = json.dumps(contract, sort_keys=True) + replay_status, replay = request(f"/api/actions/{proposal['proposal_id']}/apply", {}) + assert replay_status == 200, replay + assert json.dumps(inspect_goal_acceptance(registry_path=project / ".loopx/registry.json", + runtime_root=str(runtime), goal_id="report", agent_id="codex")["goal_acceptance_contract"], sort_keys=True) == before + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_creation_replays_original_completion_after_lost_write_response(environment, app, monkeypatch, provider): + from loopx.control_plane.goals import acceptance + from loopx.control_plane.todos.mutation_api import add_goal_todo + + configure, _, _, project, runtime = environment + store, request, _ = app + configure(provider) + proposal = preview_goal(request) + original = acceptance.refine_goal_outcome + calls = [] + + def lose_response(**kwargs): + calls.append(kwargs) + result = original(**kwargs) + if len(calls) == 1: + raise OSError("completion response lost after canonical commit") + return result + + monkeypatch.setattr(acceptance, "refine_goal_outcome", lose_response) + status, failed = request(f"/api/actions/{proposal['proposal_id']}/apply", {}) + assert status == 424 and failed["error_code"] == "canonical_action_failed", failed + committed = inspect(project, runtime)["goal_acceptance_contract"] + assert committed["outcome_requirements"]["revision"] == 1 + assert not store.load(proposal["proposal_id"])["checkpoint"]["steps"].get("first_session_opened") + add_goal_todo(registry_path=project / ".loopx/registry.json", goal_id="report", role="agent", + text="Review the report", agent_id="codex", claimed_by="codex", dry_run=False) + assert inspect(project, runtime)["provider_revision"] != calls[0]["expected_provider_revision"] + status, recovered = request(f"/api/actions/{proposal['proposal_id']}/apply", {}) + assert status == 200, recovered + assert len(calls) == 2 and calls[0] == calls[1], "unknown writes must reuse the original operation, CAS and body" + after = inspect(project, runtime)["goal_acceptance_contract"]["outcome_requirements"] + assert after["revision"] == 1 and len(after["outcomes"][0]["sources"]) == 1 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("criteria", ["x" * 1000, "\U0001f4dd" * 1000]) +def test_full_approved_creation_text_is_not_truncated(environment, app, provider, criteria): + configure, _, _, project, runtime = environment + _, request, _ = app + configure(provider) + proposal = preview_goal(request, completion_criteria=criteria) + status, applied = request(f"/api/actions/{proposal['proposal_id']}/apply", {}) + assert status == 200, applied + requirement = inspect(project, runtime)["goal_acceptance_contract"]["outcome_requirements"]["outcomes"][0]["requirements"][0] + assert requirement["description"] == criteria + + +def test_no_criteria_does_not_infer_requirements_from_objective(environment, app): + _, _, _, project, runtime = environment + _, request, _ = app + proposal = preview_goal(request, completion_criteria="", objective=CRITERIA) + status, result = request(f"/api/actions/{proposal['proposal_id']}/apply", {}) + assert status == 200, result + assert "outcome_requirements" not in inspect(project, runtime)["goal_acceptance_contract"] + + +def test_completion_criteria_require_an_explicit_executor_before_any_creation(environment, app): + _, _, _, project, _ = environment + _, request, _ = app + rejected = preview_goal(request, expected_status=400, agent_id="") + assert "selected Agent" in rejected["error"] + assert not any(row["id"] == "report" for row in json.loads((project / ".loopx/registry.json").read_text())["goals"]) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_creation_does_not_rebase_a_conflicted_completion_write(environment, app, monkeypatch, provider): + from loopx.control_plane.goals import acceptance + from loopx.control_plane.todos.mutation_api import add_goal_todo + + configure, _, _, project, runtime = environment + store, request, _ = app + configure(provider) + proposal = preview_goal(request) + original = acceptance.refine_goal_outcome + requests = [] + + def conflict_before_commit(**kwargs): + requests.append(kwargs) + if len(requests) == 1: + add_goal_todo(registry_path=project / ".loopx/registry.json", goal_id="report", role="agent", + text="Concurrent owner work", agent_id="codex", claimed_by="codex", dry_run=False) + return original(**kwargs) + + monkeypatch.setattr(acceptance, "refine_goal_outcome", conflict_before_commit) + for _ in range(2): + status, rejected = request(f"/api/actions/{proposal['proposal_id']}/apply", {}) + assert status == 400 and "revision" in rejected["error"], rejected + assert "outcome_requirements" not in inspect(project, runtime)["goal_acceptance_contract"] + assert "first_session_opened" not in store.load(proposal["proposal_id"])["checkpoint"]["steps"] + assert requests[0] == requests[1], "a known conflict needs reconciliation; retry cannot silently rebase" + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("fault", ["declaration_response", "first_turn_response"]) +def test_managed_creation_retains_requirements_before_one_host_dispatch(environment, app, monkeypatch, provider, fault): + import runpy + from loopx.chat_runtime import ChatRuntimeController + from loopx.chat_store import ChatSessionStore + from loopx.capabilities.native_chat.project_context import ChatProjectContexts + from loopx.control_plane.goals import acceptance + + configure, _, _, project, runtime = environment + _, request, service = app + configure(provider) + capture, binary = runtime / "native-requests.jsonl", runtime / "synthetic-codex" + source = runpy.run_path(str(Path(__file__).parents[2] / "examples/loopx-chat-runtime-smoke.py"))["FAKE_CODEX"] + source = source.replace(' method = request.get("method")', + f' with open({str(capture)!r}, "a") as stream:\n stream.write(json.dumps(request) + "\\n")\n' + ' method = request.get("method")') + binary.write_text(source) + binary.chmod(0o700) + chat = ChatSessionStore(runtime / "chat") + controller = ChatRuntimeController(store=chat, codex_bin=str(binary), registry_path=service.registry_path, + project_contexts=ChatProjectContexts([project])) + service.chat_store, service.runtime_controller = chat, controller + proposal = preview_goal(request) + target = acceptance if fault == "declaration_response" else controller + method = "refine_goal_outcome" if fault == "declaration_response" else "submit_turn" + original = getattr(target, method) + + def lose_response(*args, **kwargs): + result = original(*args, **kwargs) + raise TimeoutError("Committed creation response was lost") + + monkeypatch.setattr(target, method, lose_response) + try: + status, failed = request(f"/api/actions/{proposal['proposal_id']}/apply", {}) + assert status == 424, failed + assert inspect(project, runtime)["goal_acceptance_contract"]["outcome_requirements"]["revision"] == 1 + if fault == "declaration_response": + assert chat.list_sessions(goal_id="report") == [], "an uncertain declaration must block Host startup" + monkeypatch.setattr(target, method, original) + submit = controller.submit_turn + + def assert_retained_then_submit(*args, **kwargs): + outcome = inspect(project, runtime)["goal_acceptance_contract"]["outcome_requirements"]["outcomes"][0] + assert outcome["requirements"][0]["description"] == CRITERIA + return submit(*args, **kwargs) + + monkeypatch.setattr(controller, "submit_turn", assert_retained_then_submit) + status, recovered = request(f"/api/actions/{proposal['proposal_id']}/apply", {}) + assert status == 202, recovered + ids = recovered["proposal"]["receipt"]["resource_ids"] + controller.wait_for_turn(session_id=ids["session_id"], turn_id=ids["turn_id"], timeout_sec=10) + assert len(chat.list_sessions(goal_id="report")) == 1 + starts = [json.loads(line) for line in capture.read_text().splitlines() if json.loads(line).get("method") == "turn/start"] + assert len(starts) == 1 + assert inspect(project, runtime)["goal_acceptance_contract"]["outcome_requirements"]["revision"] == 1 + finally: + controller.close() + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("completion_surface", ["native-cli", "external-worker"]) +def test_ordinary_goal_replans_after_first_todo_and_delivers_the_missing_section(environment, app, monkeypatch, provider, completion_surface): + """Real creation, native CLI and authority; only the work decision is scripted.""" + configure, _, _, project, runtime = environment + _, request, _ = app + configure(provider, mode="soft_claim") + proposal = preview_goal(request) + status, created = request(f"/api/actions/{proposal['proposal_id']}/apply", {}) + assert status == 200, created + original = created["proposal"]["receipt"]["resource_ids"]["todo_ids"][0] + registry = project / ".loopx/registry.json" + + def cli(*args): + run = subprocess.run([sys.executable, "-m", "loopx.entrypoint", "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", *args], cwd=project, + text=True, capture_output=True, timeout=60) + assert run.returncode == 0, run.stdout + run.stderr + return json.loads(read_cli_context_for_oracle(run.stdout)[0]) + + artifact = project / "report.txt" + artifact.write_text("Introduction\n") + cli("todo", "complete", "--goal-id", "report", "--agent-id", "codex", "--todo-id", original, + "--no-follow-up", "--evidence", "artifact:report-introduction") + decision = cli("quota", "should-run", "--codex-app", "--turn-instance-id", "report-replan", + "--goal-id", "report", "--agent-id", "codex") + frontier = decision.get("goal_frontier_projection") + assert frontier, decision + assert frontier["replan_required"], decision + gap = next(g for g in frontier["acceptance_gaps"] if g["kind"] == "goal_outcome_uncovered") + assert gap["requirement_ids"] == ["completion"] + assert decision.get("autonomous_replan_obligation"), decision + assert inspect(project, runtime)["goal_acceptance_contract"]["outcome_requirements"]["outcomes"][0]["requirements"][0]["description"] == CRITERIA + + # The delivered requirement justifies this concrete successor, not a timer + # or an additional benchmark-only controller. Preserve the actual artifact. + successor = cli("todo", "add", "--goal-id", "report", "--role", "agent", "--text", "Add the missing supporting appendix", + "--claimed-by", "codex", "--task-class", "advancement_task", "--action-kind", "implement", + "--operation-id", "appendix-from-retained-criteria", "--target-key", "report-appendix", + "--replan-obligation-id", decision["autonomous_replan_obligation"]["obligation_id"])["todo_id"] + from loopx.control_plane.goals.acceptance import refine_goal_outcome + refine_goal_outcome(registry_path=registry, runtime_root=str(runtime), goal_id="report", agent_id="codex", + expected_provider_revision=inspect(project, runtime)["provider_revision"], operation_id="link-appendix", + refinement={"outcome_id": "goal-creation", "todo_ids": [successor]}, execute=True) + vision = project / "replan.json" + def read_action(command): + result = subprocess.run(shlex.split(command), cwd=project, text=True, capture_output=True, timeout=60) + assert result.returncode == 0, result.stdout + result.stderr + return json.loads(read_cli_context_for_oracle(result.stdout)[0]) + + review = read_action(decision["replan_action_packet"]["review"]) + writeback = read_action(review["next"]["writeback"]) + stride = {**writeback["stride_control"]["minimal_example"], + "basis": "unexplained_gap", "action": "verify", "axes": { + "work_slice": {"plan": "Add the missing appendix", "reason": "The initial introduction alone is incomplete"}, + "route_distance": {"plan": "Retain the introduction and append the required section", "reason": "The original useful text remains valid"}, + "validation_scope": {"plan": "Check both sections", "reason": "Completing only the latest task could lose the earlier requirement"}}, + "next_probe": {"scope": "One local report artifact", "prediction": "Both required sections will be present", + "minimum_evidence": "Read the introduction and appendix from the actual report", + "adoption_criterion": "The report contains both sections", "cost_bound": "One local edit and one content check"}} + vision.write_text(json.dumps({"state": "vision_patch_proposed", "stride_decision": stride, + "todo_delta": ["create:" + successor], "vision_patch": { + "acceptance_summary": CRITERIA}, "path_delta": {"outcome": "replan", + "prior_assumption": "Completing the introduction would finish the local task", + "observed_reality": "The overall report still needs the explicitly required appendix", + "retained": ["The introduction and the owner's completion criteria"], "evidence_refs": ["report.txt"]}})) + artifact.write_text(artifact.read_text() + "Appendix\n") + subprocess.run([sys.executable, "-c", "from pathlib import Path; assert Path('report.txt').read_text() == 'Introduction\\nAppendix\\n'"], + cwd=project, check=True) + obligation = decision["autonomous_replan_obligation"]["obligation_id"] + recorded = cli("refresh-state", "--goal-id", "report", "--agent-id", "codex", "--turn-instance-id", "report-replan", + "--replan-obligation-id", obligation, "--agent-vision-json", str(vision), + "--classification", "bounded_replan_progress", "--delivery-batch-scale", "single_surface", + "--delivery-outcome", "outcome_progress", "--progress-result-class", "advanced", + "--progress-surface-id", "report-artifact", "--progress-hypothesis-id", "appendix-completes-report", + "--progress-probe-kind", "complete-content-check", "--progress-evidence-id", "artifact:complete-report", + "--no-global-sync", "--suppress-external-sinks") + assert recorded["autonomous_replan_ack"]["recorded"], recorded + cli("quota", "spend-slot", "--goal-id", "report", "--agent-id", "codex", "--turn-instance-id", "report-replan", + "--replan-obligation-id", obligation, "--slots", "1", "--source", "heartbeat", "--execute") + completion_args = ["todo", "complete", "--goal-id", "report", "--agent-id", "codex", "--todo-id", successor, + "--no-follow-up", "--evidence", "artifact:complete-report"] + if completion_surface == "native-cli": + cli(*completion_args) + else: + from tests.test_external_scheduler_worker import _args, worker + + # An actual external scheduler admits one subprocess Host. Its model + # boundary is a local deterministic validator, not a paid model call. + host = project / "report_host.py" + marker = project / "external-adoption.json" + host.write_text("import json,subprocess,sys\nfrom pathlib import Path\n" + "from loopx.control_plane.goals.acceptance import inspect_goal_acceptance\n" + f"current=inspect_goal_acceptance(registry_path=Path({str(registry)!r}), runtime_root={str(runtime)!r}, goal_id='report', agent_id='codex')\n" + "outcome=current['goal_acceptance_contract']['outcome_requirements']['outcomes'][0]\n" + f"assert {successor!r} in outcome['todo_ids']\n" + f"assert outcome['requirements'][0]['description'] == {CRITERIA!r}\n" + "assert Path('report.txt').read_text() == 'Introduction\\nAppendix\\n'\n" + f"command = { [sys.executable, '-m', 'loopx.entrypoint', '--registry', str(registry), '--runtime-root', str(runtime), '--format', 'json', *completion_args]!r}\n" + "r=subprocess.run(command,text=True,capture_output=True); assert r.returncode == 0,r.stdout+r.stderr\n" + f"Path({str(marker)!r}).write_text(json.dumps({{'adopted_criteria': outcome['requirements'][0]['description'], 'completed_todo': {successor!r}}}))\n") + monkeypatch.setenv("PYTHONPATH", str(Path(__file__).resolve().parents[2])) + args = _args(fake_cli=Path(sys.executable).parent / "loopx", + state_file=project / "external-scheduler.json", wake_cmd=shlex.join([sys.executable, str(host)]), + quota_timeout_seconds=60, wake_timeout_seconds=60) + args.registry, args.runtime_root = str(registry), str(runtime) + args.goal_id, args.agent_id = "report", "codex" + monkeypatch.chdir(project) + assert worker.run_worker(args) == 0 + assert json.loads(marker.read_text())["completed_todo"] == successor + refine_goal_outcome(registry_path=registry, runtime_root=str(runtime), goal_id="report", agent_id="codex", + expected_provider_revision=inspect(project, runtime)["provider_revision"], operation_id="review-complete-report", + refinement={"outcome_id": "goal-creation", "review": [{"id": "completion", "conclusion": "met", + "evidence_refs": ["report.txt"], "note": "Read the artifact; the introduction and supporting appendix are present"}]}, execute=True) + vision.write_text(json.dumps({"state": "no_followup", "vision_patch": { + "acceptance_summary": CRITERIA, "last_patch_summary": "The actual report contains both required sections"}, + "path_delta": {"outcome": "stop", "prior_assumption": "The report still needed its appendix", + "observed_reality": "Both sections are present and the complete content check passed", + "evidence_refs": ["report.txt"], "stopped": ["No remaining work is required for this report"]}})) + cli("refresh-state", "--goal-id", "report", "--agent-id", "codex", "--agent-vision-json", str(vision), + "--no-global-sync", "--suppress-external-sinks") + final = cli("quota", "should-run", "--goal-id", "report", "--agent-id", "codex") + assert not final["goal_frontier_projection"]["acceptance_gaps"], final + assert final["should_run"] is False and not final.get("autonomous_replan_obligation"), final + requirement = inspect(project, runtime)["goal_acceptance_contract"]["outcome_requirements"]["outcomes"][0]["requirements"][0] + assert requirement["planning_satisfied"] and requirement["status"] == "unverified", "a work review is not independent acceptance" + assert artifact.read_text() == "Introduction\nAppendix\n" + if completion_surface == "external-worker": + before = marker.read_bytes() + assert worker.run_worker(args) == 0 + assert marker.read_bytes() == before, "the completed ordinary Goal must not dispatch the Host again" diff --git a/tests/control_plane_ts/action_review_plan.test.ts b/tests/control_plane_ts/action_review_plan.test.ts index 4fff09a7bb..b7137aa574 100644 --- a/tests/control_plane_ts/action_review_plan.test.ts +++ b/tests/control_plane_ts/action_review_plan.test.ts @@ -84,6 +84,37 @@ test("creation rejects terminal first-Turn readback without a Host start", () => "readback_verified"); }); +test("creation completion keeps the original write basis and leaves dispatched legacy Goals alone", () => { + const steps = {workspace_validated: {outcome: "workspace_validated", workspace_digest: "a".repeat(64)}, + goal_bootstrapped: {outcome: "goal_bootstrapped", goal_id: "report"}, + agent_bound: {outcome: "agent_bound", goal_id: "report", agent_id: "codex"}, + todos_created: {outcome: "todos_created", todo_ids: ["intro"]}}; + const proposal = {proposal_id: "creation", expected_state_fingerprint: "registry", + action_kind: "goal.create", permission_classification: "durable_write", status: "failed", + normalized_parameters: {goal_id: "report", title: "Report", agent_id: "codex", + completion_criteria: "Retain the appendix", initial_todos: ["Write the introduction"]}, checkpoint: {steps}}; + const declaration = compileActionReviewPlan(proposal).creationOutcome!; + assert.equal(declaration.refinement.requirements[0].description, "Retain the appendix"); + assert.deepEqual(declaration.refinement.todo_ids, ["intro"]); + const prepared = {...steps, goal_completion_prepared: {outcome: "goal_completion_prepared", goal_id: "report", + operation_id: declaration.operationId, provider_revision: "original-revision"}}; + assert.deepEqual(compileActionReviewPlan({...proposal, checkpoint: {steps: prepared}}).creationProgress?.completionBasis, + {operationId: declaration.operationId, providerRevision: "original-revision"}); + const session = {outcome: "first_session_opened", session_id: "original-session"}; + const legacy = compileActionReviewPlan({...proposal, checkpoint: {steps: {...steps, first_session_opened: session}}}); + assert.equal(legacy.creationOutcome, undefined); + assert.equal(legacy.creationProgress?.sessionId, "original-session"); + for (const invalid of [ + {...prepared, goal_completion_prepared: {...prepared.goal_completion_prepared, goal_id: "foreign"}}, + {...prepared, goal_completion_prepared: {...prepared.goal_completion_prepared, operation_id: "other"}}, + {...steps, goal_completion_recorded: {outcome: "goal_completion_recorded", goal_id: "report", + operation_id: declaration.operationId, provider_revision: "new"}}, + {...prepared, first_session_opened: session}, + ]) { + assert.equal(compileActionReviewPlan({...proposal, checkpoint: {steps: invalid}}).creationProgress, undefined); + } +}); + test("canonical update recovery includes User completion without changing generic action authority", () => { const proposal = {proposal_id: "reviewed", expected_state_fingerprint: "review-basis", action_kind: "todo.update", status: "failed", normalized_parameters: {operation: "edit"}, diff --git a/tests/control_plane_ts/goal_outcome_capacity.test.ts b/tests/control_plane_ts/goal_outcome_capacity.test.ts index 3e86e77d2f..3a2cfa89ca 100644 --- a/tests/control_plane_ts/goal_outcome_capacity.test.ts +++ b/tests/control_plane_ts/goal_outcome_capacity.test.ts @@ -36,6 +36,19 @@ function crowded() { const review = {outcome_id: "outcome-19", review: Array.from({length: 5}, (_, i) => ({id: `r${i}`, conclusion: "uncertain", evidence_refs: ["artifact:current-check"], note: "Independent acceptance is still pending."}))}; +test("a complete creation criterion fits without removing the declaration byte ceiling", () => { + const head = authorityProjectionFixture(goal, [], [], "native"); + const declaration = {outcome_id: "approved", owner_agent_id: "author", objective: "Deliver the approved report", + requirements: [{id: "completion", description: "\u{1f4dd}".repeat(1000)}], source: {ref: "owner:creation", summary: "Approved criteria"}}; + const accepted = refineGoalOutcome(head, goal, null, "author", declaration); + assert.equal(accepted.outcomes[0].requirements[0].description, declaration.requirements[0].description); + assert.throws(() => refineGoalOutcome(head, goal, null, "author", {...declaration, + requirements: [{id: "completion", description: "x".repeat(2001)}]}), /requirement description must be bounded/); + assert.throws(() => refineGoalOutcome(head, goal, null, "author", {...declaration, + requirements: Array.from({length: 32}, (_, i) => ({id: `r${i}`, description: "\u754c".repeat(2000)}))}), /declaration capacity exceeded/); + assert.equal(head.goal_outcomes, undefined, "rejected larger declarations never alter retained state"); +}); + test("a full retained declaration can receive a complete review without losing origins", () => { const head = crowded(), original = structuredClone(head); const state = refineGoalOutcome(head, goal, null, "author", review);