Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`)}</p> : null}
{row.owner_attention.enabled && row.owner_attention.source_failures?.length ? <details>
<summary>{zh ? "查看不可读的 Goal" : "View unavailable Goals"}</summary>
<ul>{row.owner_attention.source_failures.map(source => <li key={source.goal_id} style={{overflowWrap: "anywhere"}}>
<strong>{source.goal_id}</strong>{" · "}{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.")}
</li>)}</ul>
</details> : null}
{row.owner_attention.unconfirmed_count > 0 ? <p role="status">{zh ? "有通知发送结果尚未确认;服务只恢复原回执,不重复发送。" : "A notification receipt is unconfirmed; the service recovers the original receipt without resending."}</p> : null}
</> : <p role="status">{zh ? "自动通知状态尚未确认,请刷新后重试。" : "Automatic notification state is unavailable. Refresh to retry."}</p>}
</div> : null}
Expand Down
11 changes: 11 additions & 0 deletions docs/product/periodic-reports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion loopx/control_plane/presentation/owner_attention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>): 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
Expand Down Expand Up @@ -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};
Expand All @@ -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");
Expand Down Expand Up @@ -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}};
Expand Down
11 changes: 8 additions & 3 deletions loopx/extensions/lark/owner_attention.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions tests/control_plane_ts/owner_attention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
19 changes: 19 additions & 0 deletions tests/test_lark_owner_attention.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,17 +358,36 @@ 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":
o.tasks([{"id": "todo_beta", "text": "Recovered source decision"}], goal="beta", provider=provider_kind)
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
Expand Down
Loading