diff --git a/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts b/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts index b587db6a0b..4fe5762107 100644 --- a/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts +++ b/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { goalStorageEditorValue, parseEditableCapabilityJson, projectEditableCapabilityConfiguration } from "../src/data/capability-configuration.js"; +import { goalStorageEditorValue, parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults } from "../src/data/capability-configuration.js"; const periodicReportEditor = { fields: [ @@ -13,6 +13,22 @@ const periodicReportEditor = { }; const storageEditor = { fields: [{ key: "new_goal_provider" }, { key: "canonical_creation" }, { key: "new_goal_handoff_mode" }] }; +const cadenceEditor = { fields: [{ key: "count_unit" }, { key: "count" }, { key: "stride_strategy" }] }; +const cadenceDefault = { count_unit: "effective_turns", count: 6, stride_strategy: "off" }; +assert.deepEqual(projectEditableCapabilityConfiguration(cadenceEditor, + { stride_strategy: "evidence_conditioned_v1" }, + replanCadenceEditorDefaults({ count_unit: "effective_turns", count: 3, stride_strategy: "off" }, cadenceDefault)), + { count_unit: "effective_turns", count: 3, stride_strategy: "evidence_conditioned_v1" }, + "a sparse JSON draft returning to the form preserves the effective review interval"); +assert.deepEqual(projectEditableCapabilityConfiguration(cadenceEditor, + { count_unit: "completed_todos", count: 2 }, + replanCadenceEditorDefaults({ count_unit: "effective_turns", count: 3, stride_strategy: "evidence_conditioned_v1" }, cadenceDefault)), + { count_unit: "completed_todos", count: 2, stride_strategy: "off" }, + "an explicit cadence wins and omitted strategy retains default-off semantics"); +assert.deepEqual(replanCadenceEditorDefaults( + { schema_version: "todo_replan_cadence_machine_defaults_v0", completed_todos: 4 }, cadenceDefault), + { count_unit: "completed_todos", count: 4, stride_strategy: "off" }, + "legacy effective intervals retain their own unit and count"); const storageDefault = { schema_version: "loopx_goal_storage_defaults_v1", new_goal_provider: "sqlite", canonical_creation: true, new_goal_handoff_mode: "hard_lease" }; assert.deepEqual(projectEditableCapabilityConfiguration(storageEditor, diff --git a/apps/presentation/dashboard/src/data/capability-configuration.ts b/apps/presentation/dashboard/src/data/capability-configuration.ts index c87ceb214e..d0aa2a9853 100644 --- a/apps/presentation/dashboard/src/data/capability-configuration.ts +++ b/apps/presentation/dashboard/src/data/capability-configuration.ts @@ -61,6 +61,16 @@ export function replanCadenceEditorValue(value: unknown): unknown { return { ...record, stride_strategy: record.stride_strategy ?? "off" }; } +/** Fill omitted review interval fields from the currently effective value. */ +export function replanCadenceEditorDefaults(effective: unknown, fallback: unknown): Record { + const defaults = { ...configurationObject(fallback) }; + const live = configurationObject(replanCadenceEditorValue(effective)); + if (Object.hasOwn(live, "count_unit")) defaults.count_unit = live.count_unit; + if (Object.hasOwn(live, "count")) defaults.count = live.count; + // Strategy omission keeps the existing default-off meaning. + return defaults; +} + /** A v0 storage selector did not enable creation; opening Settings must not. */ export function goalStorageEditorValue(value: unknown): unknown { const record = configurationObject(value); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx index c2b6a86d79..6cc206f989 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx @@ -10,7 +10,7 @@ import { type GoalConfigurationPreview, type GoalConfigurationPartialWrite, } from "../../data/chat"; -import { parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorValue } from "../../data/capability-configuration"; +import { parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults, replanCadenceEditorValue } from "../../data/capability-configuration"; import { useWorkspaceI18n } from "./i18n"; import { CapabilityConfigurationFields } from "./capability-configuration-fields"; import { withReportScheduleTimezone } from "./periodic-report-schedule-field"; @@ -37,7 +37,10 @@ type CapabilityMutationState = Readonly<{ }>; function goalWriteDraft(selected: CapabilityConfigurationCatalog["capabilities"][number], draft: Record) { - const writable = projectEditableCapabilityConfiguration(selected.configuration_editor, draft, selected.default); + const defaults = selected.capability_id === "todo_replan_cadence" + ? replanCadenceEditorDefaults(selected.effective_configuration?.configuration, selected.default) + : selected.default; + const writable = projectEditableCapabilityConfiguration(selected.configuration_editor, draft, defaults); if (selected.capability_id === "pull_request_review" && !Object.hasOwn(selected.current ?? {}, "owner_logins") && JSON.stringify(writable.owner_logins ?? []) === JSON.stringify(selected.effective_configuration?.configuration?.owner_logins ?? [])) { delete writable.owner_logins; @@ -148,7 +151,10 @@ function useCapabilityMutation({ goalId, onApplied, selected, t }: Readonly<{ setJsonDraft(text); const parsed = parseEditableCapabilityJson(selected.configuration_editor, text); setMutation((current) => ({ ...current, preview: null, draft: parsed - ? projectEditableCapabilityConfiguration(selected.configuration_editor, parsed, selected.default) + ? projectEditableCapabilityConfiguration(selected.configuration_editor, parsed, + selected.capability_id === "todo_replan_cadence" + ? replanCadenceEditorDefaults(selected.effective_configuration?.configuration, selected.default) + : selected.default) : current.draft })); setError(null); } @@ -157,7 +163,12 @@ function useCapabilityMutation({ goalId, onApplied, selected, t }: Readonly<{ if (mutation.busy || !jsonValid) return; if (editorMode === "guided") setJsonDraft(JSON.stringify(mutation.draft, null, 2)); setEditorMode(editorMode === "guided" ? "json" : "guided"); - setMutation((current) => ({ ...current, preview: null })); + setMutation((current) => ({ ...current, preview: null, + draft: editorMode === "json" && selected?.capability_id === "todo_replan_cadence" + ? projectEditableCapabilityConfiguration(selected.configuration_editor, current.draft, + replanCadenceEditorDefaults(selected.effective_configuration?.configuration, selected.default)) + : current.draft, + })); } return { apply, changeDraft, changeJson, changeMode, editorMode, jsonDraft, jsonValid, error, mutation, preview }; diff --git a/docs/assets/stride-effective-config-after.png b/docs/assets/stride-effective-config-after.png new file mode 100644 index 0000000000..df4c812fae Binary files /dev/null and b/docs/assets/stride-effective-config-after.png differ diff --git a/docs/assets/stride-effective-config-before.png b/docs/assets/stride-effective-config-before.png new file mode 100644 index 0000000000..267c152d11 Binary files /dev/null and b/docs/assets/stride-effective-config-before.png differ diff --git a/examples/personal-workspace-browser/replan-cadence.mjs b/examples/personal-workspace-browser/replan-cadence.mjs index 717fc60320..76281d3c1a 100644 --- a/examples/personal-workspace-browser/replan-cadence.mjs +++ b/examples/personal-workspace-browser/replan-cadence.mjs @@ -88,6 +88,20 @@ export const replanCadenceScenario = { return packet.capability_catalog.capabilities.find((row) => row.capability_id === "todo_replan_cadence"); } if (await strategy.inputValue() !== "evidence_conditioned_v1") throw new Error("Goal did not inherit strategy version"); + await page.getByRole("button", { name: "编辑 JSON", exact: true }).click(); + await page.locator("#goal-configuration-json").fill(JSON.stringify({ stride_strategy: "evidence_conditioned_v1" })); + await page.getByRole("button", { name: "返回表单", exact: true }).click(); + if (await unit.inputValue() !== "effective_turns" || await count.inputValue() !== "3") { + throw new Error("JSON-to-form conversion lost the effective review interval"); + } + await page.getByRole("button", { name: "预览变更", exact: true }).click(); + await applyGoalPreview(); + const converted = await readGoalCadence(); + if (converted.effective_configuration.configuration.count !== 3 + || converted.effective_configuration.configuration.count_unit !== "effective_turns") { + throw new Error("JSON-to-form save substituted the product-default interval"); + } + await page.screenshot({ path: resolve(outputDir, "replan-cadence-json-form.png"), animations: "disabled" }); await count.fill("2"); await strategy.selectOption("off"); await page.getByRole("button", { name: "预览变更", exact: true }).click(); diff --git a/loopx/cli_commands/goal_amendment_proposal.py b/loopx/cli_commands/goal_amendment_proposal.py index 7f98b29ae3..efd8e8d485 100644 --- a/loopx/cli_commands/goal_amendment_proposal.py +++ b/loopx/cli_commands/goal_amendment_proposal.py @@ -207,10 +207,12 @@ def handle_goal_amendment_proposal_command( if "operation_id" in parameters: raise ValueError("supply operation_id once") parameters["operation_id"] = args.operation_id + from ..capabilities.todo_replan_cadence.machine_defaults import resolve_todo_replan_cadence_goal payload = execute_goal_amendment(registry_path=registry_path, goal_id=goal_id, goal_instance_id=args.goal_instance_id, operation=args.governed, actor_agent_id=args.agent_id, parameters=parameters, - runtime_root=runtime_root, execute=args.execute) + runtime_root=runtime_root, execute=args.execute, + replan_configuration_reader=resolve_todo_replan_cadence_goal) accepted = payload.get("ok") is True print_payload(payload, output_format(args), render_goal_amendment_proposal_markdown) return 0 if accepted else 1 diff --git a/loopx/control_plane/goals/goal_amendment_runtime.py b/loopx/control_plane/goals/goal_amendment_runtime.py index 9c70a86307..9fd39aa694 100644 --- a/loopx/control_plane/goals/goal_amendment_runtime.py +++ b/loopx/control_plane/goals/goal_amendment_runtime.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +from collections.abc import Callable from pathlib import Path from typing import Any, Mapping @@ -55,6 +56,7 @@ def _replan_readback(derived: Mapping[str, Any], inventory: list[dict[str, Any]] def execute_goal_amendment( *, registry_path: Path, goal_id: str, goal_instance_id: str, operation: str, actor_agent_id: str | None, + replan_configuration_reader: Callable[[Mapping[str, Any], Path], Mapping[str, Any]], parameters: Mapping[str, Any] | None = None, runtime_root: Path | None = None, execute: bool = False, ) -> dict[str, Any]: @@ -89,6 +91,7 @@ def execute_goal_amendment( gate_path = source_turn_effect_gate_path(registry_path, goal_id) gate = json.loads(gate_path.read_text()) if gate_path.exists() else None agents = registered_agent_ids_for_goal(goal) + effective_replan_goal: Mapping[str, Any] | None = None derived = _derive_open_replan_obligation_inventory(runtime_root=root, goal_id=goal_id, goal=goal, state_text="", goal_ref=goal_ref) obligations = _open_replan_obligation_inventory(goal_id=goal_id, registered_agents=agents, status_item=derived) @@ -104,12 +107,17 @@ def execute_goal_amendment( "runtime_root": str(root), "observed_at": now_utc_iso(), "source": source} def inspect() -> dict[str, Any]: + nonlocal effective_replan_goal value = effect_runtime_result("goal.amendment.execute", {**common, "operation": "inspect", "dry_run": True}, timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS) if not isinstance(value, dict): raise TypeError("Goal amendment readback is invalid") + # Resolve once for current review observations. Historical + # receipt lookup must not depend on current configuration IO. + if effective_replan_goal is None: + effective_replan_goal = replan_configuration_reader(goal, root) value["replan_observations"] = _replan_readback(derived, obligations, value, - stride_strategy=(goal.get("execution_profile") or {}).get("stride_strategy", "off")) + stride_strategy=(effective_replan_goal.get("execution_profile") or {}).get("stride_strategy", "off")) return value if operation in {"propose", "commit", "claim"}: diff --git a/loopx/presentation/goal_amendment_api.py b/loopx/presentation/goal_amendment_api.py index d8a96f2060..fa0fb487a9 100644 --- a/loopx/presentation/goal_amendment_api.py +++ b/loopx/presentation/goal_amendment_api.py @@ -11,6 +11,7 @@ from ..chat import redact_local_paths from ..feedback import validate_goal_id +from ..capabilities.todo_replan_cadence.machine_defaults import resolve_todo_replan_cadence_goal from ..control_plane.goals.goal_amendment_runtime import execute_goal_amendment from ..control_plane.goals.source_session_registry_state import current_goal_ref from ..control_plane.projects.registry_codec import load_project_registry @@ -83,10 +84,12 @@ def _amendment_inspect(self) -> None: if len(query.get("operation_id", [])) != 1 or len(query.get("goal_instance_id", [])) != 1: raise ValueError("Historical lookup requires one operation and exact Goal instance") result = execute_goal_amendment(registry_path=source, goal_id=goal_id, goal_instance_id=query["goal_instance_id"][0], - operation="receipt", actor_agent_id=None, parameters={"receipt_operation_id": query["operation_id"][0]}) + operation="receipt", actor_agent_id=None, parameters={"receipt_operation_id": query["operation_id"][0]}, + replan_configuration_reader=resolve_todo_replan_cadence_goal) else: result = execute_goal_amendment(registry_path=source, goal_id=goal_ref["goal_id"], - goal_instance_id=goal_ref["goal_instance_id"], operation="inspect", actor_agent_id=None) + goal_instance_id=goal_ref["goal_instance_id"], operation="inspect", actor_agent_id=None, + replan_configuration_reader=resolve_todo_replan_cadence_goal) self._send_json(_public(result)) except (OSError, TypeError, ValueError) as exc: self._send_error(redact_local_paths(str(exc)), status=400, error_code="goal_amendment_unavailable") @@ -104,7 +107,8 @@ def _amendment_configure(self) -> None: parameters = body["parameters"] if set(parameters) != {"permission_scope", "terminal_conditions", "policy", "expected_provider_revision"}: raise ValueError("Only initial root policy fields are accepted here") - common = dict(registry_path=source, goal_id=goal_id, goal_instance_id=str(body["goal_instance_id"]), actor_agent_id=None) + common = dict(registry_path=source, goal_id=goal_id, goal_instance_id=str(body["goal_instance_id"]), actor_agent_id=None, + replan_configuration_reader=resolve_todo_replan_cadence_goal) current = execute_goal_amendment(**common, operation="inspect") if current.get("ok") is not True: self._send_json(_public(current)) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 3055d63e0e..1e9700a786 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -743,7 +743,7 @@ }, { "site": "loopx/cli_commands/goal_amendment_proposal.py::._resolve_goal_routing::codec_read:load_registry#1", - "line": 304, + "line": 306, "column": 28, "kind": "codec_read", "api": "load_registry", @@ -1455,7 +1455,7 @@ }, { "site": "loopx/control_plane/goals/goal_amendment_runtime.py::.execute_goal_amendment::codec_read:load_project_registry#1", - "line": 67, + "line": 69, "column": 15, "kind": "codec_read", "api": "load_project_registry", @@ -1463,7 +1463,7 @@ }, { "site": "loopx/control_plane/goals/goal_amendment_runtime.py::.execute_goal_amendment::codec_transaction:source_session_registry_transaction#1", - "line": 77, + "line": 79, "column": 14, "kind": "codec_transaction", "api": "source_session_registry_transaction", @@ -2191,7 +2191,7 @@ }, { "site": "loopx/presentation/goal_amendment_api.py::.GoalAmendmentRequestMixin._amendment_inspect::codec_read:load_project_registry#1", - "line": 80, + "line": 81, "column": 24, "kind": "codec_read", "api": "load_project_registry", @@ -2199,7 +2199,7 @@ }, { "site": "loopx/presentation/goal_amendment_api.py::._source_registry::codec_read:load_project_registry#1", - "line": 23, + "line": 24, "column": 15, "kind": "codec_read", "api": "load_project_registry", diff --git a/tests/control_plane/test_goal_amendment_authority.py b/tests/control_plane/test_goal_amendment_authority.py index 41793d087d..37a99bef3c 100644 --- a/tests/control_plane/test_goal_amendment_authority.py +++ b/tests/control_plane/test_goal_amendment_authority.py @@ -220,6 +220,88 @@ def test_claimed_successor_is_consumed_by_existing_replan_owner(source_goal, tmp assert original["receipts"][0]["result"]["replan_settlement"] == "pending" +@pytest.mark.parametrize("strategy", ["off", "evidence_conditioned_v1"]) +def test_periodic_successor_readback_uses_live_machine_stride(source_goal, tmp_path, capsys, strategy): + from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry + from loopx.capabilities.machine_configuration.store import configure_machine_configuration + from loopx.capabilities.todo_replan_cadence.machine_defaults import resolve_todo_replan_cadence_goal + + f = source_goal + # This retained source inventory reviews twenty durable records. Give it + # distinct advancing observations so a stall cannot replace that trigger. + rows = _stall_runs(goal_id=f["ref"]["goal_id"], count=20) + for index, row in enumerate(rows): + row["goal_ref"] = f["ref"] + row["progress_observation"].update(result_class="advanced", + surface_id=f"coverage-{index}", hypothesis_id=f"hypothesis-{index}") + f["runs"].write_text("".join(json.dumps(row) + "\n" for row in rows)) + owner = build_builtin_machine_configuration_registry() + configuration = {"schema_version": "loopx_machine_configuration_v0", "namespaces": { + "todo_replan_cadence": {"schema_version": "todo_replan_cadence_machine_defaults_v2", + "count_unit": "effective_turns", "count": 6, "stride_strategy": strategy}}} + preview = configure_machine_configuration(runtime_root=f["runtime"], registry=owner, configuration=configuration) + configure_machine_configuration(runtime_root=f["runtime"], registry=owner, configuration=configuration, + execute=True, expected_plan_revision=preview["plan_revision"]) + goal = load_project_registry(f["registry"])["goals"][0] + assert "stride_strategy" not in goal.get("execution_profile", {}) + effective = resolve_todo_replan_cadence_goal(goal, f["runtime"]) + assert effective["execution_profile"].get("stride_strategy", "off") == strategy + derived = _derive_open_replan_obligation_inventory(runtime_root=f["runtime"], goal_id=f["ref"]["goal_id"], + goal=goal, state_text="", goal_ref=f["ref"]) + candidates = list(derived.get("autonomous_replan_obligations_by_agent", {}).values()) + assert candidates and all(any(trigger["kind"] == "periodic_review_due" for trigger in item["triggers"]) + for item in candidates) + inventory = _open_replan_obligation_inventory(goal_id=f["ref"]["goal_id"], + registered_agents=["agent-a", "agent-b"], status_item=derived) + f["obligation"] = next(row["obligation_id"] for row in inventory if "agent-a" in row["bound_agent_ids"]) + + view = _call(f, tmp_path, capsys, "inspect")[1] + assert _call(f, tmp_path, capsys, "configure", _root(view), agent=None, + operation_id="root", execute=True)[0] == 0 + view = _call(f, tmp_path, capsys, "inspect")[1] + assert _call(f, tmp_path, capsys, "adopt", _basis(view), operation_id="adopt", execute=True)[0] == 0 + proposal = {**_basis(view), "proposal_id": "periodic", "template_id": "check", + "evidence_refs": ["evidence:gap"], "replan_obligation_id": f["obligation"]} + admitted = _call(f, tmp_path, capsys, "propose", proposal, operation_id="propose", execute=True)[1] + committed = _call(f, tmp_path, capsys, "commit", {"proposal_id": "periodic", + "proposal_digest": admitted["proposal"]["digest"]}, operation_id="commit", execute=True)[1] + view = committed["current_projection"] + adopted = _call(f, tmp_path, capsys, "adopt", _basis(view), operation_id="adopt-new", execute=True)[1] + view = adopted["current_projection"] + code, claimed = _call(f, tmp_path, capsys, "claim", {**_basis(view), "todo_id": committed["todo_id"], + "expected_provider_revision": view["provider_revision"], + "lease_request": {"idempotency_key": "periodic-claim", "expected_version": None, "ttl_seconds": 300}}, + operation_id="claim", execute=True) + assert code == 0, claimed + observation = next(row for row in claimed["current_projection"]["replan_observations"] + if row["obligation_id"] == f["obligation"] and row["agent_id"] == "agent-a") + if strategy == "off": + assert observation["state"] == "settled" + assert observation["acknowledgement"]["source"] == "todo_replan_successor_transition" + else: + assert observation["state"] == "pending" + assert observation["acknowledgement"] is None + # The source observation neither fabricates a decision nor rewrites history. + assert f["runs"].read_text() == "".join(json.dumps(row) + "\n" for row in rows) + + +def test_historical_receipt_does_not_read_current_stride_configuration(source_goal, tmp_path, capsys, monkeypatch): + from loopx.capabilities.todo_replan_cadence import machine_defaults + + f = source_goal + view = _call(f, tmp_path, capsys, "inspect")[1] + code, configured = _call(f, tmp_path, capsys, "configure", _root(view), + agent=None, operation_id="root", execute=True) + assert code == 0, configured + + def unavailable(*_args, **_kwargs): + raise OSError("synthetic current configuration unavailable") + + monkeypatch.setattr(machine_defaults, "resolve_todo_replan_cadence_goal", unavailable) + code, historical = _call(f, tmp_path, capsys, "receipt", {"receipt_operation_id": "root"}) + assert code == 0 and historical["status"] == "found" and historical["historical_only"], historical + + def test_root_initialization_cannot_shrink_source_goal_commitments(source_goal, tmp_path, capsys): f = source_goal view = _call(f, tmp_path, capsys, "inspect")[1]