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"}
+ {row.owner_attention.source_failures.map(source => -
+ {source.goal_id}{" · "}{source.error_code === "canonical_authority_required"
+ ? (zh ? "原生待办数据尚未迁移;需由项目维护者先备份并迁移。" : "Native Todo data has not been migrated. The project maintainer must back up and migrate it first.")
+ : (zh ? "来源暂不可读;将自动重查,请检查原项目来源状态。" : "The source is unavailable and will be checked again. Check the original project source.")}
+
)}
+ : 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