diff --git a/docs/product/periodic-reports.md b/docs/product/periodic-reports.md index aad46a8211..2ed0e6e562 100644 --- a/docs/product/periodic-reports.md +++ b/docs/product/periodic-reports.md @@ -105,6 +105,13 @@ in its original managed Session. This backend/configuration slice is described below; arbitrary new tasks from natural language and the unified Agent UI remain separate, unfinished acceptance. +Calendar changes use the existing Goal configuration transaction. If a pause, +schedule change or clear was written but its readback is unavailable or differs, +the response retains the partial-write receipt instead of claiming that the old +configuration was preserved. Reload the source configuration before deciding on +another change. This does not rerun the task, confirm an unfinished result or +retire a failed window; those remain with their original lifecycle owners. + The steward becomes a persistent LoopX-managed Agent. Its original private conversation remains the user's entry point. Native monitor Todos, Turn admission, scheduling and periodic-report publication own recurring work. diff --git a/loopx/chat_goal_configuration_api.py b/loopx/chat_goal_configuration_api.py index 681682a18e..ff08f11bee 100644 --- a/loopx/chat_goal_configuration_api.py +++ b/loopx/chat_goal_configuration_api.py @@ -626,20 +626,20 @@ def _send_applied_goal_configuration( capability_id, readback_configuration, desired_configuration, applied ) except Exception: - if capability_id != "reward_memory" or not applied.get("written"): + if not applied.get("written"): raise self._send_partial_write_receipt( - applied={**applied, "error": "Memory configuration was written, but current readback is unavailable.", + applied={**applied, "error": "Configuration was written, but current readback is unavailable.", "recommended_action": "Reload the current Goal configuration before previewing another change."}, capability_id=capability_id, desired_configuration=desired_configuration, desired_public=desired_public, goal_id=goal_id, plan=plan, ) return if not readback_verified: - if capability_id == "reward_memory" and applied.get("written"): + if applied.get("written"): self._send_partial_write_receipt( - applied={**applied, "error": "Configuration was written, but the memory binding readback did not verify.", - "recommended_action": "Reload the current Goal configuration and inspect the binding before previewing another change."}, + applied={**applied, "error": "Configuration was written, but current readback did not verify.", + "recommended_action": "Reload the current Goal configuration before previewing another change."}, capability_id=capability_id, desired_configuration=desired_configuration, desired_public=desired_public, goal_id=goal_id, plan=plan, ) diff --git a/tests/test_managed_session_calendar_tasks.py b/tests/test_managed_session_calendar_tasks.py index cf692ac085..8cfb69ef89 100644 --- a/tests/test_managed_session_calendar_tasks.py +++ b/tests/test_managed_session_calendar_tasks.py @@ -317,6 +317,65 @@ def test_real_settings_only_select_existing_completed_source_and_current_preview assert not SessionCalendarTasks(runtime).health(session["session_id"])["configured"] +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("change", ["pause", "reschedule", "clear"]) +@pytest.mark.parametrize("readback_fault", ["unavailable", "mismatch"]) +def test_calendar_settings_report_persisted_changes_when_readback_is_unverified( + steward, tmp_path, monkeypatch, provider, change, readback_fault): + from loopx.chat_server import ChatRequestHandler + from loopx.capabilities.periodic_report.goal_configuration import configuration_summary + + session, actor, _, capture, _, config = setup_task(steward, tmp_path, monkeypatch, provider) + store, runtime, bot, *_ = steward + sid, goal_id = session["session_id"], actor["goal_id"] + target = ({**config, "enabled": False} if change == "pause" else + {**config, "schedule": {**config["schedule"], "rrule": "FREQ=DAILY;BYHOUR=10;BYMINUTE=0"}} + if change == "reschedule" else None) + calls, writes, messages = capture.read_bytes(), list(bot.writes), store.messages(sid) + with settings_server(steward) as request: + apply_configuration(request, goal_id, config) + body = {"goal_id": goal_id, "capability_id": "periodic_report", "configuration": target} + status, preview = request("/api/chat/goal-configuration/preview", body) + assert status == 201, preview + original_read = ChatRequestHandler._read_public_goal_configuration + + def unavailable_after_source_write(handler, requested_goal, *args, **kwargs): + current = configuration_summary(load_goal_from_registry(runtime.registry_path, goal_id)) + if requested_goal == goal_id and current == (normalize_configuration(target) if target else None): + if readback_fault == "unavailable": + raise OSError("synthetic private readback diagnostic must stay server-side") + value = original_read(handler, requested_goal, *args, **kwargs) + entry = next(item for item in value["capability_catalog"]["capabilities"] + if item["capability_id"] == "periodic_report") + entry["current"] = {"enabled": True} + return value + return original_read(handler, requested_goal, *args, **kwargs) + + with monkeypatch.context() as fault: + fault.setattr(ChatRequestHandler, "_read_public_goal_configuration", unavailable_after_source_write) + status, receipt = request("/api/chat/goal-configuration/apply", + {**body, "expected_plan_revision": preview["plan_revision"]}) + # Read the actual source independently of the failed response path. + assert configuration_summary(load_goal_from_registry(runtime.registry_path, goal_id)) == ( + normalize_configuration(target) if target else None) + assert status == 207, receipt + assert receipt["status"] == "partial_write" and receipt["source_written"] is True + assert receipt["readback_verified"] is False and receipt["applied_revision"] is None + assert receipt["goal_configuration"] == (None if readback_fault == "unavailable" else {"enabled": True}) + assert receipt["capability_id"] == "periodic_report" and receipt["goal_id"] == goal_id + assert "prior state was preserved" not in receipt["error"] + assert "synthetic private" not in json.dumps(receipt) + assert "Reload" in receipt["recommended_action"] + # A normal read/re-preview settles the uncertainty without replaying a + # write, a model turn, a scheduled period or an external delivery. + status, loaded = request("/api/chat/goal-configuration?goal_id=" + goal_id, None, method="GET") + assert status == 200, loaded + status, refreshed = request("/api/chat/goal-configuration/preview", body) + assert status == 201 and refreshed["writes_required"] == 0, refreshed + assert capture.read_bytes() == calls and bot.writes == writes and store.messages(sid) == messages + assert read_cadence_journal(runtime_root=runtime.coordination_runtime_root, goal_id=goal_id) is None + + @pytest.mark.parametrize("fault", ["queued", "busy", "paused", "unregistered", "stopped"]) def test_original_user_work_and_withdrawn_authority_win_without_model(steward, tmp_path, monkeypatch, fault): session, actor, _, capture, _, config = setup_task(steward, tmp_path, monkeypatch, "sqlite")