From 0b5b10d3f8dff215db80b91f58ccba77fbdbf560 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:51:23 +0800 Subject: [PATCH 1/3] refactor(coordination): read Goal Channel ownership from canonical snapshots Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../coordination/local_authority_runtime.ts | 20 ++ .../coordination/ownership_observation.ts | 83 +++++++++ .../control_plane/effect_runtime_handlers.ts | 4 + .../goals/coordination_observation.py | 56 ++++++ .../goals/goal_channel_projection.py | 171 ++++-------------- .../renderers/goal_channel_html.py | 5 +- ...est_canonical_goal_channel_coordination.py | 90 +++++++++ .../authority_store_conformance.ts | 2 + .../local_authority_provider.test.ts | 1 + .../ownership_observation.test.ts | 25 +++ .../ownership_observation_conformance.ts | 53 ++++++ 11 files changed, 371 insertions(+), 139 deletions(-) create mode 100644 loopx/control_plane/coordination/ownership_observation.ts create mode 100644 loopx/control_plane/goals/coordination_observation.py create mode 100644 tests/control_plane/test_canonical_goal_channel_coordination.py create mode 100644 tests/control_plane_ts/ownership_observation.test.ts create mode 100644 tests/control_plane_ts/ownership_observation_conformance.ts diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 0e95a7a3d8..4ffa63e2fd 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1,4 +1,5 @@ import {COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA} from "./todo_archive.ts"; +import {readCoordinationOwnership} from "./ownership_observation.ts"; import {executeTodoContinuation} from "./todo_continuation.ts"; import { withFileMutationLock } from "../effect_runtime_io.ts"; import { ShadowManagementError, requireShadowPrimaryWriteAllowed, shadowMaintenanceLockPath } from "./shadow_management.ts"; @@ -1207,3 +1208,22 @@ export async function continueLocalTodo(value: unknown): Promise { ...localAuthorityOpenFailure(error)}; } } + +/** Goal Channel observes a complete provider snapshot through one coarse read. */ +export async function observeLocalCoordinationOwnership(value: unknown): Promise { + let sourceAuthority = "file_v0"; + try { + const input = requireJsonObject(value, "local ownership observation"); + if (input.schema_version !== "loopx_local_ownership_observation_request_v0") throw new Error("ownership observation schema mismatch"); + const root = runtimeRoot(input.runtime_root); + const goalId = requireAuthorityStoreId(input.goal_id, "goal id"); + const store = await openLocalAuthorityStore(root, goalId); + sourceAuthority = sourceAuthorityFor(store); + return {...await readCoordinationOwnership(store, goalId, input.observed_at as string), + source_authority: sourceAuthority, decision_read_from_provider: true, legacy_fallback_used: false}; + } catch (error) { + return {schema_version: "loopx_ownership_observation_result_v0", status: "failed", + reason_code: "coordination_observation_unavailable", source_authority: sourceAuthority, + decision_read_from_provider: true, legacy_fallback_used: false, ...localAuthorityOpenFailure(error)}; + } +} diff --git a/loopx/control_plane/coordination/ownership_observation.ts b/loopx/control_plane/coordination/ownership_observation.ts new file mode 100644 index 0000000000..02bc513d45 --- /dev/null +++ b/loopx/control_plane/coordination/ownership_observation.ts @@ -0,0 +1,83 @@ +/** Read-only ownership observations. These records describe claims/leases, never grant execution. */ +import type {JsonObject} from "../effect_program.ts"; +import type {AuthorityStore} from "./authority_store.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {parseIsoTimestamp} from "../runtime_timestamp.ts"; +import {requireAuthorityStoreId} from "./authority_store_codec.ts"; +import {indexCoordinationProjection, validateCoordinationTodoReadModel} from "./coordination_projection.ts"; +import {leaseEpoch, leaseIsActive, TASK_LEASE_SCHEMA_VERSION} from "../work_items/task_lease_acquire.ts"; + +export const OWNERSHIP_OBSERVATION_SCHEMA = "loopx_ownership_observation_request_v0"; +export const OWNERSHIP_OBSERVATION_RESULT = "loopx_ownership_observation_result_v0"; +export const CANONICAL_OWNERSHIP_DISPLAY_LIMIT = 100; + +type ObservationStatus = "soft_claim" | "hard_lease" | "hard_lease_unreadable"; +function objects(value: unknown, label: string): JsonObject[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + return value.map(item => requireJsonObject(item, label)); +} +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function note(todoId: string): JsonObject { + const status: ObservationStatus = "hard_lease_unreadable"; + return {todo_id: todoId, status, reason: "corrupt_lease"}; +} + +/** Both source adapters share time/generation/conflict rules; explicit [] is authoritative. */ +export function projectOwnershipObservation(value: unknown): JsonObject { + const input = requireJsonObject(value, "ownership observation"); + if (input.schema_version !== OWNERSHIP_OBSERVATION_SCHEMA) throw new Error("ownership observation schema mismatch"); + const at = typeof input.observed_at === "string" ? parseIsoTimestamp(input.observed_at) : null; + if (at === null) throw new Error("observed_at must be a valid timestamp"); + const todos = objects(input.todos, "todos"); + const claims = new Map(todos.map(todo => [text(todo.todo_id), text(todo.claimed_by)])); + const explicit = input.explicit_entries == null ? null : objects(input.explicit_entries, "explicit_entries"); + const entries: JsonObject[] = explicit ?? todos.filter(todo => text(todo.claimed_by)).map(todo => ({ + todo_id: todo.todo_id ?? null, owner_agent: todo.claimed_by!, status: "soft_claim" satisfies ObservationStatus, + })); + for (const row of objects(input.lease_rows, "lease_rows")) { + const todoId = text(row.todo_id); + if (!todoId) throw new Error("lease observation requires a Todo identity"); + if (row.unreadable === true) {entries.push(note(todoId)); continue;} + const lease = row.lease == null ? null : requireJsonObject(row.lease, "lease"); + if (lease === null) continue; + try { + if (lease.schema_version !== TASK_LEASE_SCHEMA_VERSION || lease.todo_id !== todoId) throw new Error("lease identity/schema mismatch"); + if (!leaseIsActive(lease, at)) continue; + const entry: JsonObject = {todo_id: todoId, status: "hard_lease" satisfies ObservationStatus, + lease_epoch: leaseEpoch(lease), expires_at: lease.expires_at!}; + const owner = text(lease.owner); + if (owner) entry.owner_agent = owner; + if (typeof lease.version === "number" && Number.isInteger(lease.version)) entry.lease_version = lease.version; + const claim = claims.get(todoId); + if (owner && claim && owner !== claim) {entry.reason = "owner_conflicts_with_claim"; entry.claimed_by = claim;} + entries.push(entry); + } catch { entries.push(note(todoId)); } + } + return {schema_version: OWNERSHIP_OBSERVATION_RESULT, status: "loaded", entries, + total_count: entries.length, observed_at: input.observed_at!}; +} + +/** One complete, validated revision; no display, local files, receipts or writes. */ +export async function readCoordinationOwnership(store: AuthorityStore, goalId: string, observedAt: string): Promise { + requireAuthorityStoreId(goalId, "goal id"); + const loaded = await store.loadAuthority(); + if (loaded.status !== "loaded") return {schema_version: OWNERSHIP_OBSERVATION_RESULT, ...loaded}; + const index = indexCoordinationProjection(loaded.head, goalId); + validateCoordinationTodoReadModel(loaded.head, goalId); + const todos = [...index.todos.values()].filter(todo => todo.archive_state === "active" && todo.done !== true); + const result = projectOwnershipObservation({schema_version: OWNERSHIP_OBSERVATION_SCHEMA, + observed_at: observedAt, todos, explicit_entries: todos.filter(todo => todo.role === "agent" && text(todo.claimed_by)) + .map(todo => ({todo_id: todo.todo_id, owner_agent: todo.claimed_by!, status: "soft_claim"})), + lease_rows: [...index.leases.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0) + .map(([todo_id, lease]) => ({todo_id, lease})), + }); + // Evaluate the whole snapshot before bounding display; diagnostics are retained first. + const entries = result.entries as JsonObject[]; + const ordered = [...entries.filter(row => row.reason), ...entries.filter(row => !row.reason)]; + return {...result, entries: ordered.slice(0, CANONICAL_OWNERSHIP_DISPLAY_LIMIT), + truncated: entries.length > CANONICAL_OWNERSHIP_DISPLAY_LIMIT, display_limit: CANONICAL_OWNERSHIP_DISPLAY_LIMIT, + todo_count: index.todos.size, lease_count: index.leases.size, + provider_revision: loaded.provider_revision, cursor: loaded.cursor}; +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index d4fcbf13e0..0ff5a2276b 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -1,5 +1,7 @@ import {planHandoffMode} from "./coordination/handoff_mode_policy.ts"; import {setLocalHandoffMode} from "./coordination/handoff_mode_runtime.ts"; +import {projectOwnershipObservation} from "./coordination/ownership_observation.ts"; +import {observeLocalCoordinationOwnership} from "./coordination/local_authority_runtime.ts"; import {evaluateTaskLeaseOwnerEligibility} from "./work_items/task_lease_eligibility.ts"; import { evaluateSubagentContext, describeSubagentContext } from "./subagent_context.ts"; import { @@ -484,6 +486,8 @@ export function createEffectRuntimeHandlers( ["coordination.local_authority.todo_compatibility_edit", editLocalCoordinationTodo], ["coordination.local_authority.mutate", mutateLocalCoordinationAuthority], ["coordination.local_authority.todo_read", readLocalCoordinationTodo], + ["coordination.ownership_observation", projectOwnershipObservation], + ["coordination.local_authority.ownership_observation", observeLocalCoordinationOwnership], ["coordination.local_authority.todo_list", listLocalCoordinationTodos], [ "coordination.local_authority.legacy_writer_fence.engage", diff --git a/loopx/control_plane/goals/coordination_observation.py b/loopx/control_plane/goals/coordination_observation.py new file mode 100644 index 0000000000..5567bb9338 --- /dev/null +++ b/loopx/control_plane/goals/coordination_observation.py @@ -0,0 +1,56 @@ +"""Source adapters for the typed, read-only Goal Channel ownership observation.""" +from pathlib import Path +from typing import Any + +from ..coordination.local_authority import LOCAL_AUTHORITY_SOURCES, local_authority_is_promoted +from ..effect_runtime import effect_runtime_result +from ..runtime.time import now_local_iso + + +def observe_goal_coordination(*, runtime_root: Any, goal_id: str, + agent_todos: list[dict[str, Any]], + explicit_entries: list[dict[str, Any]] | None) -> dict[str, Any]: + canonical = False + try: + root = Path(runtime_root) if runtime_root is not None else None + canonical = root is not None and local_authority_is_promoted(runtime_root=root, goal_id=goal_id) + if canonical: + result = effect_runtime_result('coordination.local_authority.ownership_observation', { + 'schema_version': 'loopx_local_ownership_observation_request_v0', + 'runtime_root': str(root.expanduser().resolve()), 'goal_id': goal_id, + 'observed_at': now_local_iso(), + }) + if (not isinstance(result, dict) or result.get('source_authority') not in LOCAL_AUTHORITY_SOURCES + or result.get('decision_read_from_provider') is not True or result.get('legacy_fallback_used') is not False + or not isinstance(result.get('provider_revision'), str)): + raise RuntimeError('canonical ownership observation unavailable') + else: + from ..work_items.task_lease import TaskLeaseError, read_lease, task_lease_dir + rows = [] + if root is not None: + for path in sorted(task_lease_dir(runtime_root=root, goal_id=goal_id).glob('todo_*.json')): + try: + lease = read_lease(path) + except FileNotFoundError: + continue + except (TaskLeaseError, OSError): + rows.append({'todo_id': path.stem, 'unreadable': True}) + else: + if lease is not None: + rows.append({'todo_id': path.stem, 'lease': lease}) + if not rows and not agent_todos and not explicit_entries: + return {'status': 'loaded', 'entries': []} + result = effect_runtime_result('coordination.ownership_observation', { + 'schema_version': 'loopx_ownership_observation_request_v0', + 'todos': agent_todos, 'lease_rows': rows, 'explicit_entries': explicit_entries, + 'observed_at': now_local_iso(), + }) + if (not isinstance(result, dict) or result.get('schema_version') != 'loopx_ownership_observation_result_v0' + or result.get('status') != 'loaded' or not isinstance(result.get('entries'), list) + or any(not isinstance(row, dict) for row in result['entries'])): + raise RuntimeError('invalid ownership observation') + return result + except (OSError, RuntimeError, TypeError, ValueError): + # Observation failure is visible, but never activates a legacy fallback or leaks provider errors. + return {'status': 'unavailable', 'entries': [], 'legacy_fallback_used': False, + 'source_authority': 'canonical_unavailable' if canonical else 'unavailable'} diff --git a/loopx/control_plane/goals/goal_channel_projection.py b/loopx/control_plane/goals/goal_channel_projection.py index 203f24b5b9..4c303fea6e 100644 --- a/loopx/control_plane/goals/goal_channel_projection.py +++ b/loopx/control_plane/goals/goal_channel_projection.py @@ -255,137 +255,19 @@ def _open_gates( return gates -def _hard_lease_entries( - *, - runtime_root: Any, - goal_id: str, - agent_todos: Sequence[Mapping[str, Any]], -) -> list[dict[str, Any]]: - """Best-effort read of the on-disk hard task-lease store. - - Missing or empty lease directories stay silent because most goals never - use task leases. A corrupt or unreadable lease file degrades to a typed - note instead of failing the status projection. - """ - - if runtime_root is None: - return [] - from pathlib import Path - - from ..work_items.task_lease import ( - TaskLeaseError, - lease_epoch, - lease_is_active, - read_lease, - task_lease_dir, - ) - - try: - lease_dir = task_lease_dir( - runtime_root=Path(runtime_root), - goal_id=str(goal_id), - ) - lease_paths = sorted(lease_dir.glob("todo_*.json")) - except (TaskLeaseError, OSError, TypeError, ValueError): - return [] - if not lease_paths: - return [] - claim_by_todo: dict[str, str] = {} - for item in agent_todos: - todo_id = str(item.get("todo_id") or "").strip() - claimed_by = str(item.get("claimed_by") or "").strip() - if todo_id and claimed_by: - claim_by_todo[todo_id] = claimed_by - entries: list[dict[str, Any]] = [] - for path in lease_paths: - try: - lease = read_lease(path) - except FileNotFoundError: - # Lease released between directory listing and read: nothing left - # to surface, so skip instead of mislabeling it as corrupt. - continue - except (TaskLeaseError, OSError): - entries.append( - { - "todo_id": path.stem, - "status": "hard_lease_unreadable", - "reason": "corrupt_lease", - } - ) - continue - if lease is None or not lease_is_active(lease): - continue - todo_id = _text(lease.get("todo_id"), limit=120) or path.stem - entry: dict[str, Any] = {"todo_id": todo_id, "status": "hard_lease"} - owner = _text(lease.get("owner"), limit=120) - if owner: - entry["owner_agent"] = owner - version = lease.get("version") - if isinstance(version, int): - entry["lease_version"] = version - entry["lease_epoch"] = lease_epoch(lease) - expires_at = _text(lease.get("expires_at"), limit=80) - if expires_at: - entry["expires_at"] = expires_at - claimed_by = claim_by_todo.get(todo_id) - if owner and claimed_by and claimed_by != owner: - entry["reason"] = "owner_conflicts_with_claim" - entry["claimed_by"] = claimed_by - entries.append(entry) - return entries - - -def _active_leases( - *, - active_leases: Sequence[Mapping[str, Any]] | None, - agent_todos: Sequence[Mapping[str, Any]], - runtime_root: Any = None, - goal_id: str = "", -) -> list[dict[str, Any]]: - explicit = _as_mappings(active_leases) - if explicit: - source = explicit - else: - source = [ - { - "todo_id": item.get("todo_id"), - "owner_agent": item.get("claimed_by"), - "status": "soft_claim", - } - for item in agent_todos - if item.get("claimed_by") - ] - compact: list[dict[str, Any]] = [] - for item in source: - todo_id = _text(item.get("todo_id"), limit=120) - owner = _first_text(item.get("owner_agent"), item.get("claimed_by"), limit=120) - if not (todo_id or owner): - continue - lease: dict[str, Any] = {} - if todo_id: - lease["todo_id"] = todo_id - if owner: - lease["owner_agent"] = owner - for key in ("lease_until", "status"): - value = _text(item.get(key), limit=120) - if value: - lease[key] = value - write_scope = item.get("write_scope") - if isinstance(write_scope, list): - lease["write_scope"] = [ - scope - for scope in (_text(value, limit=120) for value in write_scope) - if scope - ] - compact.append(lease) - compact.extend( - _hard_lease_entries( - runtime_root=runtime_root, - goal_id=goal_id, - agent_todos=agent_todos, - ) - ) - return compact +def _compact_coordination_entry(item: Mapping[str, Any]) -> dict[str, Any]: + """The existing channel redaction boundary owns display text, not lease rules.""" + row: dict[str, Any] = {} + for key in ("todo_id", "owner_agent", "claimed_by", "lease_until", "expires_at", "status", "reason"): + value = _text(item.get(key), limit=120) + if value: + row[key] = value + for key in ("lease_version", "lease_epoch"): + if isinstance(item.get(key), int) and not isinstance(item[key], bool): + row[key] = item[key] + if isinstance(item.get("write_scope"), list): + row["write_scope"] = [text for value in item["write_scope"] if (text := _text(value, limit=120))] + return row def _compact_artifacts(artifacts: Sequence[Mapping[str, Any]] | None) -> list[dict[str, Any]]: @@ -468,6 +350,16 @@ def build_goal_channel_projection( project_asset = _project_asset(status_item_dict) user_todos = _compact_todos(project_asset, "user") agent_todos = _compact_todos(project_asset, "agent") + from .coordination_observation import observe_goal_coordination + + explicit = None if active_leases is None else [ + _compact_coordination_entry({**{key: item[key] for key in ("todo_id", "status", "lease_until", "write_scope") if key in item}, + "owner_agent": _first_text(item.get("owner_agent"), item.get("claimed_by"), limit=120)}) + for item in _as_mappings(active_leases) + if _text(item.get("todo_id"), limit=120) or _first_text(item.get("owner_agent"), item.get("claimed_by"), limit=120) + ] + observation = observe_goal_coordination(runtime_root=runtime_root, goal_id=str(goal_id), + agent_todos=agent_todos, explicit_entries=explicit) raw_keys = _raw_material_keys( status_item_dict, status_payload_dict, @@ -541,12 +433,7 @@ def build_goal_channel_projection( user_todos=user_todos, ), "artifacts": _compact_artifacts(artifacts), - "active_leases": _active_leases( - active_leases=active_leases, - agent_todos=agent_todos, - runtime_root=runtime_root, - goal_id=str(goal_id), - ), + "active_leases": [_compact_coordination_entry(row) for row in observation["entries"]], "recent_events": _recent_events(run_history_goal_dict), "source_warnings": _source_warnings(raw_keys), "truth_contract": { @@ -559,4 +446,14 @@ def build_goal_channel_projection( ), }, } + if "source_authority" in observation: + projection["coordination_observation"] = {key: observation[key] for key in ( + "status", "source_authority", "provider_revision", "observed_at", "legacy_fallback_used", + "total_count", "truncated", "display_limit", "todo_count", "lease_count") if key in observation} + if observation["status"] != "loaded": + projection["source_warnings"].append({"kind": "coordination_unavailable", + "message": "Task ownership could not be read; an empty list does not prove that no task is owned."}) + elif observation.get("truncated") is True: + projection["source_warnings"].append({"kind": "coordination_truncated", + "message": "Ownership display is limited to 100 entries; diagnostics are shown first. Read the full task/lease state before acting."}) return {key: value for key, value in projection.items() if value is not None} diff --git a/loopx/presentation/renderers/goal_channel_html.py b/loopx/presentation/renderers/goal_channel_html.py index 9500f3b1e6..560791194f 100644 --- a/loopx/presentation/renderers/goal_channel_html.py +++ b/loopx/presentation/renderers/goal_channel_html.py @@ -114,6 +114,7 @@ def render_goal_channel_projection_html(projection: Mapping[str, Any]) -> str: open_gates = _as_mappings(projection.get("open_gates")) artifacts = _as_mappings(projection.get("artifacts")) active_leases = _as_mappings(projection.get("active_leases")) + ownership_unavailable = _as_mapping(projection.get("coordination_observation")).get("status") == "unavailable" recent_events = _as_mappings(projection.get("recent_events")) source_warnings = _as_mappings(projection.get("source_warnings")) @@ -172,8 +173,8 @@ def render_goal_channel_projection_html(projection: Mapping[str, Any]) -> str: "reason", "claimed_by", ), - empty="No active claim or lease projected.", - tone="green", + empty="Task ownership is unavailable; see Source Warnings." if ownership_unavailable else "No active claim or lease projected.", + tone="red" if ownership_unavailable else "green", ), _html_item_panel( "artifacts", diff --git a/tests/control_plane/test_canonical_goal_channel_coordination.py b/tests/control_plane/test_canonical_goal_channel_coordination.py new file mode 100644 index 0000000000..048f64a546 --- /dev/null +++ b/tests/control_plane/test_canonical_goal_channel_coordination.py @@ -0,0 +1,90 @@ +"""Canonical ownership is authoritative even when the display/local files disagree.""" +import json + +import pytest + +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.goals.goal_channel_projection import build_goal_channel_projection +from test_goal_channel_hard_lease_visibility import GOAL_ID, _status_item, _write_active_lease + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_empty_canonical_ownership_never_revives_display_claim_or_local_lease(tmp_path, monkeypatch, provider): + if provider == 'sqlite': + isolate_sqlite_runtime(tmp_path, monkeypatch) + state = tmp_path / 'state.md' + state.write_text('---\nhandoff_mode: legacy\n---\n\n## Agent Todo\n') + _write_active_lease(tmp_path, owner='stale-agent') + projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, handoff_mode='soft_claim', todos=[]) + initialize_canonical_authority(tmp_path, GOAL_ID, projection, state_path=state, provider=provider) + before = state.read_bytes() + observed = build_goal_channel_projection(goal_id=GOAL_ID, status_item=_status_item(claimed_by='stale-agent'), runtime_root=tmp_path) + assert observed['active_leases'] == [] + assert observed['coordination_observation']['source_authority'] == provider + '_v0' + assert state.read_bytes() == before + state.unlink() + assert build_goal_channel_projection(goal_id=GOAL_ID, runtime_root=tmp_path)['active_leases'] == [] + + +def test_explicit_empty_observation_is_not_unspecified(): + observed = build_goal_channel_projection(goal_id=GOAL_ID, status_item=_status_item(claimed_by='stale-agent'), active_leases=[]) + assert observed['active_leases'] == [] + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_canonical_failure_is_visible_and_never_discloses_or_falls_back(tmp_path, monkeypatch, provider): + from loopx.control_plane.goals import coordination_observation as adapter + from loopx.presentation.renderers.goal_channel_html import render_goal_channel_projection_html + if provider == 'sqlite': + isolate_sqlite_runtime(tmp_path, monkeypatch) + state = tmp_path / 'state.md' + state.write_text('## Agent Todo\n') + projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, handoff_mode='legacy', todos=[]) + initialize_canonical_authority(tmp_path, GOAL_ID, projection, state_path=state, provider=provider) + _write_active_lease(tmp_path, owner='stale-agent') + def unavailable(*_args): + raise RuntimeError('PRIVATE_BACKEND_PATH_AND_CREDENTIAL') + monkeypatch.setattr(adapter, 'effect_runtime_result', unavailable) + observed = build_goal_channel_projection(goal_id=GOAL_ID, status_item=_status_item(claimed_by='stale-agent'), runtime_root=tmp_path) + assert observed['active_leases'] == [] + assert observed['coordination_observation']['status'] == 'unavailable' + assert observed['source_warnings'][-1]['kind'] == 'coordination_unavailable' + html = render_goal_channel_projection_html(observed) + assert 'PRIVATE_' not in html and 'PRIVATE_' not in json.dumps(observed) + assert 'Task ownership could not be read' in html + assert 'Task ownership is unavailable; see Source Warnings.' in html + assert 'No active claim or lease projected.' not in html + + +@pytest.mark.parametrize('provider', ['file', 'sqlite']) +def test_real_cli_export_uses_canonical_ownership(tmp_path, monkeypatch, provider): + import subprocess + import sys + if provider == 'sqlite': + isolate_sqlite_runtime(tmp_path, monkeypatch) + from test_goal_channel_hard_lease_visibility import _write_status_workspace + registry, repo, runtime = _write_status_workspace(tmp_path) + _write_active_lease(runtime, owner='stale-agent') + state = repo / 'ACTIVE_GOAL_STATE.md' + projection = build_todo_runtime_shadow_projection(goal_id=GOAL_ID, handoff_mode='legacy', todos=[]) + initialize_canonical_authority(runtime, GOAL_ID, projection, state_path=state, provider=provider) + before = state.read_bytes() + result = subprocess.run([sys.executable, '-m', 'loopx.cli', '--registry', str(registry), '--format', 'json', + 'status', '--goal-id', GOAL_ID], capture_output=True, text=True, timeout=90) + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + def channels(value): + if isinstance(value, dict): + if 'goal_channel_projection' in value: + yield value['goal_channel_projection'] + for child in value.values(): + yield from channels(child) + elif isinstance(value, list): + for child in value: + yield from channels(child) + found = list(channels(payload)) + assert found, payload.keys() + assert all(item['active_leases'] == [] for item in found) + assert all(item['coordination_observation']['source_authority'] == provider + '_v0' for item in found) + assert state.read_bytes() == before diff --git a/tests/control_plane_ts/authority_store_conformance.ts b/tests/control_plane_ts/authority_store_conformance.ts index 053d825401..f1bd232c44 100644 --- a/tests/control_plane_ts/authority_store_conformance.ts +++ b/tests/control_plane_ts/authority_store_conformance.ts @@ -1,6 +1,7 @@ import {registerAuthorityScanConformance} from "./authority_scan_conformance.ts"; import {executeCoordinationTodoArchiveCompleted} from "../../loopx/control_plane/coordination/todo_archive.ts"; import {registerHandoffModeConformance} from "./handoff_mode_conformance.ts"; +import {registerOwnershipObservationConformance} from "./ownership_observation_conformance.ts"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import test from "node:test"; @@ -208,6 +209,7 @@ export function registerAuthorityStoreConformance( factory: AuthorityStoreConformanceFactory, ): void { registerAuthorityScanConformance(providerName, factory); + registerOwnershipObservationConformance(providerName, factory); registerNativePlanningUpdateConformance(providerName, factory); registerCoordinationReceiptConformance(providerName, factory); registerHandoffModeConformance(providerName, factory); diff --git a/tests/control_plane_ts/local_authority_provider.test.ts b/tests/control_plane_ts/local_authority_provider.test.ts index e2ab5ec459..6d692bebcd 100644 --- a/tests/control_plane_ts/local_authority_provider.test.ts +++ b/tests/control_plane_ts/local_authority_provider.test.ts @@ -98,6 +98,7 @@ function providerCalls(directory: string, revision: string, dryRun: boolean) { (value: unknown) => Promise ? K : never}[keyof typeof runtime]; // A new exported runtime action must deliberately enter this failure matrix. const requests = { + observeLocalCoordinationOwnership: [{...input, schema_version: "loopx_local_ownership_observation_request_v0"}], listLocalCoordinationTodos: [{...input, schema_version: runtime.LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA}], readLocalCoordinationTodo: [{...input, schema_version: runtime.LOCAL_COORDINATION_TODO_READ_REQUEST_SCHEMA}], mutateLocalCoordinationAuthority: [{...input, schema_version: runtime.LOCAL_COORDINATION_MUTATION_REQUEST_SCHEMA, diff --git a/tests/control_plane_ts/ownership_observation.test.ts b/tests/control_plane_ts/ownership_observation.test.ts new file mode 100644 index 0000000000..964ae77595 --- /dev/null +++ b/tests/control_plane_ts/ownership_observation.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {projectOwnershipObservation, OWNERSHIP_OBSERVATION_SCHEMA} from "../../loopx/control_plane/coordination/ownership_observation.ts"; +const at = "2026-09-13T00:00:00Z"; +const request = {schema_version: OWNERSHIP_OBSERVATION_SCHEMA, observed_at: at, + todos: [{todo_id: "todo-a", claimed_by: "agent-a"}], explicit_entries: null, lease_rows: []}; +const lease = {schema_version: "task_lease_v0", todo_id: "todo-a", owner: "agent-b", + status: "active", expires_at: "2027-01-01T00:00:00Z", version: 2}; + +test("explicit empty differs from absent observation; source inputs are not mutated", () => { + assert.equal((projectOwnershipObservation(request).entries as unknown[]).length, 1); + const input = {...request, explicit_entries: []}; + assert.deepEqual(projectOwnershipObservation(input).entries, []); + assert.deepEqual(input.explicit_entries, []); +}); +for (const patch of [{expires_at: "invalid"}, {schema_version: "unknown"}, {lease_epoch: true}, {todo_id: "wrong-id"}]) { + test(`invalid lease observation is visible and never crashes the channel: ${JSON.stringify(patch)}`, () => { + const result = projectOwnershipObservation({...request, lease_rows: [{todo_id: "todo-a", lease: {...lease, ...patch}}]}); + assert.deepEqual((result.entries as unknown[])[1], {todo_id: "todo-a", status: "hard_lease_unreadable", reason: "corrupt_lease"}); + }); +} +test("all leases use the same observation time; exact expiry is expired", () => { + assert.equal((projectOwnershipObservation({...request, lease_rows: [{todo_id: "todo-a", lease: {...lease, expires_at: at}}]}).entries as unknown[]).length, 1); + assert.throws(() => projectOwnershipObservation({...request, observed_at: "invalid"})); +}); diff --git a/tests/control_plane_ts/ownership_observation_conformance.ts b/tests/control_plane_ts/ownership_observation_conformance.ts new file mode 100644 index 0000000000..196c89a64e --- /dev/null +++ b/tests/control_plane_ts/ownership_observation_conformance.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {readCoordinationOwnership} from "../../loopx/control_plane/coordination/ownership_observation.ts"; +import type {AuthorityStoreConformanceFactory} from "./authority_store_conformance.ts"; +import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; + +const at = "2026-09-13T00:00:00Z"; +export function registerOwnershipObservationConformance(provider: string, factory: AuthorityStoreConformanceFactory) { + for (const scenario of ["empty", "full_conflict", "bounded", "malformed_lease"] as const) { + test(`${provider}: readonly ownership observation ${scenario}`, async t => { + const {store} = await factory(t); + const goal = "ownership-goal"; + const projection = productionScaleCoordinationFixture(goal).projection; + const todos = projection.todos as JsonObject[]; + for (const todo of todos) delete todo.claimed_by; + const target = [...todos].reverse().find(todo => todo.role === "agent" && todo.done !== true)!; + const lease = {...(projection.leases as JsonObject[])[0]!, todo_id: target.todo_id, + status: "active", owner: "agent-a", expires_at: "2027-01-01T00:00:00Z", + private_diagnostic: "PRIVATE_LEASE_PAYLOAD", idempotency_key: "PRIVATE_OPERATION_KEY"}; + if (scenario !== "empty") target.claimed_by = "agent-b"; + if (scenario === "bounded") for (const todo of todos) if (todo.role === "agent" && todo.done !== true) todo.claimed_by = "agent-b"; + if (scenario === "malformed_lease") lease.expires_at = "invalid"; + projection.leases = scenario === "empty" ? [] : scenario === "bounded" + ? [...todos.slice(0, 140).map(todo => ({...lease, todo_id: todo.todo_id})), lease] + : [lease]; + (projection.todo_read_model as JsonObject).records_sha256 = canonicalAuthoritySha256(todos); + assert.equal((await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + next_projection: projection, events: [], receipts: []})).status, "applied"); + const before = await store.loadAuthority(); + const result = await readCoordinationOwnership(store, goal, at); + assert.equal(result.status, "loaded"); + assert.equal(result.todo_count, 464); + const entries = result.entries as JsonObject[]; + assert.equal(JSON.stringify(result).includes("PRIVATE_"), false); + if (scenario === "empty") assert.deepEqual(entries, []); + if (scenario === "full_conflict") { + assert.equal(entries[0]!.reason, "owner_conflicts_with_claim"); + assert.equal(entries[0]!.claimed_by, "agent-b"); + assert.equal(entries[0]!.todo_id, target.todo_id); + } + if (scenario === "bounded") { + assert.equal(entries.length, 100); assert.equal(result.truncated, true); + assert.ok(Number(result.total_count) > 100); + assert.equal(entries[0]!.reason, "owner_conflicts_with_claim"); + } + if (scenario === "malformed_lease") assert.equal(entries[0]!.status, "hard_lease_unreadable"); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await store.readReceipt("observation")).status, "missing"); + }); + } +} From 5fb5b09314083104f238d6730dfe6b411ee17895 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:51:35 +0800 Subject: [PATCH 2/3] docs(coordination): explain ownership source completeness and failures Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...shared-goal-authority-state-provider-v0.md | 2 + ...-goal-authority-state-provider-v0.zh-CN.md | 2 + .../typescript-control-plane-migration-v0.md | 4 +- ...script-control-plane-migration-v0.zh-CN.md | 6 +- .../coordination-observation-before.png | Bin 0 -> 16516 bytes .../coordination-observation-unavailable.png | Bin 0 -> 8999 bytes docs/reference/coordination-observation.md | 87 ++++++++++++++++++ 7 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 docs/assets/coordination-observation-before.png create mode 100644 docs/assets/coordination-observation-unavailable.png create mode 100644 docs/reference/coordination-observation.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 db04967bd4..9bd00f5637 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2889,6 +2889,8 @@ source paths, authorize monitor writeback, or change provider/promotion holds. **D1 — qualify permanent projection delivery; may overlap T1/T2.** +The Goal Channel ownership observation consumes one complete provider revision before bounding display. It never repairs Markdown or revives old local leases; provider failures and truncation stay visible. This is a T3 read closure with shared TS interpretation, not D1/D2 qualification or D3 cutover. See [coordination observation](../../reference/coordination-observation.md). + The D1 document-ownership slice gives readers, editors and projection one visible-region and Todo-block boundary. It fixes fenced examples becoming real tasks, narrative after an archive end marker entering history, and sparse imported ordinals or archived 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 9fd0058ef9..7dfb761310 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 @@ -2286,6 +2286,8 @@ Scoped fallback 的选择与门禁关系也已复用同一 TS decision owner, **D1 — 资格化永久投影交付,可与 T1/T2 重叠推进。** +Goal Channel 所有权观察先读取完整 provider revision,再限制展示;不修复 Markdown、不复活旧本地 lease,明确披露失败与截断。这是共用 TS 解释规则的 T3 读链路闭合,不完成 D1/D2 或 D3 切换,见 [coordination observation](../../reference/coordination-observation.md)。 + D1 的文档归属切片把读取、编辑与投影放到同一可见区域/Todo 行解码边界,修复 fenced 示例被当成真实任务、归档 end marker 后叙述进入历史、稀疏历史行号及归档 优先级阻塞读回的问题。投影复用普通状态的耐久原子写入;相同字节的重试仍完成 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index a5fadfbdf7..795df45452 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -631,6 +631,8 @@ delivery. This does not finish all T2 commands or authorize whole-Goal promotion **T3 — close remaining structured consumers, then remove their old reads.** +Goal Channel ownership observation now reads a complete canonical Todo/lease revision and shares one TS batch policy with the legacy adapter. It retires display-layer lease time/generation/conflict decisions and local-file reads after promotion. Empty, unavailable and truncated observations remain distinct; see [coordination observation](../../reference/coordination-observation.md). This closes the Goal Channel ownership reader, not other channel panels or whole-Goal promotion. + The D1 document-ownership slice gives readers, editors and projection one visible-region and Todo-block boundary. It fixes fenced examples becoming real tasks, narrative after an archive end marker entering history, and sparse imported ordinals or archived @@ -664,7 +666,7 @@ derived inside acquire from the supplied owner/claim/exclusion/registration fact not from the old caller-provided `effective` hint. Other-Todo overlap facts still come from the existing complete execution snapshot; release retains its separate key/version cleanup fence. This closes one T3 reader and shared rule boundary, -not the remaining Goal-channel lease display, T1/T2 transactions or promotion. +not the remaining T1/T2 transactions or promotion. Goal Channel ownership display closes in the separate ownership-observation slice. Capability resolution now shares `agents/capability_gate.ts`: missing prerequisites, repair outputs, owner/agent resolution and blocked-Todo bindings have one typed 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 0ce2a496eb..cbe2a8d7e7 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 @@ -491,6 +491,8 @@ promotion 已完成。 **T3 — 闭合剩余 structured consumer,删除各自旧读路径。** +Goal Channel 所有权观察现从完整 canonical Todo/lease revision 读取,并与 legacy adapter 共用 TS 批量规则;删除展示层的时间/代数/冲突判断和晋升后的本地文件读路径。空值、不可用与截断分别披露,见 [coordination observation](../../reference/coordination-observation.md)。这只闭合所有权观察 reader,不宣称其余面板或整 Goal 晋升完成。 + D1 的文档归属切片把读取、编辑与投影放到同一可见区域/Todo 行解码边界,修复 fenced 示例被当成真实任务、归档 end marker 后叙述进入历史、稀疏历史行号及归档 优先级阻塞读回的问题。投影复用普通状态的耐久原子写入;相同字节的重试仍完成 @@ -515,8 +517,8 @@ handoff mode;canonical 无租约不复活本地旧文件,provider 失败不 供 acquire、lifecycle 与终态 fence 复用。当前租约是否有效由 acquire 内部根据同一输入 的 owner/claim/exclusion/注册事实推导,不再由旧 `effective` 派生提示覆盖。 其他 Todo 的 scope 冲突仍消费现有完整执行快照;release 保留独立的 key/version -清理门禁。这是一个 T3 reader 与共享规则边界的闭合,不代表 Goal-channel lease -展示、T1/T2 全部事务或 promotion 已完成。 +清理门禁。这是一个 T3 reader 与共享规则边界的闭合,不代表 T1/T2 全部事务或 +promotion 已完成;Goal Channel 所有权展示由独立的 observation 切片闭合。 Quota 的 scope/claim 消费者现通过每个 source 一次 `todo.quota_planning.project`, 组合选择、有限展示与既有 resume planner。`quota_selection.ts` 替代 Python diff --git a/docs/assets/coordination-observation-before.png b/docs/assets/coordination-observation-before.png new file mode 100644 index 0000000000000000000000000000000000000000..c9cd7e6bd957e02402da550d1abd6234dfe4ba59 GIT binary patch literal 16516 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/assets/coordination-observation-unavailable.png b/docs/assets/coordination-observation-unavailable.png new file mode 100644 index 0000000000000000000000000000000000000000..b4c623aa2b088c8ecd6c1a21cd40f948952557c2 GIT binary patch literal 8999 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 A;s5{u literal 0 HcmV?d00001 diff --git a/docs/reference/coordination-observation.md b/docs/reference/coordination-observation.md new file mode 100644 index 0000000000..6366e9ca35 --- /dev/null +++ b/docs/reference/coordination-observation.md @@ -0,0 +1,87 @@ +# Goal Channel coordination observation + +Goal Channel's `active_leases` is a read-only display of Todo claims and +time-active lease records. A displayed lease is not an execution grant: actual +mutation still checks the owning operation's eligibility, mode and lease fence. + +```bash +loopx status --goal-id example-goal --format json +``` + +Inspect `goal_channel_projection.active_leases` and its `source_warnings` in the +status/attention item. No new capability activation or provider selection is +required. Existing Goal Channel HTML renders these rows and warnings; this read +never sends a message or changes channel bindings. + +Before canonical promotion, the source remains the supplied status Todo view +plus local lease files. Python adapts file records; one TS request evaluates +expiry, lease generation and claim conflicts for the whole batch. The legacy +Todo view can be incomplete and does not acquire canonical completeness by using +the shared rule. An explicitly supplied empty `active_leases=[]` does not cause +soft claims to be inferred; local hard-lease observation remains independent. + +After promotion, the selected provider supplies the complete Todo/lease snapshot +in one read. Stale or absent Markdown, legacy lease files and caller-supplied +claim/lease display overrides cannot replace canonical ownership facts. An empty +canonical result stays empty. Claims of completed/archived Todos do not re-enter +the active claim display. Canonical claim/lease conflict comparison happens +before output limits or text redaction. + +`coordination_observation` records the provider source/revision, observation time, +record counts, total observations, display limit and truncation. These fields +apply only to the ownership observation; other Goal Channel panels remain their +existing status/quota/history projections and need not share that revision. +The canonical display limit is 100 entries, after evaluating the complete source; +corrupt-lease and conflict diagnostics come first. A truncation warning means the +visible rows are not a complete work inventory. + +All leases use one observation time. Expiry equal to that time is expired. +Malformed active expiry, unknown schema, mismatched lease identity and invalid +generation produce `hard_lease_unreadable` / `corrupt_lease` observations instead +of silently disappearing or crashing the channel. A provider/protocol failure +produces an empty ownership list with `coordination_unavailable`; that empty list +is **not evidence of no ownership**. Raw errors, lease operation keys, write +scopes and arbitrary backend metadata are not copied into canonical display. +Existing channel text redaction still applies. + +The read performs no business mutation, receipt creation, Markdown repair, +promotion or fallback write. It does not change provider defaults or qualify a +PostgreSQL CLI selector, whole-Goal cutover or long-duration SQLite storage. +Disable/rollback follows the existing provider lifecycle; do not revive stale +local files to bypass an unavailable canonical source. + +## Recognize an unavailable source + +The same synthetic unavailable canonical source and stale local lease, rendered +by the pinned legacy implementation (left) and the provider-aware reader (right). +The corrected panel explicitly reports an unavailable observation rather than +presenting the local lease as current ownership. Source Warnings carries the +additional explanation. Desktop and mobile layouts use the existing renderer. + +| Legacy display | Provider-aware display | +| --- | --- | +| ![Stale local lease shown as ownership](../assets/coordination-observation-before.png) | ![Canonical ownership unavailable](../assets/coordination-observation-unavailable.png) | + +## 中文 + +Goal Channel 的 `active_leases` 展示认领与时间上有效的 lease 记录,不授予执行权; +实际修改仍受相应操作的资格、mode 和 lease 门禁约束。上面的 status 命令可读取 +现有 Goal Channel/attention 投影,不新增 activation,也不发送消息。 + +晋升前沿用传入的状态 Todo 视图与本地 lease 文件,由同一 TS 批量规则计算时间、 +代数和认领冲突;旧 Todo 视图仍可能不完整。明确传入空列表不再补出 soft claim, +本地 hard lease 仍独立观察。 + +晋升后从选定 provider 的完整同一 revision 读取所有权事实。陈旧/缺失 Markdown、 +旧 lease 文件或调用方的展示覆盖值不再成为事实来源;canonical 空值保持为空, +完成/归档 Todo 的认领不进入活动认领展示。先判断完整数据中的冲突,再脱敏与截断。 + +`coordination_observation` 披露来源、版本、观察时间、总数和截断情况,只描述 +所有权这一组数据,不声称整张 Goal Channel 的所有面板来自同一 revision。 +最多展示 100 条,异常/冲突优先;截断提示意味着不能把可见列表当作完整工作清单。 +所有 lease 使用同一个观察时间,到期时间恰好相等视为过期。损坏记录显示不可读提示; +provider 失败显示 `coordination_unavailable`,不会回退本地文件,也不把空列表说成无人负责。 +原始错误、操作密钥和任意后端字段不进入展示,现有文本脱敏继续生效。 + +这是一条只读链路,不修改权威状态、回执或 Markdown,不改变默认 provider,也不 +完成 SQLite 长程资格化、PostgreSQL CLI 选择入口或整 Goal 切换。 From 412126b6f147c9011a7e636cc87efb8692dbd652 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:04:45 +0800 Subject: [PATCH 3/3] fix(coordination): harden ownership observation redaction Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../coordination/local_authority_runtime.ts | 2 +- .../coordination/ownership_observation.ts | 16 ++++++++++++++-- .../goals/coordination_observation.py | 12 ++++++++---- .../ownership_observation.test.ts | 7 +++++++ 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 4ffa63e2fd..bc16684945 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1211,7 +1211,7 @@ export async function continueLocalTodo(value: unknown): Promise { /** Goal Channel observes a complete provider snapshot through one coarse read. */ export async function observeLocalCoordinationOwnership(value: unknown): Promise { - let sourceAuthority = "file_v0"; + let sourceAuthority = "canonical_unavailable"; try { const input = requireJsonObject(value, "local ownership observation"); if (input.schema_version !== "loopx_local_ownership_observation_request_v0") throw new Error("ownership observation schema mismatch"); diff --git a/loopx/control_plane/coordination/ownership_observation.ts b/loopx/control_plane/coordination/ownership_observation.ts index 02bc513d45..885d0943cb 100644 --- a/loopx/control_plane/coordination/ownership_observation.ts +++ b/loopx/control_plane/coordination/ownership_observation.ts @@ -24,6 +24,18 @@ function note(todoId: string): JsonObject { return {todo_id: todoId, status, reason: "corrupt_lease"}; } +function displayEntry(item: JsonObject): JsonObject { + const entry: JsonObject = {}; + for (const key of ["todo_id", "owner_agent", "claimed_by", "lease_until", "expires_at", "status", "reason"]) { + const value = text(item[key]); + if (value) entry[key] = value; + } + for (const key of ["lease_version", "lease_epoch"]) { + if (typeof item[key] === "number" && Number.isSafeInteger(item[key])) entry[key] = item[key]; + } + return entry; +} + /** Both source adapters share time/generation/conflict rules; explicit [] is authoritative. */ export function projectOwnershipObservation(value: unknown): JsonObject { const input = requireJsonObject(value, "ownership observation"); @@ -33,9 +45,9 @@ export function projectOwnershipObservation(value: unknown): JsonObject { const todos = objects(input.todos, "todos"); const claims = new Map(todos.map(todo => [text(todo.todo_id), text(todo.claimed_by)])); const explicit = input.explicit_entries == null ? null : objects(input.explicit_entries, "explicit_entries"); - const entries: JsonObject[] = explicit ?? todos.filter(todo => text(todo.claimed_by)).map(todo => ({ + const entries: JsonObject[] = explicit === null ? todos.filter(todo => text(todo.claimed_by)).map(todo => ({ todo_id: todo.todo_id ?? null, owner_agent: todo.claimed_by!, status: "soft_claim" satisfies ObservationStatus, - })); + })) : explicit.map(displayEntry).filter(entry => text(entry.todo_id) || text(entry.owner_agent) || text(entry.claimed_by)); for (const row of objects(input.lease_rows, "lease_rows")) { const todoId = text(row.todo_id); if (!todoId) throw new Error("lease observation requires a Todo identity"); diff --git a/loopx/control_plane/goals/coordination_observation.py b/loopx/control_plane/goals/coordination_observation.py index 5567bb9338..76322575fb 100644 --- a/loopx/control_plane/goals/coordination_observation.py +++ b/loopx/control_plane/goals/coordination_observation.py @@ -13,8 +13,10 @@ def observe_goal_coordination(*, runtime_root: Any, goal_id: str, canonical = False try: root = Path(runtime_root) if runtime_root is not None else None - canonical = root is not None and local_authority_is_promoted(runtime_root=root, goal_id=goal_id) + if root is not None: + canonical = local_authority_is_promoted(runtime_root=root, goal_id=goal_id) if canonical: + assert root is not None result = effect_runtime_result('coordination.local_authority.ownership_observation', { 'schema_version': 'loopx_local_ownership_observation_request_v0', 'runtime_root': str(root.expanduser().resolve()), 'goal_id': goal_id, @@ -25,12 +27,14 @@ def observe_goal_coordination(*, runtime_root: Any, goal_id: str, or not isinstance(result.get('provider_revision'), str)): raise RuntimeError('canonical ownership observation unavailable') else: - from ..work_items.task_lease import TaskLeaseError, read_lease, task_lease_dir + from ..work_items.local_lease_record import TaskLeaseError + from ..work_items import task_lease as task_lease_module rows = [] if root is not None: - for path in sorted(task_lease_dir(runtime_root=root, goal_id=goal_id).glob('todo_*.json')): + for path in sorted(task_lease_module.task_lease_dir(runtime_root=root, goal_id=goal_id).glob('todo_*.json')): try: - lease = read_lease(path) + # task_lease re-exports this seam and existing callers/tests patch it there. + lease = task_lease_module.read_lease(path) # type: ignore[attr-defined] except FileNotFoundError: continue except (TaskLeaseError, OSError): diff --git a/tests/control_plane_ts/ownership_observation.test.ts b/tests/control_plane_ts/ownership_observation.test.ts index 964ae77595..18271eb9fa 100644 --- a/tests/control_plane_ts/ownership_observation.test.ts +++ b/tests/control_plane_ts/ownership_observation.test.ts @@ -13,6 +13,13 @@ test("explicit empty differs from absent observation; source inputs are not muta assert.deepEqual(projectOwnershipObservation(input).entries, []); assert.deepEqual(input.explicit_entries, []); }); +test("explicit entries keep only the public ownership display fields", () => { + const result = projectOwnershipObservation({...request, explicit_entries: [{ + todo_id: "todo-a", owner_agent: "agent-a", status: "hard_lease", lease_epoch: 2, + write_scopes: ["private/**"], idempotency_key: "PRIVATE_OPERATION_KEY", private_backend: "PRIVATE_BACKEND", + }]}); + assert.deepEqual(result.entries, [{todo_id: "todo-a", owner_agent: "agent-a", status: "hard_lease", lease_epoch: 2}]); +}); for (const patch of [{expires_at: "invalid"}, {schema_version: "unknown"}, {lease_epoch: true}, {todo_id: "wrong-id"}]) { test(`invalid lease observation is visible and never crashes the channel: ${JSON.stringify(patch)}`, () => { const result = projectOwnershipObservation({...request, lease_rows: [{todo_id: "todo-a", lease: {...lease, ...patch}}]});