From f48f2068cb8dd21225bf5051df1a988cb86ff73e Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:06:03 +0800 Subject: [PATCH 1/3] refactor(todos): route Monitor configuration through canonical transactions Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/control_plane/todos/monitor_metadata.ts | 28 ++++++ .../control_plane/todos/native_update_plan.ts | 11 ++- loopx/control_plane/todos/public_update.ts | 7 ++ loopx/control_plane/todos/update_intent.py | 47 ++-------- loopx/todos.py | 4 +- .../test_native_monitor_configuration.py | 87 ++++++++++++++++++ .../test_public_todo_update_plan.py | 12 +-- .../control_plane/test_todo_update_intent.py | 12 ++- .../authority_store_conformance.ts | 2 + .../monitor_configuration_conformance.ts | 89 +++++++++++++++++++ 10 files changed, 245 insertions(+), 54 deletions(-) create mode 100644 tests/control_plane/test_native_monitor_configuration.py create mode 100644 tests/control_plane_ts/monitor_configuration_conformance.ts diff --git a/loopx/control_plane/todos/monitor_metadata.ts b/loopx/control_plane/todos/monitor_metadata.ts index e08d689c34..50c38e0c02 100644 --- a/loopx/control_plane/todos/monitor_metadata.ts +++ b/loopx/control_plane/todos/monitor_metadata.ts @@ -9,10 +9,38 @@ import { parseTodoTimestampMicros } from "../runtime_timestamp.ts"; export const TODO_MONITOR_METADATA_REQUEST_SCHEMA = "loopx_todo_monitor_metadata_request_v0"; export const TODO_MONITOR_METADATA_RESULT_SCHEMA = "loopx_todo_monitor_metadata_result_v0"; +export const MONITOR_CONFIGURATION_FIELDS = ["target_key", "cadence", "next_due_at", "expires_at", "watch_only"] as const; export const MONITOR_METADATA_FIELDS = ["target_key", "monitor_effect_id", "cadence", "next_due_at", "expires_at", "last_checked_at", "result_hash", "consecutive_no_change", "material_change", "material_change_generation", "max_no_change_before_replan", "watch_only"] as const; +/** Public configuration is not an observation/import codec. Keep historical + * fields available to their existing lower-level owners, never to this intent. */ +export function normalizeMonitorConfiguration(value: unknown): JsonObject { + const raw = requireJsonObject(value, "Monitor configuration"); + const input: JsonObject = {}; + for (const [field, value] of Object.entries(raw)) { + if (!(MONITOR_CONFIGURATION_FIELDS as readonly string[]).includes(field)) { + throw new EffectRuntimeRequestError(`Monitor configuration does not own ${field}; use the observation lifecycle`); + } + if (value !== null && typeof value !== "string" && !(field === "watch_only" && typeof value === "boolean")) { + throw new EffectRuntimeRequestError(`Monitor configuration ${field} must be a string or null`); + } + input[field] = field === "watch_only" && value !== null ? String(value).toLowerCase() : value; + } + return normalizeMetadata(input); +} + +export function validateMonitorConfigurationTarget(existing: JsonObject, metadata: JsonObject): void { + if (!Object.hasOwn(metadata, "target_key") || text(metadata.target_key) === text(existing.target_key)) return; + // Observations and dependent generation fences name this target's history. + // Changing its identity cannot reuse those receipts as evidence for a new target. + if (["monitor_effect_id", "result_hash", "last_checked_at"].some(field => text(existing[field])) || + counter(existing.material_change_generation) > 0) { + throw new EffectRuntimeRequestError("an observed Monitor cannot change target_key; create an independent Monitor for the new target"); + } +} + function text(value: unknown): string { if (value === null || value === undefined || value === false || value === 0) return ""; return stripPythonWhitespace(value === true ? "True" : String(value)); diff --git a/loopx/control_plane/todos/native_update_plan.ts b/loopx/control_plane/todos/native_update_plan.ts index 0db221d426..f0cb21a143 100644 --- a/loopx/control_plane/todos/native_update_plan.ts +++ b/loopx/control_plane/todos/native_update_plan.ts @@ -15,11 +15,13 @@ import { validateTodoDecisionMetadata, } from "./decision_metadata.ts"; +import { normalizeMonitorConfiguration } from "./monitor_metadata.ts"; + const STRINGS = new Set(["status", "evidence", "reason", "task_class", "continuation_policy", "resume_when", "unblocks_todo_id", "bound_agent", "blocks_agent"]); const BOOLEANS = new Set(["clear_resume_when", "no_followup", "goal_bound", "clear_blocks_agent", "global_gate", "clear_global_gate"]); -const FIELDS = new Set([...STRINGS, ...BOOLEANS, "successor_todo_ids", +const FIELDS = new Set([...STRINGS, ...BOOLEANS, "successor_todo_ids", "monitor_metadata", ...TODO_WORK_REQUIREMENT_FIELDS, ...TODO_OWNERSHIP_INTENT_FIELDS, ...TODO_DECISION_METADATA_FIELDS]); /** A separate intent namespace preserves the shipped text/note patch and its @@ -33,6 +35,10 @@ export function normalizeNativePlanningIntent(value: unknown): JsonObject { if (!FIELDS.has(field)) throw new AuthorityStoreProtocolError(`Todo planning update does not own ${field}`); if ((TODO_WORK_REQUIREMENT_FIELDS as readonly string[]).includes(field)) continue; if ((TODO_OWNERSHIP_INTENT_FIELDS as readonly string[]).includes(field)) continue; + if (field === "monitor_metadata") { + if (value != null) intent[field] = normalizeMonitorConfiguration(value); + continue; + } if (field === "decision_scope") { intent[field] = normalizeTodoDecisionScope(value, field); continue; @@ -64,9 +70,6 @@ export function normalizeNativePlanningIntent(value: unknown): JsonObject { export function planNativeTodoUpdate(todo: JsonObject, intent: JsonObject, head: JsonObject, actor: string | null, agents: readonly string[], updatedAt: string): JsonObject { - if (todo.task_class === "continuous_monitor") { - throw new AuthorityStoreProtocolError("native Monitor planning updates require the atomic monitor writer; text/note correction remains supported"); - } validateTodoDecisionMetadata(todo, intent); const planned = planPublicTodoUpdate({schema_version: TODO_PUBLIC_UPDATE_REQUEST_SCHEMA, todo, intent, updated_at: updatedAt, diff --git a/loopx/control_plane/todos/public_update.ts b/loopx/control_plane/todos/public_update.ts index 5045cfe823..7a569aea07 100644 --- a/loopx/control_plane/todos/public_update.ts +++ b/loopx/control_plane/todos/public_update.ts @@ -14,6 +14,8 @@ import { validateTodoDecisionMetadata, } from "./decision_metadata.ts"; +import { normalizeMonitorConfiguration, validateMonitorConfigurationTarget } from "./monitor_metadata.ts"; + export const TODO_PUBLIC_UPDATE_REQUEST_SCHEMA = "todo_public_update_request_v0"; const SCOPE_INTENT_FIELDS = ["task_class", "status", "claimed_by", "bound_agent", "goal_bound", @@ -63,6 +65,11 @@ export function planPublicTodoUpdate(value: unknown): JsonObject { } validateTodoDecisionMetadata(todo, intent); const context = requireJsonObject(request.context, "public Todo update context"); + if (context.monitor_observation == null && intent.monitor_metadata != null) { + const metadata = normalizeMonitorConfiguration(intent.monitor_metadata); + validateMonitorConfigurationTarget(todo, metadata); + intent.monitor_metadata = metadata; + } // Preserve omission at the authoring-scope boundary. Filling every field // with null made an unrelated metadata edit erase a retained gate scope. const scopeIntent = Object.fromEntries(SCOPE_INTENT_FIELDS.flatMap(key => diff --git a/loopx/control_plane/todos/update_intent.py b/loopx/control_plane/todos/update_intent.py index c283d95d3a..0aba116f31 100644 --- a/loopx/control_plane/todos/update_intent.py +++ b/loopx/control_plane/todos/update_intent.py @@ -9,44 +9,7 @@ from typing import Any - -# Keep governance decisions and monitor effects on their owning paths. These -# are exactly the planning fields accepted by native_update_plan.ts; the set -# is intentionally duplicated here as a boundary check, not as a second rule -# implementation. Decision outcomes remain effect-owned; only declarative -# decision scope metadata crosses this transaction boundary. -_CANONICAL_INTENT_FIELDS = frozenset( - { - "status", - "evidence", - "reason", - "task_class", - "action_kind", - "task_domain", - "task_repository", - "continuation_policy", - "required_write_scopes", - "required_capabilities", - "target_capabilities", - "explore_result_node_refs", - "decision_scope", - "required_decision_scopes", - "claimed_by", - "bound_agent", - "goal_bound", - "blocks_agent", - "clear_blocks_agent", - "excluded_agents", - "global_gate", - "clear_global_gate", - "unblocks_todo_id", - "successor_todo_ids", - "resume_when", - "clear_resume_when", - "no_followup", - "clear_claim", - } -) +from .monitor_metadata import MonitorPollObservation def build_canonical_update_intent( @@ -133,17 +96,17 @@ def canonical_update_is_supported( """Whether an ordinary update can use the canonical transaction. Terminal completion and monitor polling retain their effect-owned paths. + Ordinary metadata is validated by the typed transaction, including rejection + of unsupported fields; this transport must not duplicate its field catalog. They must not silently fall back to Markdown after authority promotion. """ - if monitor_metadata or authority_reason: + if isinstance(monitor_metadata, MonitorPollObservation) or authority_reason: return False if status is not None and status.strip().lower() == "done": return False - if any(field not in _CANONICAL_INTENT_FIELDS for field in intent): - return False # Empty notes are the long-standing compatibility spelling for omission; # routing them to the canonical adapter would produce an empty patch and a # less useful protocol error. Text still uses the normal non-empty text # validator at the provider boundary. - return text is not None or (note is not None and bool(note.strip())) or bool(intent) + return text is not None or (note is not None and bool(note.strip())) or bool(intent) or bool(monitor_metadata) diff --git a/loopx/todos.py b/loopx/todos.py index 50519a728a..be23a26fbf 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -1159,7 +1159,9 @@ def update_goal_todo( operation_id=update_operation_id, task_lease_idempotency_key=task_lease_idempotency_key, task_lease_expected_version=task_lease_expected_version, - planning_intent=planning_intent, + planning_intent={**planning_intent, **( + {"monitor_metadata": monitor_metadata} if monitor_metadata else {} + )}, ) if canonical_edit is not None: return canonical_edit diff --git a/tests/control_plane/test_native_monitor_configuration.py b/tests/control_plane/test_native_monitor_configuration.py new file mode 100644 index 0000000000..0b62141257 --- /dev/null +++ b/tests/control_plane/test_native_monitor_configuration.py @@ -0,0 +1,87 @@ +"""Monitor configuration uses the public writer and actual local providers.""" +from __future__ import annotations + +import pytest +from canonical_authority_fixture import isolate_sqlite_runtime +from test_native_monitor_poll import _canonical +from test_monitor_followthrough_contract import _write_fixture, _add_monitor, GOAL_ID, AGENT_ID +from loopx.control_plane.testing.canary_harness import run_json_cli +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.todos import list_goal_todos, update_goal_todo + + +def setup(tmp_path, provider): + if provider == "legacy": + registry, runtime, state = _write_fixture(tmp_path) + monitor = _add_monitor(registry, text="Observe public changes", target_key="public-watch", next_due_at="2000-01-01T00:00:00Z") + return registry, runtime, state, monitor + return _canonical(tmp_path, provider=provider) + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_configuration_cli_and_clear_preserve_observation(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, runtime, state, monitor = setup(tmp_path, provider) + before = list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"][0] + if provider != "legacy": + state.unlink() + args = ("todo", "update", "--goal-id", GOAL_ID, "--todo-id", monitor["todo_id"], + "--agent-id", AGENT_ID, "--cadence", "2h", "--next-due-at", "2099-01-01T02:00:00Z") + identity = () if provider == "legacy" else ("--update-operation-id", "configuration-a") + run_json_cli(*args, *identity, "--dry-run", registry_path=registry, runtime_root=runtime) + if provider != "legacy": + assert not state.exists() + result = run_json_cli(*args, *identity, registry_path=registry, runtime_root=runtime) + current = list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"][0] + assert current["cadence"] == "2h" + for field in ("result_hash", "material_change_generation", "last_checked_at", "monitor_effect_id", "consecutive_no_change"): + assert current.get(field) == before.get(field) + if provider != "legacy": + assert result["source_authority"] == ("file_v0" if provider == "file" else "sqlite_v0") + replay = run_json_cli(*args, *identity, registry_path=registry, runtime_root=runtime) + assert replay["status"] == "replayed" + update_goal_todo(registry_path=registry, runtime_root_arg=str(runtime), goal_id=GOAL_ID, + todo_id=monitor["todo_id"], agent_id=AGENT_ID, + monitor_metadata={"watch_only": None, "expires_at": "2099-02-01T00:00:00Z"}) + current = list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"][0] + assert not current.get("watch_only") + assert current["expires_at"] == "2099-02-01T00:00:00Z" + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_configuration_rejection_and_delivery_recovery(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, runtime, state, monitor = setup(tmp_path, provider) + args = dict(registry_path=registry, runtime_root_arg=str(runtime), goal_id=GOAL_ID, + todo_id=monitor["todo_id"], agent_id=AGENT_ID) + before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) + for metadata in ({"material_change_generation": 100}, {"cadence": "never"}, {"watch_only": None}, {"unknown": "x"}): + with pytest.raises((ValueError, RuntimeError)): + update_goal_todo(**args, text="Must not partially commit", monitor_metadata=metadata) + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL_ID) == before + import loopx.control_plane.todos.provider_projection as delivery + def unavailable(**kwargs): + raise OSError("synthetic delivery outage") + with monkeypatch.context() as m: + m.setattr(delivery, "project_current_canonical_todos", unavailable) + result = update_goal_todo(**args, monitor_metadata={"cadence": "3h"}, update_operation_id="recover-config") + assert result["projection_delivery"] == "pending" + replay = update_goal_todo(**args, monitor_metadata={"cadence": "3h"}, update_operation_id="recover-config") + assert replay["status"] == "replayed" + assert replay["projection_delivery"] == "delivered" + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_observed_target_cannot_be_repurposed_by_configuration(tmp_path, monkeypatch, provider): + from loopx.control_plane.scheduler.monitor_poll_writeback import write_monitor_poll_todo_state + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, runtime, _state, monitor = setup(tmp_path, provider) + write_monitor_poll_todo_state(registry_path=registry, runtime_root=runtime, goal_id=GOAL_ID, + execute=True, todo_id=monitor["todo_id"], agent_id=AGENT_ID, monitor_effect_id="observed-target", + generated_at="2030-01-01T00:00:00Z", result_hash="original-target-result", material_change=True) + before = list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"] + for metadata in ({"target_key": "another-target"}, {"target_key": None}, {"result_hash": "invented"}): + with pytest.raises((ValueError, RuntimeError)): + update_goal_todo(registry_path=registry, runtime_root_arg=str(runtime), goal_id=GOAL_ID, + todo_id=monitor["todo_id"], agent_id=AGENT_ID, monitor_metadata=metadata) + assert list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"] == before diff --git a/tests/control_plane/test_public_todo_update_plan.py b/tests/control_plane/test_public_todo_update_plan.py index 1e443c0df3..789be33c56 100644 --- a/tests/control_plane/test_public_todo_update_plan.py +++ b/tests/control_plane/test_public_todo_update_plan.py @@ -4,16 +4,18 @@ from loopx.todos import add_goal_todo, update_goal_todo from loopx.control_plane.testing.canary_harness import run_json_cli_result from tests.control_plane.test_monitor_followthrough_contract import ( - AGENT_ID, GOAL_ID, _add_monitor, _write_fixture, + AGENT_ID, GOAL_ID, _write_fixture, ) def waiting_goal(tmp_path): registry, runtime, state = _write_fixture(tmp_path) - monitor = _add_monitor(registry, text="Observe fixture", target_key="fixture") - update_goal_todo(registry_path=registry, goal_id=GOAL_ID, - todo_id=monitor["todo_id"], agent_id=AGENT_ID, - monitor_metadata={"material_change_generation": "3"}) + # Initialize historical evidence through the create/import boundary; + # ordinary configuration cannot manufacture an observation generation. + monitor = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Observe fixture", task_class="continuous_monitor", claimed_by=AGENT_ID, + monitor_metadata={"target_key": "fixture", "cadence": "1h", "watch_only": "true", + "material_change_generation": "3"}) def add(text): return add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", task_class="advancement_task", text=text, claimed_by=AGENT_ID) diff --git a/tests/control_plane/test_todo_update_intent.py b/tests/control_plane/test_todo_update_intent.py index fabe366706..7e4da75a8f 100644 --- a/tests/control_plane/test_todo_update_intent.py +++ b/tests/control_plane/test_todo_update_intent.py @@ -1,3 +1,4 @@ +from loopx.control_plane.todos.monitor_metadata import MonitorPollObservation from loopx.control_plane.todos.update_intent import ( build_canonical_update_intent, canonical_update_is_supported, @@ -51,13 +52,13 @@ def test_update_route_promotes_declarative_decision_metadata() -> None: ) -def test_terminal_and_monitor_updates_stay_off_canonical_route() -> None: +def test_terminal_and_monitor_observations_stay_off_canonical_route() -> None: intent = build_canonical_update_intent(reason="ordinary") assert not canonical_update_is_supported( text=None, note=None, intent=intent, - monitor_metadata={"material_change": True}, + monitor_metadata=MonitorPollObservation(generated_at="2030-01-01T00:00:00Z", result_hash="observed", material_change=True), authority_reason=None, status=None, ) @@ -77,3 +78,10 @@ def test_terminal_and_monitor_updates_stay_off_canonical_route() -> None: authority_reason=None, status=None, ) + + +def test_configuration_field_admission_belongs_to_the_typed_transaction() -> None: + # Even malformed/unknown fields must reach the rejecting TS decoder; + # they cannot divert a promoted request into a Markdown business writer. + assert canonical_update_is_supported(text=None, note=None, intent={}, + monitor_metadata={"material_change_generation": 99}, authority_reason=None, status=None) diff --git a/tests/control_plane_ts/authority_store_conformance.ts b/tests/control_plane_ts/authority_store_conformance.ts index f1bd232c44..bac4074891 100644 --- a/tests/control_plane_ts/authority_store_conformance.ts +++ b/tests/control_plane_ts/authority_store_conformance.ts @@ -1,3 +1,4 @@ +import {registerMonitorConfigurationConformance} from "./monitor_configuration_conformance.ts"; import {registerAuthorityScanConformance} from "./authority_scan_conformance.ts"; import {executeCoordinationTodoArchiveCompleted} from "../../loopx/control_plane/coordination/todo_archive.ts"; import {registerHandoffModeConformance} from "./handoff_mode_conformance.ts"; @@ -211,6 +212,7 @@ export function registerAuthorityStoreConformance( registerAuthorityScanConformance(providerName, factory); registerOwnershipObservationConformance(providerName, factory); registerNativePlanningUpdateConformance(providerName, factory); + registerMonitorConfigurationConformance(providerName, factory); registerCoordinationReceiptConformance(providerName, factory); registerHandoffModeConformance(providerName, factory); for (const native of [false, true]) test(`${providerName} conformance: standing revocation survives canonical ordering and archive (${native ? "native" : "legacy"})`, async (t) => { diff --git a/tests/control_plane_ts/monitor_configuration_conformance.ts b/tests/control_plane_ts/monitor_configuration_conformance.ts new file mode 100644 index 0000000000..6fcbc81b9f --- /dev/null +++ b/tests/control_plane_ts/monitor_configuration_conformance.ts @@ -0,0 +1,89 @@ +/** Configuration is a Todo transaction, never a poll or a generation advance. */ +import assert from "node:assert/strict"; +import test from "node:test"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import type {AuthorityStore} from "../../loopx/control_plane/coordination/authority_store.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {executeCoordinationTodoUpdate} from "../../loopx/control_plane/coordination/todo_update.ts"; +import type {AuthorityStoreConformanceFactory} from "./authority_store_conformance.ts"; +import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; + +export function registerMonitorConfigurationConformance(provider: string, factory: AuthorityStoreConformanceFactory) { + for (const leased of [false, true]) test(`${provider}: Monitor configuration preserves complete history and ${leased ? "current lease" : "receipt replay"}`, async t => { + const {store, contender} = await factory(t); + const goal = "monitor-config"; + const fixture = productionScaleCoordinationFixture(goal); + const projection = structuredClone(fixture.projection) as JsonObject; + const todos = projection.todos as JsonObject[]; + const leases = projection.leases as JsonObject[]; + // Extend one existing live leased task into the Monitor dimension; keep + // every other task, dependency and lease from the complete fixture. + const monitor = todos.find(todo => leased ? todo.todo_id === fixture.completion_todo_id : + todo.task_class === "continuous_monitor" && todo.status === "open" && + !leases.some(lease => lease.todo_id === todo.todo_id))!; + assert.ok(monitor, "complete fixture needs a live task for configuration"); + const lease = leases.find(lease => lease.todo_id === monitor.todo_id); + const actor = leased ? String(lease!.owner) : "agent-a"; + Object.assign(monitor, {task_class: "continuous_monitor", claimed_by: actor, watch_only: "true", target_key: "configuration-target", + cadence: "1h", result_hash: "observed-state", material_change_generation: 7, last_checked_at: "2025-01-01T00:00:00Z"}); + delete monitor.excluded_agents; + delete monitor.bound_agent; + projection.handoff_mode = "soft_claim"; + (projection.todo_read_model as JsonObject).records_sha256 = canonicalAuthoritySha256(todos); + assert.equal((await store.commitAuthority({operation_id: "seed-monitor", expected_provider_revision: null, + next_projection: projection, events: [], receipts: []})).status, "applied"); + const before = await store.loadAuthority(); + assert.equal(before.status, "loaded"); + if (before.status !== "loaded") throw new Error("seed missing"); + const request = {goal_id: goal, todo_id: String(monitor.todo_id), expected_role: "agent", actor_agent_id: actor, + registered_agents: fixture.registered_agents, operation_id: "configure-monitor", patch: {}, clear_fields: [], + dry_run: false, now: leased ? new Date(new Date(String(lease!.expires_at)).getTime() - 60000) : new Date("2099-01-01T00:00:00Z"), + planning_intent: {monitor_metadata: {cadence: "2h", watch_only: "TRUE"}}, + ...(leased ? {lease_idempotency_key: String(lease!.idempotency_key), lease_expected_version: Number(lease!.version)} : {})}; + if (leased) { + assert.equal((await executeCoordinationTodoUpdate(store, {...request, lease_idempotency_key: null, lease_expected_version: null})).status, "failed"); + assert.deepEqual(await store.loadAuthority(), before); + } + assert.equal((await executeCoordinationTodoUpdate(store, {...request, dry_run: true})).status, "planned"); + assert.deepEqual(await store.loadAuthority(), before); + const applied = await executeCoordinationTodoUpdate(store, request); + assert.equal(applied.status, "applied", JSON.stringify(applied)); + let after = await store.loadAuthority(); + if (after.status !== "loaded") throw new Error("committed head missing"); + assert.deepEqual(after.head.leases, projection.leases); + const actual = (after.head.todos as JsonObject[]).find(todo => todo.todo_id === monitor.todo_id)!; + assert.equal(actual.cadence, "2h"); + assert.equal(actual.material_change_generation, 7); + assert.equal(actual.result_hash, "observed-state"); + assert.equal(actual.last_checked_at, monitor.last_checked_at); + assert.deepEqual((after.head.todos as JsonObject[]).filter(todo => todo.todo_id !== monitor.todo_id), + todos.filter(todo => todo.todo_id !== monitor.todo_id)); + assert.equal((await executeCoordinationTodoUpdate(store, {...request, + planning_intent: {monitor_metadata: {cadence: "2h", watch_only: true}}})).status, "replayed"); + assert.deepEqual(await store.loadAuthority(), after); + for (const metadata of [{result_hash: "fabricated"}, {material_change_generation: 9}, {watch_only: null}, + {target_key: "different-target"}, {target_key: null}, {cadence: []}, {cadence: "never"}, {unknown: "x"}]) { + const result = await executeCoordinationTodoUpdate(store, {...request, operation_id: "illegal", patch: {text: "Partial edit"}, + planning_intent: {monitor_metadata: metadata}}); + assert.equal(result.status, "failed", JSON.stringify(result)); + assert.equal((await store.readReceipt("illegal")).status, "missing"); + assert.deepEqual(await store.loadAuthority(), after); + } + assert.equal((await executeCoordinationTodoUpdate(store, {...request, operation_id: "wrong-actor", actor_agent_id: "agent-foreign"})).status, "failed"); + assert.equal((await executeCoordinationTodoUpdate(store, {...request, operation_id: "no-change"})).status, "no_change"); + // Configuration and the receipt use one CAS. A competing observation cannot be overwritten. + const stale = before.provider_revision; + assert.equal((await executeCoordinationTodoUpdate(contender, {...request, operation_id: "stale", + expected_provider_revision: stale})).reason_code, "provider_revision_mismatch"); + const lost: AuthorityStore = {storeIdentity: store.storeIdentity, + loadAuthority: () => store.loadAuthority(), readReceipt: key => store.readReceipt(key), + scanCommitted: (cursor, limit) => store.scanCommitted(cursor, limit), async commitAuthority(value) { + await store.commitAuthority(value); throw new Error("synthetic response lost"); + }}; + const next = {...request, operation_id: "lost-config", planning_intent: {monitor_metadata: {cadence: "3h"}}}; + assert.equal((await executeCoordinationTodoUpdate(lost, next)).status, "recovered"); + after = await store.loadAuthority(); + assert.equal((await executeCoordinationTodoUpdate(contender, next)).status, "replayed"); + assert.deepEqual(await store.loadAuthority(), after); + }); +} From 74175c39df5070cf64872c046e01eafaca27566e Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:06:13 +0800 Subject: [PATCH 2/3] docs(rfc): sequence the qualified local-provider default rollout Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...shared-goal-authority-state-provider-v0.md | 84 ++++++++++++++----- ...-goal-authority-state-provider-v0.zh-CN.md | 66 ++++++++++----- .../typescript-control-plane-migration-v0.md | 22 ++++- ...script-control-plane-migration-v0.zh-CN.md | 18 +++- docs/reference/monitor-configuration.md | 76 +++++++++++++++++ 5 files changed, 222 insertions(+), 44 deletions(-) create mode 100644 docs/reference/monitor-configuration.md diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index 0c983001b9..21ffacca93 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2984,30 +2984,68 @@ independent legacy three-arm comparison or D2 soak. #### Execution handoff and integration order -| Ready condition | Next action | What it does not authorize | +**Local-default delivery program (2026-09-14).** The outcome is a new local +Goal whose normal CLI, Turn and operator actions use one TS-owned canonical +transaction path, with Markdown as a permanent projection. An omitted selector +resolving to File is not this outcome: existing Goals still have a legacy +writer until an explicit whole-Goal cutover. + +Qualify **one** long-lived local default profile. SQLite is the current D2 +candidate; File remains the real reference/explicit profile and migration +rehearsal backend. Do not publish two ambiguous defaults, declare the current +File history layout long-horizon-qualified, or silently fall back from a +selected SQLite store. The final profile decision must cite its D2 evidence. +PostgreSQL shares the TS semantic contracts but has independent service, +tenant, restore and capacity qualification; its deployment must not delay the +local profile's work. + +The reconciled baseline includes #4286 (command receipts/archive), #4289 +(typed work/ownership intent), #4292 (declarative decision metadata), and #4304 +(canonical handoff mode). Candidate #4316 closes Goal Channel observation; +#4317 unifies provider opening; #4348 adds canonical renew; #4328 is the first +SQLite D2 measurement/recovery batch. They are review candidates, not merged +prerequisites or proof of the full cards. #4334 is the independent PostgreSQL +service-admission candidate. Re-read actual heads before composing work; do +not carry their already-merged ancestors as new changes. + +The identifiers below are **planned PR packages**, not reserved GitHub numbers. +A package may split at a real effect/compatibility boundary; changing languages +or moving a helper is not by itself a package exit. + +| Wave / package | Reviewable delivery and TS ownership payoff | Dependencies and exit evidence | | --- | --- | --- | -| Current refactor stack is reconciled | T1; D1 and D2 may proceed independently | Default provider changes or another generic migration framework | -| T1 closes field semantics | T2; close T3 consumers as their contracts become available | Per-command split authority within one Goal | -| T1–T3 and D1/D2 plus capture qualify | D3 rehearsal, then explicit promotion request | Skipping soak, bypassing failed evidence, or production promotion by the agent | -| Approved cutover and legacy window finish | T4 full-writer retirement | Deleting permanent Markdown presentation or historical receipts still needed for replay | - -Expect roughly **five to seven cohesive implementation/qualification batches** -after reconciling the current stack, not a fixed PR quota: T1, T2, T3, D1, D2, -D3 and T4 can share a PR only when their dependencies, review and rollback -remain clear. Semantic deletion starts in T1; full legacy-writer deletion waits -for D3/T4. Elapsed-time soak is separate and is not shortened by splitting PRs. - -For each handoff, record the exact base/head, selected card, actual callers -removed, changed authority/observable semantics, real-backend results, remaining -holds and one next executable action. If an earlier stage already landed, -verify its evidence and skip its implementation; if prerequisites fail, stop -that dependent stage. Do not turn hypothetical post-merge readiness into an -automatic promotion, automation, merge or release permission. - -The current default and Appendix C promotion holds remain unchanged. This plan -does not declare the whole Todo family, long-goal profile, or shared deployment -production-ready. Providers keep CAS/transactions durable; they never own a -second Todo state machine. +| A / L1: Monitor configuration (this slice) | Existing `todo update` config enters the TS planner/CAS/receipt; delete Python's duplicate intent field catalog. Separate authoring from observed hashes, times and generations. | Ordinary CLI/API, clear/omission, active lease proof, no-op/replay, failed display delivery, complete fixture and real providers. This does not complete delegated Chat or leased polling. | +| A / L2: Complete public mutation admission | Inventory actual CLI/Turn/Chat callers; close remaining effect-owned user decisions, delegated owner actions and Monitor lifecycle transitions with validated actor/grant facts. | Build on merged T1 owners, not a generic raw patch. Prove permission rejection and exact caller response; remove replaced Python admission and name every remaining unsupported command. | +| A / L3: Canonical lease lifecycle | Reconcile #4348 renew; close transfer/release and their CLI consumers through the same typed lease rules, atomic head/event/receipt and replay. | One canonical Todo/lease revision; lost replies, stale versions, competing owners, expired/released history and cleanup proofs. Receipt replay is never a fresh execution grant. | +| B / L4: Leased Monitor poll and settlement | Compose observation, generation and independent successors with the current lease fence. Reuse the existing quota settlement protocol and exact business receipt. | L2/L3; real polling failure, duplicate/no-change observations, crash between business and quota settlement, and competing writers. Do not pretend separate authorities share a database transaction. | +| B / L5: Consumer and display closure | Reconcile #4316, audit Turn/quota/Dashboard/Chat source reads, and finish D1 freshness/recovery through the existing projection outbox. | CLI, Lark/Chat and packaged frontend read back their affected interactions; absent/stale display, empty canonical state, pending projection and data beyond UI limits. Delete post-promotion legacy fallbacks with each consumer. | +| A–C / L6: Local durability qualification | Continue contributor-owned #4224/#4328 on the selected SQLite profile; reuse File/NoKV references and complete 7.2's ledger. | Capacity, real process/crash/restore/upgrade, retained receipts/scans, consumer lag, supported runtimes/OS and the separately authorized >=10-day synthetic soak. Missing measurements remain holds. | +| A–C / L7: Capture continuity | Resolve #4315 with source-correlated archive retirement and identical lease membership at bootstrap and later writers; activate its row/mutant and complete the mixed-writer/event-source matrix. | Real CLI/File capture, history retained, partial drain unqualified, crash/replay and a new lease after archive/rebootstrap. Keep the legacy migration window provable; T4 cannot be used to skip this row. | +| C / L8: Whole-Goal rehearsal and cohort migration | Integrate one exact revision/profile after L2–L7; drain capture, fence old writers, verify canonical readback and projection, then rehearse fenced export/rollback. | D3 evidence packet binds lineage, cursor, source digest, command coverage and profile. Existing Goal migration requires explicit cohort approval; no per-command split authority or stale Markdown revival. | +| D / L9: New-Goal default and bounded retirement | A dedicated default-change PR makes new-Goal creation/onboarding choose the qualified local profile, including settings/readback, installer and packaged clients. Retire old business writers only as their final callers and migration window close. | L8's integrated product/rollback qualification; distinguish new Goal default from existing Goal migration. Publish compatibility/disable guidance, keep explicit provider choice, permanent rendering and validated import/export. T4 can continue after the default ships. | + +**Cadence is evidence-based.** First reconcile the active stack, then deliver A +packages as complete operations while L6/L7 progress independently. B integrates +those contracts into complete user flows; C has one reproducible qualification +checkpoint; D changes the default in its own reviewable PR. This is roughly +nine cohesive packages at this checkpoint, not a line-count target or a promise +of nine merges. Avoid concurrent edits to the same transaction owner; share +fixture/contracts early and rebase after the owner lands. + +There is no defensible calendar completion date before the L2/L3 command +inventory and L6 missing-evidence ledger close. The >=10-day soak is a real +elapsed-time lower bound **after the measured profile is ready**, not ten days +from this plan. It may overlap compatible work after explicit launch approval; +changes to the qualified durability semantics require an impact-based rerun. +Accelerated fixtures cannot replace elapsed time. Native TS CLI/distribution +cleanup, removal of every Python adapter, and PostgreSQL service deployment are +not prerequisites for this local default. + +Every package records actual caller/owner deletion, added bridge LOC and its +exit, request/response counts, real-backend results, baseline parity and disclosed +semantic corrections. A green unit suite, a canonical selector, or a new config +field alone cannot advance a package to default readiness. Planned integration, +soak, release, merge and live promotion retain their respective authorization. ### Parallel delivery plan diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index acfe1b6415..35ea48dd9b 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -2366,26 +2366,54 @@ route planner 本身仍不授予权限。CLI 将已提交回执交给既有 jour #### 执行交接与汇合顺序 -| 就绪条件 | 下一动作 | 不授予的权限 | +**本地默认化交付计划(2026-09-14)。** 目标是新建本地 Goal 后,日常 CLI、Turn 和 +操作者动作都通过 TS 拥有的 canonical 事务运行,Markdown 永久作为展示投影。 +“未指定 selector 时选择 File”不等于达成目标:已有 Goal 在明确的整 Goal cutover +之前,仍有 legacy writer。 + +长程默认应选定**一个**合格本地 profile。SQLite 是当前 D2 候选;File 保留为真实 +对照、显式可选 profile 和迁移演练后端。不能发布两个含混的默认项,不能把现有 File +历史布局直接称为长程合格,也不能从选定 SQLite 静默回退。最终选择必须引用 D2 +证据。PostgreSQL 复用 TS 语义合同,但 service、tenant、restore 和 capacity 单独 +资格化;其部署不阻塞本地路线。 + +核对基线:#4286(命令回执/归档)、#4289(typed 工作/归属 intent)、#4292 +(声明式 decision metadata)、#4304(canonical handoff mode)已合并。#4316 +是 Goal Channel observation 候选,#4317 是 provider opening 候选,#4348 是 +canonical renew 候选,#4328 是 SQLite D2 首批测量/恢复候选;它们尚不能算作已 +合并前提或完整执行卡证据。#4334 是独立 PostgreSQL service admission 候选。 +组合前重读实际 head,不能把已合并祖先再次算成新变化。 + +下表编号表示**计划 PR 包**,不是预留 GitHub 编号。可沿真实 effect/兼容边界拆分; +仅换语言或移动 helper 不构成一个包的退出条件。 + +| 波次/PR 包 | 完整交付内容与 TS 归属收益 | 依赖与退出证据 | | --- | --- | --- | -| 当前 refactor stack 已核对 | T1;D1、D2 可独立推进 | 修改默认 provider 或新增通用迁移框架 | -| T1 字段语义闭合 | T2;所需合同就绪后推进 T3 consumer | 同一 Goal 按命令拆分 authority | -| T1–T3、D1/D2 和 capture 均合格 | D3 演练,再请求 promotion 批准 | 跳过 soak、绕过失败证据或自行生产晋升 | -| 批准的 cutover 和 legacy 窗口结束 | T4 完整 writer 退役 | 删除永久 Markdown 展示或 replay 仍需的历史 receipt | - -核对当前 stack 后,预估还需**五到七个完整实现/资格化批次**,不是固定 PR 配额: -T1、T2、T3、D1、D2、D3、T4 仅在依赖、评审和回滚清晰时可同 PR 交付。 -T1 就开始删除重复语义;完整 legacy writer 删除等待 D3/T4。Soak 的真实经过时间 -独立计算,不能靠拆 PR 缩短。 - -每次交接记录精确 base/head、执行卡、实际删除的 caller、authority/可观察语义变化、 -真实 backend 结果、剩余 hold 和一个可执行的下一动作。前序已合入则验证证据后跳过 -重复实现;前提不满足就暂停依赖阶段。不能把“假设合并后”的就绪状态当成自动 -promotion、automation、merge 或 release 授权。 - -当前默认和附录 C promotion hold 均不改变。这份计划不宣称完整 Todo 命令族、长程 -profile 或 shared deployment 已生产就绪。provider 负责 durable CAS/transaction, -不拥有第二份 Todo 状态机。 +| A/L1:Monitor 配置(本切片) | 现有 `todo update` 配置进入 TS planner/CAS/receipt,删除 Python 重复 intent 字段表;区分配置与观察 hash、时间、代数。 | 普通 CLI/API、清除/省略、active lease proof、no-op/replay、展示失败恢复、完整 fixture 和真实 provider。不宣称完成委托 Chat 或 leased polling。 | +| A/L2:公共 mutation admission 闭合 | 盘点 CLI/Turn/Chat 实际 caller;以可信 actor/grant 事实闭合剩余 effect-owned 用户决策、委托 owner 动作和 Monitor lifecycle。 | 复用已合并 T1 owner,不开通通用 raw patch;验证权限拒绝和 caller 响应,删除替代的 Python admission,列全未支持命令。 | +| A/L3:canonical lease 生命周期 | 核对 #4348 renew,继续 transfer/release 及 CLI consumer;复用 typed lease 规则和原子 head/event/receipt。 | 同一 canonical Todo/lease revision;丢回复、旧版本、owner 竞争、过期/释放历史和清理凭据。旧回执 replay 不是新执行权。 | +| B/L4:leased Monitor poll 与 settlement | 组合观察、变化代数、独立 successor 和现有 lease fence;复用 quota settlement 与精确业务回执。 | L2/L3;真实 polling 失败、重复/无变化、业务提交到 quota settlement 间崩溃和并发。不能假装不同 authority 共享一个数据库事务。 | +| B/L5:consumer 与展示闭合 | 核对 #4316,审计 Turn/quota/Dashboard/Chat 的来源,复用 projection outbox 完成 D1 新鲜度和恢复。 | 验证 CLI、Lark/Chat、打包 frontend 的受影响交互;缺失/陈旧展示、权威空状态、pending 投影及超过 UI 上限的数据。逐个删除晋升后的 legacy fallback。 | +| A–C/L6:本地持久化资格 | 延续 contributor 认领的 #4224/#4328,在选定 SQLite profile 上补齐第 7.2 节 ledger,复用 File/NoKV 对照。 | capacity、真实进程/crash/restore/upgrade、历史 receipt/scan、consumer lag、支持的 runtime/OS,以及另行授权的 >=10 天合成 soak。缺项继续 hold。 | +| A–C/L7:capture 连续性 | 修复 #4315:归档的源事务明确退休 lease 引用,bootstrap 与后续 writer 使用一致成员范围;执行 row/mutant 和 mixed-writer/event-source 矩阵。 | 真实 CLI/File capture、保留历史、半完成 drain 不合格、crash/replay,以及归档/rebootstrap 后再申请 lease。不能借 T4 跳过迁移窗口证明。 | +| C/L8:整 Goal 演练与分组迁移 | L2–L7 后汇合一个精确 revision/profile;drain capture、fence 旧 writer、回读 canonical 与投影、演练 fenced export/rollback。 | D3 包绑定 lineage、cursor、source digest、命令覆盖和 profile;已有 Goal 分组迁移需明确批准,不能按命令拆 authority 或复活旧 Markdown。 | +| D/L9:新 Goal 默认与有界退役 | 单独 default-change PR 让新建/onboarding 选择合格本地 profile,配齐 settings/readback、installer 和打包客户端;最后 caller 与迁移窗口退出才删除旧业务 writer。 | L8 整体产品/回滚资格;区分新 Goal 默认和已有 Goal 迁移。发布兼容/停用说明,保留显式 provider、永久 renderer 和合法 import/export。T4 可在默认启用后继续收尾。 | + +**开发节奏以证据推进。** 先核对在途 stack,再按完整操作交付 A;L6/L7 可独立推进。 +B 汇合为完整用户流程,C 形成一次可复现资格检查点,D 用独立 PR 修改默认。此时约 +九个完整包,不是代码行数指标,也不承诺恰好九次 merge。同一 transaction owner +避免并发重写,先共享 fixture/合同,owner 合入后再 rebase。 + +L2/L3 命令盘点与 L6 缺失证据未闭合前,不给虚假的日历承诺。>=10 天 soak 是 +**被测 profile 就绪之后**的真实时间下限,不是从写计划当天计时;明确授权后可与 +兼容工作重叠,涉及持久化语义的后续变化须按影响重新验证。加速 fixture 不能替代 +真实经过时间。本地默认不依赖完整 TS CLI/distribution 清理、删除所有 Python +adapter,也不依赖 PostgreSQL service 部署。 + +每包记录实际 caller/owner 删除、bridge LOC 与退出条件、跨运行时次数、真实后端、 +基线 parity 和公开的语义纠正。单元测试绿、canonical selector 或新增配置字段,均 +不能单独代表默认化就绪。计划中的 integration、soak、release、merge 和生产晋升 +仍分别保留授权边界。 ### 并行交付计划 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index f23304c336..c5caf6badb 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -548,7 +548,7 @@ atomic follow-up are not fully closed. Lease-edit PR #4152 is merged; bounded planning updates now reuse that fence and the existing CAS/receipt transaction. Continue with the remaining field/effect inventory, not another update engine. -Work-requirement editing is now closed for non-Monitor Agent Todos without a +Work-requirement editing was first closed for non-Monitor Agent Todos without a retained lease: `action_kind`, `task_domain`, `task_repository`, `required_write_scopes`, `required_capabilities`, `target_capabilities` and `explore_result_node_refs` use the existing v1 planning transaction. Public @@ -598,6 +598,26 @@ part of T1 without granting approval, lease, completion, or promotion authority. competing revisions, retry and lost-response recovery through the public command and affected real providers. +Monitor configuration now uses the existing native planning transaction as well +as the public legacy planner. A typed authoring codec owns target/cadence/due/ +expiry/watch-only fields; observation hashes, timestamps, effect identities and +generations stay with the polling lifecycle. The Python duplicate field allowlist +and blanket native Monitor exclusion are removed. Configuration preserves +observation history and cannot retarget an already observed Monitor. The lower +import/observation codec retains its callers and is not exposed as a raw update. +Ordinary CLI/API edits, explicit clears, receipt recovery and the existing active +lease proof are covered; owner-confirmed Chat delegation and leased Monitor +polling remain separate incomplete paths. No configuration prose grants authority. + +The local-default program is maintained once in the shared RFC's +[execution sequence](shared-goal-authority-state-provider-v0.md#execution-handoff-and-integration-order). +L1–L4 close mutation semantics before L5 consumer integration; L6/L7 cover +storage and capture; L8 qualifies whole-Goal migration; L9 changes new-Goal +creation defaults. Each package must remove duplicate decisions with its new +owner. A full TS launcher is not required: a bounded Python input/effect adapter +is acceptable while one coarse TS request owns the transaction. Do not turn +these packages into repeated leaf-RPC additions or bypass a retained caller. + **T2 — close monitor writeback and its atomic follow-up.** Bounded prerequisite delivered: `scheduler/monitor_successor.ts` owns successor diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index f1e9890db2..ff5921f317 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -431,7 +431,7 @@ field codec 仍有真实 caller,不引入公开 update 限制。Native metadat T2 原子后续动作尚未全部闭合。Lease-edit PR #4152 已合入;有界规划更新复用该 fence 及既有 CAS/receipt 事务。下一步继续剩余字段/effect 清单,不另建 update engine。 -工作要求编辑现已闭合:没有保留 lease 的非 Monitor Agent Todo,可通过既有 v1 +工作要求编辑首先闭合于没有保留 lease 的非 Monitor Agent Todo,可通过既有 v1 planning 事务更新 `action_kind`、`task_domain`、`task_repository`、 `required_write_scopes`、`required_capabilities`、`target_capabilities` 和 `explore_result_node_refs`。公开 legacy 编辑与 native planning 共用 @@ -467,6 +467,22 @@ metadata 修正不会擦掉保留的 user-gate scope。这闭合的是 T1 的声 metadata 的差异、other-owner/lease 拒绝、no-op、非法输入无写入、竞争 revision、 retry 和丢响应恢复。 +Monitor 配置现通过既有 native planning transaction 和 public legacy planner +共享 typed authoring codec:target/cadence/due/expiry/watch-only 属于配置, +观察 hash、时间、effect identity 和代数仍属于 polling lifecycle。删除 Python +重复字段 allowlist 和 native 对 Monitor 的整体拒绝。配置保留观察历史,已观察的 +Monitor 不允许换 target;底层 import/observation codec 保留真实 caller,不作为 +raw update 开放。普通 CLI/API、显式清除、回执恢复和既有 active lease proof 已覆盖; +owner-confirmed Chat 委托和 leased Monitor polling 仍是独立未闭合路径,配置文本 +不授予权限。 + +本地默认化计划统一维护在 shared RFC 的 +[执行顺序](shared-goal-authority-state-provider-v0.zh-CN.md#执行交接与汇合顺序): +L1–L4 闭合 mutation 语义,L5 汇合 consumer,L6/L7 完成存储与 capture,L8 验证整 +Goal 迁移,L9 修改新 Goal 默认。每包用新的 owner 删除重复决策。无需等待完整 TS +launcher:一个粗粒度 TS 请求拥有完整事务时,有限的 Python 输入/外部 effect +adapter 可保留;不能把执行卡拆成不断新增 leaf RPC,也不能绕过仍在使用的 caller。 + **T2 — 闭合 monitor 写回及原子后续动作。** 已交付有边界前置项:`scheduler/monitor_successor.ts` 统一 quota preflight、legacy diff --git a/docs/reference/monitor-configuration.md b/docs/reference/monitor-configuration.md new file mode 100644 index 0000000000..f7ae201541 --- /dev/null +++ b/docs/reference/monitor-configuration.md @@ -0,0 +1,76 @@ +# Monitor configuration and observations + +`todo update` changes an existing Monitor's configuration. After whole-Goal +promotion it uses the same TS planning/CAS/receipt owner as other supported +Todo updates; it does not read Markdown as authority. Before promotion it +uses the legacy writer and the same typed configuration policy. + +```sh +loopx todo update --goal-id demo --todo-id todo_watch --agent-id agent-a \ + --cadence 2h --next-due-at 2030-01-01T02:00:00Z --dry-run +loopx todo update --goal-id demo --todo-id todo_watch --agent-id agent-a \ + --cadence 2h --next-due-at 2030-01-01T02:00:00Z \ + --update-operation-id monitor-config-1 +loopx todo list --goal-id demo --todo-id todo_watch +``` + +The operation ID in the execute example requires a promoted Goal. Repeating +that ID with the same normalized intent returns its original receipt; changed +intent rejects. Use a new operation ID for a later correction or a reversal. +Dry-run validates without consuming a receipt or repairing display. + +The configuration fields are `target_key`, `cadence`, `next_due_at`, +`expires_at` and `watch_only`. Omitted fields remain unchanged; blank strings +retain the existing omission behavior. Python API callers can explicitly clear +an individual field with `None`, for example replace `watch_only` with a valid +`expires_at` in the same update. A Monitor must retain an expiry, a resume +condition or `watch_only=true`. Clearing its final bound is rejected atomically. +Boolean `watch_only` values and their string spellings share a normalized intent. +Changing cadence computes the next due time from the edit timestamp unless +`next_due_at` is supplied explicitly, preserving the legacy schedule contract. +An expiry-only edit leaves the existing due time unchanged. + +Configuration does not fabricate `result_hash`, `last_checked_at`, +`monitor_effect_id`, no-change counts or material-change generations. These +belong to the observation lifecycle (`quota monitor-poll` / typed +`MonitorPollObservation`), and raw configuration attempts reject. Historical +import/create codecs retain their own source-validation contract. + +Once a Monitor has observation evidence, its target identity cannot be changed +or cleared by configuration. Create a new independent Monitor for a different +target; do not reuse the former target's generations as new evidence. An +unobserved Monitor may correct its target. Repeating the same target is valid. + +Actor, claim, exclusion and lease checks remain mandatory. A leased metadata +edit requires its current active lease key/version via the existing +`--task-lease-idempotency-key` and `--task-lease-expected-version` options; it +neither renews nor releases the lease. Released/expired history grants nothing. +Owner-confirmed Chat delegation is still outside this native update contract; +`authority_reason` text cannot substitute for a validated grant. This slice +therefore completes ordinary CLI/API configuration, not every Chat/Monitor +lifecycle operation or leased polling. + +A committed configuration can report pending Markdown projection delivery. +Retry its original operation to recover the receipt and deliver the current +projection; do not rerun the change under a new identity to repair display. +Provider selection/defaults and whole-Goal promotion remain unchanged. Reverse +a configuration through a fresh validated update, never by editing a stale +Markdown projection or switching off the writer fence. + +## 中文 + +Monitor 配置修改复用 `todo update`。晋升后由 TS 在同一个 canonical revision 上 +校验并提交状态、事件和回执;晋升前保留 legacy writer,复用相同 typed 配置规则。 +上面的 CLI 给出了预览、带 operation ID 的执行和回读;operation ID 只适用于已 +晋升 Goal,修正或撤销使用新 ID,重试同一请求使用原 ID。 + +配置字段仅有 target、频率、下次检查时间、到期时间、watch-only。省略/空白保留 +旧值,Python API 可用单字段 `None` 明确清除;同一次修改必须保留到期、resume +条件或 watch-only 中至少一项。单改频率沿用旧语义,从修改时间计算下次检查;要保留指定时间,显式传入 next_due_at。 +观察 hash、时间、effect ID、无变化次数和变化代数由 observation lifecycle 写入, +普通配置不能伪造。已有观察证据时不能更换/清除 target,新目标应新建独立 Monitor。 + +已有 claim/exclusion/lease 检查继续生效,lease proof 不会因配置而续期。Chat +委托 owner 动作和带 lease 的 polling 尚未闭合,文字理由不能替代可信授权。 +提交成功但展示 pending 时,用原操作重试回执/投影;不能改旧 Markdown 当作回滚。 +本切片不改变 provider 默认,不晋升已有 Goal。 From 89195a8fc600931851e2a7ae5d85b8a0063e31bb Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:33:25 +0800 Subject: [PATCH 3/3] test(monitor): prove observation preservation and the route-identity boundary - Write a real poll observation before comparing preserved fields, so the preservation assertions cannot pass vacuously, and assert the documented schedule contract: an explicit next_due_at is kept, an expiry-only edit keeps the due time, and a cadence-only edit re-derives it from the edit timestamp. - Pin that a Monitor successor keeps target_key as its route identity while cadence, due time, expiry and watch-only still require task_class=continuous_monitor, so a future tightening of the guard cannot silently break Monitor successor routing. - Document the import/create-owned no-change replan threshold and the route-identity boundary on the reference page. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/reference/monitor-configuration.md | 12 ++- .../test_native_monitor_configuration.py | 87 ++++++++++++++++--- 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/docs/reference/monitor-configuration.md b/docs/reference/monitor-configuration.md index f7ae201541..f05ccec898 100644 --- a/docs/reference/monitor-configuration.md +++ b/docs/reference/monitor-configuration.md @@ -34,7 +34,12 @@ Configuration does not fabricate `result_hash`, `last_checked_at`, `monitor_effect_id`, no-change counts or material-change generations. These belong to the observation lifecycle (`quota monitor-poll` / typed `MonitorPollObservation`), and raw configuration attempts reject. Historical -import/create codecs retain their own source-validation contract. +import/create codecs retain their own source-validation contract; the no-change +replan threshold stays one of those import/create-owned fields rather than a +public configuration knob. Mapping identity is not a schedule field: a Monitor +successor may carry `target_key` as its route identity without becoming a +Monitor, while cadence, due time, expiry and watch-only require +`task_class=continuous_monitor`. Once a Monitor has observation evidence, its target identity cannot be changed or cleared by configuration. Create a new independent Monitor for a different @@ -68,7 +73,10 @@ Monitor 配置修改复用 `todo update`。晋升后由 TS 在同一个 canonica 旧值,Python API 可用单字段 `None` 明确清除;同一次修改必须保留到期、resume 条件或 watch-only 中至少一项。单改频率沿用旧语义,从修改时间计算下次检查;要保留指定时间,显式传入 next_due_at。 观察 hash、时间、effect ID、无变化次数和变化代数由 observation lifecycle 写入, -普通配置不能伪造。已有观察证据时不能更换/清除 target,新目标应新建独立 Monitor。 +普通配置不能伪造;无变化重规划阈值仍属于 create/import 合同,不作为公开配置项。 +已有观察证据时不能更换/清除 target,新目标应新建独立 Monitor。target 是路由身份 +而非调度字段:Monitor 后继 Todo 可以只带 target 而不成为 Monitor,频率、到期、 +检查时间和 watch-only 仍要求 task_class=continuous_monitor。 已有 claim/exclusion/lease 检查继续生效,lease proof 不会因配置而续期。Chat 委托 owner 动作和带 lease 的 polling 尚未闭合,文字理由不能替代可信授权。 diff --git a/tests/control_plane/test_native_monitor_configuration.py b/tests/control_plane/test_native_monitor_configuration.py index 0b62141257..c1fcb84db0 100644 --- a/tests/control_plane/test_native_monitor_configuration.py +++ b/tests/control_plane/test_native_monitor_configuration.py @@ -1,13 +1,21 @@ """Monitor configuration uses the public writer and actual local providers.""" from __future__ import annotations +from datetime import datetime, timedelta, timezone + import pytest from canonical_authority_fixture import isolate_sqlite_runtime from test_native_monitor_poll import _canonical from test_monitor_followthrough_contract import _write_fixture, _add_monitor, GOAL_ID, AGENT_ID +from loopx.control_plane.scheduler.monitor_poll_writeback import write_monitor_poll_todo_state from loopx.control_plane.testing.canary_harness import run_json_cli from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted -from loopx.todos import list_goal_todos, update_goal_todo +from loopx.todos import add_goal_todo, list_goal_todos, update_goal_todo + + +OBSERVED_AT = "2030-01-01T00:00:00Z" +OBSERVATION_FIELDS = ("result_hash", "last_checked_at", "monitor_effect_id", + "material_change_generation", "consecutive_no_change") def setup(tmp_path, provider): @@ -18,11 +26,33 @@ def setup(tmp_path, provider): return _canonical(tmp_path, provider=provider) +def _row(registry, todo_id): + todos = list_goal_todos(registry_path=registry, goal_id=GOAL_ID, todo_id=todo_id)["todos"] + assert len(todos) == 1, todos + return todos[0] + + +def _observe(registry, runtime, monitor, result_hash="observed-before-configuration"): + receipt = write_monitor_poll_todo_state(registry_path=registry, runtime_root=runtime, + goal_id=GOAL_ID, execute=True, todo_id=monitor["todo_id"], agent_id=AGENT_ID, + monitor_effect_id="configuration-observation", generated_at=OBSERVED_AT, + result_hash=result_hash, material_change=True) + assert receipt is not None + return receipt + + @pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) def test_configuration_cli_and_clear_preserve_observation(tmp_path, monkeypatch, provider): isolate_sqlite_runtime(tmp_path, monkeypatch) registry, runtime, state, monitor = setup(tmp_path, provider) - before = list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"][0] + # Preserving an observation is only meaningful once one exists: write a real + # poll result first, then edit configuration and compare those fields. + _observe(registry, runtime, monitor) + before = _row(registry, monitor["todo_id"]) + assert before["result_hash"] == "observed-before-configuration" + assert before["monitor_effect_id"] == "configuration-observation" + assert before["last_checked_at"] + assert int(before["material_change_generation"]) >= 1 if provider != "legacy": state.unlink() args = ("todo", "update", "--goal-id", GOAL_ID, "--todo-id", monitor["todo_id"], @@ -32,20 +62,56 @@ def test_configuration_cli_and_clear_preserve_observation(tmp_path, monkeypatch, if provider != "legacy": assert not state.exists() result = run_json_cli(*args, *identity, registry_path=registry, runtime_root=runtime) - current = list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"][0] + current = _row(registry, monitor["todo_id"]) assert current["cadence"] == "2h" - for field in ("result_hash", "material_change_generation", "last_checked_at", "monitor_effect_id", "consecutive_no_change"): - assert current.get(field) == before.get(field) + assert current["next_due_at"] == "2099-01-01T02:00:00Z" + for field in OBSERVATION_FIELDS: + assert current.get(field) == before.get(field), field if provider != "legacy": assert result["source_authority"] == ("file_v0" if provider == "file" else "sqlite_v0") replay = run_json_cli(*args, *identity, registry_path=registry, runtime_root=runtime) assert replay["status"] == "replayed" + # Clearing watch_only needs another retained bound, and an expiry-only edit + # must keep the due time the explicit cadence edit pinned. update_goal_todo(registry_path=registry, runtime_root_arg=str(runtime), goal_id=GOAL_ID, todo_id=monitor["todo_id"], agent_id=AGENT_ID, monitor_metadata={"watch_only": None, "expires_at": "2099-02-01T00:00:00Z"}) - current = list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"][0] - assert not current.get("watch_only") - assert current["expires_at"] == "2099-02-01T00:00:00Z" + expiry_only = _row(registry, monitor["todo_id"]) + assert not expiry_only.get("watch_only") + assert expiry_only["expires_at"] == "2099-02-01T00:00:00Z" + assert expiry_only["next_due_at"] == "2099-01-01T02:00:00Z" + # A cadence-only edit keeps the legacy schedule contract: the due time is + # derived from the edit timestamp, not from the pinned schedule above. + started = datetime.now(timezone.utc) + update_goal_todo(registry_path=registry, runtime_root_arg=str(runtime), goal_id=GOAL_ID, + todo_id=monitor["todo_id"], agent_id=AGENT_ID, monitor_metadata={"cadence": "3h"}) + recadenced = _row(registry, monitor["todo_id"]) + assert recadenced["cadence"] == "3h" + due = datetime.fromisoformat(str(recadenced["next_due_at"]).replace("Z", "+00:00")) + assert started + timedelta(hours=2, minutes=59) <= due + assert due <= datetime.now(timezone.utc) + timedelta(hours=3, minutes=1) + for field in OBSERVATION_FIELDS: + assert recadenced.get(field) == before.get(field), field + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_route_identity_target_key_is_not_schedule_configuration(tmp_path, monkeypatch, provider): + """A Monitor successor keeps its routing target without becoming a Monitor.""" + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, runtime, _state, monitor = setup(tmp_path, provider) + successor = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Validate the observed change", task_class="advancement_task", + action_kind="validate", claimed_by=AGENT_ID, unblocks_todo_id=monitor["todo_id"], + monitor_metadata={"target_key": "public-pr:42:successor"}) + before = _row(registry, successor["todo_id"]) + assert before["task_class"] == "advancement_task" + assert before["target_key"] == "public-pr:42:successor" + for metadata in ({"cadence": "1h"}, {"next_due_at": "2099-01-01T00:00:00Z"}, + {"expires_at": "2099-01-01T00:00:00Z"}, {"watch_only": "true"}): + with pytest.raises((ValueError, RuntimeError)): + update_goal_todo(registry_path=registry, runtime_root_arg=str(runtime), goal_id=GOAL_ID, + todo_id=successor["todo_id"], agent_id=AGENT_ID, monitor_metadata=metadata) + assert _row(registry, successor["todo_id"]) == before @pytest.mark.parametrize("provider", ["file", "sqlite"]) @@ -73,12 +139,9 @@ def unavailable(**kwargs): @pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) def test_observed_target_cannot_be_repurposed_by_configuration(tmp_path, monkeypatch, provider): - from loopx.control_plane.scheduler.monitor_poll_writeback import write_monitor_poll_todo_state isolate_sqlite_runtime(tmp_path, monkeypatch) registry, runtime, _state, monitor = setup(tmp_path, provider) - write_monitor_poll_todo_state(registry_path=registry, runtime_root=runtime, goal_id=GOAL_ID, - execute=True, todo_id=monitor["todo_id"], agent_id=AGENT_ID, monitor_effect_id="observed-target", - generated_at="2030-01-01T00:00:00Z", result_hash="original-target-result", material_change=True) + _observe(registry, runtime, monitor, result_hash="original-target-result") before = list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"] for metadata in ({"target_key": "another-target"}, {"target_key": None}, {"result_hash": "invented"}): with pytest.raises((ValueError, RuntimeError)):