From 2b7c59cdfd98b69c196b5263f3c643d327e6b549 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:56:12 +0800 Subject: [PATCH] fix(chat): identify unavailable owner-notification sources Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../smoke/owner-attention-source-fixture.py | 15 ++++++- apps/presentation/dashboard/src/data/chat.ts | 2 + .../private-conversation-panel.tsx | 8 ++++ docs/product/periodic-reports.md | 11 +++++ .../presentation/owner_attention.ts | 32 ++++++++++++++- loopx/extensions/lark/owner_attention.py | 11 +++-- .../control_plane_ts/owner_attention.test.ts | 40 +++++++++++++++++++ tests/test_lark_owner_attention.py | 19 +++++++++ 8 files changed, 133 insertions(+), 5 deletions(-) diff --git a/apps/presentation/dashboard/smoke/owner-attention-source-fixture.py b/apps/presentation/dashboard/smoke/owner-attention-source-fixture.py index d0476869c8..118da86f83 100644 --- a/apps/presentation/dashboard/smoke/owner-attention-source-fixture.py +++ b/apps/presentation/dashboard/smoke/owner-attention-source-fixture.py @@ -28,6 +28,7 @@ def main(): import pytest from test_chat_ordinary_project import ordinary from test_lark_owner_attention import owner + from conftest import _isolated_default_runtime_routes, _isolated_worker_effect_runtime from loopx.chat_server import ChatHTTPServer, ChatRequestHandler from loopx.extensions.lark.goal_topic_runtime_service import LarkGoalTopicRuntimeService from loopx.extensions.lark.cli_resolution import LarkCliResolution @@ -43,6 +44,18 @@ def main(): signal.signal(signal.SIGINT, lambda *_: stop.set()) with TemporaryDirectory(prefix="loopx-owner-source-qa-") as directory, pytest.MonkeyPatch.context() as patch: root = Path(directory) + # Direct fixture calls do not run pytest's autouse isolation. Reuse it + # so the packaged probe never consults personal default runtime routes + # or shares an Effect process with another test or installed service. + def mktemp(name): + path = root / name + path.mkdir() + return path + factory = SimpleNamespace(mktemp=mktemp) + isolation = [_isolated_default_runtime_routes.__wrapped__(factory, patch), + _isolated_worker_effect_runtime.__wrapped__(factory)] + for fixture in isolation: + next(fixture) ordinary_fixture = ordinary.__wrapped__(root, patch) data = next(ordinary_fixture) patch.setattr(manager, "collect_manager_turn_context", collect_manager_turn_context) @@ -117,7 +130,7 @@ def provider(args, cwd=None, timeout=None): for server in servers: server.shutdown() server.server_close() - for fixture in (owner_fixture, ordinary_fixture): + for fixture in (owner_fixture, ordinary_fixture, *reversed(isolation)): try: next(fixture) except StopIteration: diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index a033103fa1..7649ac4c3a 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -2754,6 +2754,8 @@ const privateConversationSchema = z.object({ last_checked_at: z.number().nullable(), error_code: z.string().nullable(), source_coverage: z.object({goal_count: z.number().int().nonnegative(), read_goal_count: z.number().int().nonnegative(), unavailable_goal_count: z.number().int().nonnegative()}).nullable().optional(), + source_failures: z.array(z.object({goal_id: z.string(), + error_code: z.enum(["canonical_authority_required", "source_unavailable"])})).optional(), pending_count: z.number().int(), unconfirmed_count: z.number().int(), failed_count: z.number().int()}), z.object({available: z.literal(false), error_code: z.string()}), ]).optional(), diff --git a/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx b/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx index 62aa343b55..539dd3e51d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx @@ -179,6 +179,14 @@ export function PrivateConversationPanel({apps, onOpenOriginal}: {apps: LarkApp[ : row.owner_attention.last_checked_at === null ? (zh ? "等待首次后台检查" : "Waiting for the first background check") : Date.now() / 1000 - row.owner_attention.last_checked_at > 120 ? (zh ? "后台检查尚未恢复,请确认管家服务正在运行。" : "Background checks have not resumed. Check that the steward service is running.") : (zh ? `后台自动检查中 · 待发 ${row.owner_attention.pending_count}` : `Background checks active · ${row.owner_attention.pending_count} pending`)}

: null} + {row.owner_attention.enabled && row.owner_attention.source_failures?.length ?
+ {zh ? "查看不可读的 Goal" : "View unavailable Goals"} + +
: null} {row.owner_attention.unconfirmed_count > 0 ?

{zh ? "有通知发送结果尚未确认;服务只恢复原回执,不重复发送。" : "A notification receipt is unconfirmed; the service recovers the original receipt without resending."}

: null} :

{zh ? "自动通知状态尚未确认,请刷新后重试。" : "Automatic notification state is unavailable. Refresh to retry."}

} : null} diff --git a/docs/product/periodic-reports.md b/docs/product/periodic-reports.md index 280a9a02c0..0397590114 100644 --- a/docs/product/periodic-reports.md +++ b/docs/product/periodic-reports.md @@ -367,6 +367,17 @@ Failure to enumerate scope or verify the original owner, App, grant or route still pauses delivery. The panel distinguishes a fresh partial-source check from a stale worker or a delivery error; unknown receipts remain visible. +Expand **View unavailable Goals** in that setting to identify each missing +source. A native `canonical_authority_required` result asks the project's +maintainer to back up and migrate its Todo authority; other read failures stay +generic and are checked again. Only the Goal identity and an allowlisted error +code enter this health view. Private exception text and paths are not displayed. +This diagnosis never migrates a Goal, changes its grant, or resends a notice. + +在同一设置中展开“查看不可读的 Goal”,可逐项定位缺失来源。原生待办尚未迁移时, +需由该项目维护者先备份并迁移;其他读取失败只提示暂不可读,服务会继续检查。 +页面不显示原始异常或私有路径,也不会据此迁移项目、扩权或重发通知。 + The packaged fixture `apps/presentation/dashboard/smoke/owner-attention-source-fixture.py` uses real HTTP, native File authority and the existing outbound worker, with synthetic diff --git a/loopx/control_plane/presentation/owner_attention.ts b/loopx/control_plane/presentation/owner_attention.ts index af2267f14d..5d03294550 100644 --- a/loopx/control_plane/presentation/owner_attention.ts +++ b/loopx/control_plane/presentation/owner_attention.ts @@ -41,6 +41,32 @@ function readCoverage(raw: unknown): JsonObject | null { throw new Error("invalid owner attention coverage"); return coverage; } + +/** Explanatory only: failures neither widen scope nor authorize a retry. */ +function sourceFailures(raw: unknown, unavailable?: Set): JsonObject[] { + if (raw === undefined) return [...(unavailable ?? [])].sort() + .map(goal_id => ({goal_id, error_code: "source_unavailable"})); + const result = records(raw, "source_failures").map(row => { + const goal_id = requireNonEmptyString(row.goal_id, "source failure Goal"); + if (typeof row.error_code !== "string" || !["canonical_authority_required", "source_unavailable"].includes(row.error_code)) + throw new Error("unknown owner attention source failure"); + return {goal_id, error_code: row.error_code}; + }); + const ids = identities(result.map(row => row.goal_id), "source failure Goal ids"); + if (unavailable && (ids.size !== unavailable.size || [...ids].some(id => !unavailable.has(id)))) + throw new Error("source failures must identify exactly the unavailable Goals"); + return result.sort((a, b) => String(a.goal_id).localeCompare(String(b.goal_id))); +} + +function storedSourceFailures(raw: unknown, coverage: JsonObject | null): JsonObject[] { + // Older writers can retain stale optional diagnostics. They must not make + // an otherwise valid receipt journal unavailable or stop its next refresh. + try { + const failures = sourceFailures(raw); + return failures.length === (coverage?.unavailable_goal_count ?? 0) ? failures : []; + } catch { return []; } +} + function active(row: JsonObject, goalId: string): boolean { if (row.goal_id != null && row.goal_id !== goalId) throw new Error("foreign owner Todo"); // Reuse the existing attention owner, one source at a time. Its presentation @@ -75,9 +101,11 @@ function readState(raw: unknown): JsonObject { export function ownerAttentionHealth(input: JsonObject): JsonObject { const state = readState(input.state); const rows = records(state.outbox, "outbox"); + const coverage = readCoverage(state.source_coverage); return {available: true, enabled: state.enabled, revision: state.revision, last_checked_at: state.last_checked_at ?? null, error_code: state.error_code ?? null, - source_coverage: readCoverage(state.source_coverage), + source_coverage: coverage, + source_failures: storedSourceFailures(state.source_failures, coverage), pending_count: rows.filter(row => row.status === "pending").length, unconfirmed_count: rows.filter(row => row.status === "sending" || row.status === "unconfirmed").length, failed_count: rows.filter(row => row.status === "failed").length}; @@ -87,6 +115,7 @@ export function planOwnerAttention(input: JsonObject): JsonObject { const state = readState(input.state); const goals = records(input.goals, "goals"); const coverage = sourceCoverage(input, goals); + const failures = sourceFailures(input.source_failures, coverage.unavailable); const baselinePending = identities(state.baseline_pending_goal_ids ?? [], "baseline_pending_goal_ids"); if (state.initialized !== true) for (const id of coverage.unavailable) baselinePending.add(id); const previous = records(state.subjects, "subjects"); @@ -143,6 +172,7 @@ export function planOwnerAttention(input: JsonObject): JsonObject { } return {...state, initialized: true, subjects, outbox, error_code: coverage.unavailable.size ? "source_incomplete" : null, + source_failures: failures, baseline_pending_goal_ids: [...baselinePending].filter(id => coverage.unavailable.has(id)).sort(), source_coverage: {goal_count: coverage.scope.size, read_goal_count: coverage.read.size, unavailable_goal_count: coverage.unavailable.size}}; diff --git a/loopx/extensions/lark/owner_attention.py b/loopx/extensions/lark/owner_attention.py index 49ba8bc426..10075720d9 100644 --- a/loopx/extensions/lark/owner_attention.py +++ b/loopx/extensions/lark/owner_attention.py @@ -14,6 +14,7 @@ from typing import Any from ...chat_store import _read_json +from ...control_plane.coordination.local_authority import CanonicalAuthorityRequired from ...control_plane.effect_runtime import effect_runtime_result from ...control_plane.todos.list_readback import list_goal_todos from ...file_lock import exclusive_file_lock, try_exclusive_file_lock, write_json_atomic @@ -136,14 +137,18 @@ def _snapshot(self, binding: dict[str, Any]) -> dict[str, Any]: # Scope enumeration remains all-or-nothing. A failed authority read # inside that grant is a missing Goal, never an empty Todo list. scope = self.transport.bindings.goal_scope_ids(binding) - goals, unavailable = [], [] + goals, unavailable, failures = [], [], [] for goal_id in scope: try: goals.append({"goal_id": goal_id, "todos": self._goal_todos(goal_id)}) - except (ValueError, OSError, KeyError, TypeError, RuntimeError): + except (ValueError, OSError, KeyError, TypeError, RuntimeError) as exc: unavailable.append(goal_id) + # Only native error codes reach health; exception messages may + # contain private paths, credentials or untrusted source text. + failures.append({"goal_id": goal_id, "error_code": "canonical_authority_required" + if isinstance(exc, CanonicalAuthorityRequired) else "source_unavailable"}) return {"goals": goals, "scope_goal_ids": scope, "unavailable_goal_ids": unavailable, - "source_complete": not unavailable} + "source_complete": not unavailable, "source_failures": failures} def _goal_todos(self, goal_id: str) -> list[dict[str, Any]]: controller = self.transport.core.controller diff --git a/tests/control_plane_ts/owner_attention.test.ts b/tests/control_plane_ts/owner_attention.test.ts index 72b921c411..35fdda3698 100644 --- a/tests/control_plane_ts/owner_attention.test.ts +++ b/tests/control_plane_ts/owner_attention.test.ts @@ -194,3 +194,43 @@ test("partial coverage must exhaustively partition a complete authorized scope", assert.throws(() => ownerAttentionHealth({state: {...state, source_coverage: {goal_count: 2, read_goal_count: 2, unavailable_goal_count: 1}}})); }); + +test("source diagnostics identify the unavailable Goal and retain no raw error details", () => { + const base = snapshot(empty(), [goal("alpha", []), goal("beta", [])], []); + const input = {state: base, goals: [goal("alpha", [task])], scope_goal_ids: ["alpha", "beta"], + unavailable_goal_ids: ["beta"], source_complete: false, + source_failures: [{goal_id: "beta", error_code: "canonical_authority_required", error: "private path"}]}; + const missing = planOwnerAttention(input); + const health = ownerAttentionHealth({state: JSON.parse(JSON.stringify(missing))}); + assert.deepEqual(health.source_failures, [{goal_id: "beta", error_code: "canonical_authority_required"}]); + assert.ok(!JSON.stringify(health).includes("private path")); + assert.equal(rows(missing).length, 1); + assert.equal(rows(missing)[0].source_current, true, "diagnostics do not hold healthy delivery"); + const restored = snapshot(missing, [goal("alpha", [task]), goal("beta", [])], []); + assert.deepEqual(ownerAttentionHealth({state: restored}).source_failures, []); + assert.equal(rows(restored).length, 1, "changing diagnostics cannot create another notice"); + // Journals written before this optional field remain readable. The next + // full snapshot supplies the missing Goal identity without guessing a cause. + const {source_failures: _oldField, ...legacy} = missing; + assert.deepEqual(ownerAttentionHealth({state: legacy}).source_failures, []); + assert.deepEqual(snapshot(legacy, [goal("alpha", [task])], ["beta"]).source_failures, + [{goal_id: "beta", error_code: "source_unavailable"}]); + for (const source_failures of [[], + [{goal_id: "alpha", error_code: "source_unavailable"}], + [{goal_id: "foreign", error_code: "source_unavailable"}], + [{goal_id: "beta", error_code: "unknown"}], + [{goal_id: "beta", error_code: "source_unavailable"}, {goal_id: "beta", error_code: "source_unavailable"}]]) + assert.throws(() => planOwnerAttention({...input, source_failures})); + assert.deepEqual(ownerAttentionHealth({state: {...missing, source_failures: []}}).source_failures, []); + for (const source_failures of [null, [{goal_id: "beta", error_code: "private path"}]]) { + const retained = {...missing, source_failures}; + assert.equal(ownerAttentionHealth({state: retained}).available, true); + assert.deepEqual(ownerAttentionHealth({state: retained}).source_failures, []); + assert.equal(rows(snapshot(retained, [goal("alpha", [task])], ["beta"])).length, 1); + } + const oldWriter = {...missing, source_coverage: {goal_count: 2, read_goal_count: 2, unavailable_goal_count: 0}}; + assert.deepEqual(ownerAttentionHealth({state: oldWriter}).source_failures, [], "ignore stale optional diagnosis"); + const current = snapshot(oldWriter, [goal("alpha", [task]), goal("beta", [])], []); + assert.deepEqual(current.source_failures, []); + assert.equal(rows(current).length, 1, "old-writer metadata cannot retire or duplicate the original notice"); +}); diff --git a/tests/test_lark_owner_attention.py b/tests/test_lark_owner_attention.py index 0339910d9b..d8697749d1 100644 --- a/tests/test_lark_owner_attention.py +++ b/tests/test_lark_owner_attention.py @@ -358,6 +358,8 @@ def read(**args): health = o.attention.health(o.binding["binding_id"]) assert health["error_code"] == "source_incomplete" and health["last_checked_at"] is not None assert health["source_coverage"] == {"goal_count": 2, "read_goal_count": 1, "unavailable_goal_count": 1} + assert health["source_failures"] == [{"goal_id": "beta", "error_code": + "canonical_authority_required" if failure == "unmigrated" else "source_unavailable"}] assert "private source" not in json.dumps(health) monkeypatch.setattr(module, "list_goal_todos", native) if failure == "unmigrated": @@ -365,10 +367,27 @@ def read(**args): assert o.attention.reconcile() == 1 assert len(o.provider.writes) == before + 2 and "Recovered source" in o.provider.writes[-1][1] assert o.attention.health(o.binding["binding_id"])["error_code"] is None + assert o.attention.health(o.binding["binding_id"])["source_failures"] == [] assert o.attention.reconcile() == 0 and len(o.provider.writes) == before + 2 assert not o.capture.exists() and not o.store.list_sessions() +@pytest.mark.parametrize("code", ["private://credentials", None]) +def test_source_diagnostics_do_not_publish_unknown_native_errors(owner, monkeypatch, code): + from loopx.extensions.lark import owner_attention as module + o = owner + o.configure() + o.tasks([], goal="alpha") + before = len(o.provider.writes) + monkeypatch.setattr(module, "list_goal_todos", lambda **_: { + "ok": False, "error_code": code, "error": "private source details"}) + assert o.attention.reconcile() == 0 + health = o.attention.health(o.binding["binding_id"]) + assert health["source_failures"] == [{"goal_id": "alpha", "error_code": "source_unavailable"}] + assert "private" not in json.dumps(health) + assert not o.capture.exists() and len(o.provider.writes) == before + + def test_missing_pending_items_do_not_take_healthy_delivery_slots_or_lose_original_identity(owner, monkeypatch): from loopx.extensions.lark import owner_attention as module o = owner