diff --git a/apps/presentation/dashboard/src/data/conversation-requests.ts b/apps/presentation/dashboard/src/data/conversation-requests.ts index 0c85bf7366..2c58144b16 100644 --- a/apps/presentation/dashboard/src/data/conversation-requests.ts +++ b/apps/presentation/dashboard/src/data/conversation-requests.ts @@ -15,7 +15,9 @@ const inventorySchema = z.object({ok: z.literal(true), session_id: z.string(), r snapshot_digest: z.string().regex(/^[a-f0-9]{64}$/), pending_count: z.number().int().nonnegative(), matched: z.number().int().nonnegative(), next_offset: z.number().int().nonnegative().nullable(), observed_at: z.string(), - coverage: z.object({scan_complete: z.boolean(), unreadable: z.number().int().nonnegative()})}); + coverage: z.object({scan_complete: z.boolean(), unreadable: z.number().int().nonnegative(), + diagnostics: z.array(z.object({request_id: z.string().regex(/^[a-f0-9]{64}$/), goal_id: z.string(), agent_id: z.string(), + code: z.literal("initial_delivery_unproved"), reason: z.literal("handoff_receipt_conflict")})).max(12).default([])})}); export type ConversationRequest = z.infer; export type ConversationRequestInventory = z.infer; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx index 642fb0e13d..5e15d62ffa 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx @@ -348,7 +348,16 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se ? `已显示 ${rows.length} / ${page.matched}${unknown ? "+" : ""}` : `${rows.length} / ${page.matched}${unknown ? "+" : ""} shown`} {unknown ? -

{zh ? "部分记录无法核验,实际数量可能更多。" : "Some records cannot be verified. The actual total may be higher."}

+

{page.coverage.unreadable > 0 ? (zh ? `${page.coverage.unreadable} 条记录未能核验。` : `Unverified records: ${page.coverage.unreadable}. `) : ""} + {zh ? "数量与列表仍不完整。" : "Counts and list remain incomplete."} + {!page.coverage.scan_complete ? (zh ? " 历史扫描尚未完整。" : " The history scan is incomplete.") : ""}

+ {page.coverage.diagnostics.length ? <> +

{zh ? "原委派记录与来源对话回执指向不同委派;原记录已保留,尚不能判定取消或完成。" : "The source conversation's receipt points to a different delegation. The original records are retained; cancellation or completion cannot be established."}

+
    {page.coverage.diagnostics.map(item =>
  • + {item.agent_id} · {item.goal_id}
    {item.request_id} +
  • )}
+ : null} + {page.coverage.unreadable > page.coverage.diagnostics.length ?

{zh ? "其它读取缺口未在此列出。" : "Other read gaps are not listed here."}

: null}
: null} {last?.next_offset !== null && last?.next_offset !== undefined ? : null} diff --git a/examples/personal-workspace-browser/assets/delegation-diagnostics/after-desktop.png b/examples/personal-workspace-browser/assets/delegation-diagnostics/after-desktop.png new file mode 100644 index 0000000000..eab1893d09 Binary files /dev/null and b/examples/personal-workspace-browser/assets/delegation-diagnostics/after-desktop.png differ diff --git a/examples/personal-workspace-browser/assets/delegation-diagnostics/after-diagnostic-desktop.png b/examples/personal-workspace-browser/assets/delegation-diagnostics/after-diagnostic-desktop.png new file mode 100644 index 0000000000..67a6c99ad9 Binary files /dev/null and b/examples/personal-workspace-browser/assets/delegation-diagnostics/after-diagnostic-desktop.png differ diff --git a/examples/personal-workspace-browser/assets/delegation-diagnostics/after-diagnostic-narrow.png b/examples/personal-workspace-browser/assets/delegation-diagnostics/after-diagnostic-narrow.png new file mode 100644 index 0000000000..c4e11034c8 Binary files /dev/null and b/examples/personal-workspace-browser/assets/delegation-diagnostics/after-diagnostic-narrow.png differ diff --git a/examples/personal-workspace-browser/assets/delegation-diagnostics/before-diagnostic-desktop.png b/examples/personal-workspace-browser/assets/delegation-diagnostics/before-diagnostic-desktop.png new file mode 100644 index 0000000000..fdeb59fa1d Binary files /dev/null and b/examples/personal-workspace-browser/assets/delegation-diagnostics/before-diagnostic-desktop.png differ diff --git a/loopx/capabilities/manager_context/README.md b/loopx/capabilities/manager_context/README.md index cfebf883eb..2553cf66e1 100644 --- a/loopx/capabilities/manager_context/README.md +++ b/loopx/capabilities/manager_context/README.md @@ -182,10 +182,19 @@ Goal visibility are checked before and after reading. Original-Chat delivery and explicit peer-return consumption remain different receipt paths. The list does not mark reads, accept work, start an Agent or certify an overall outcome. Unreadable/missing sources and incomplete scans keep counts visibly partial. +When the typed lifecycle proves a matching original source/route but finds a +conflicting handoff receipt, coverage includes at most 12 diagnostic +request/Goal/Agent identities. This is a refusal readback, not a readable brief, +cancellation or completion. The App's warning glyph exposes that cause on demand; +other unreadable sources remain unknown. Refresh rechecks the original owners; +the read neither repairs historical receipts nor grants execution/return authority. 管家标题栏提供常驻委派入口,按接收方查看待跟进请求,并可展开当前交办内容、关联 工作和原对话。同一来源的较早会话不会因重开会话而丢失;其它用户、来源、群聊和 项目会话不混入。结论已回传与成果已验收不是同一事实,历史记录仍可回看。 +来源与原路匹配但发送回执冲突时,保留不完整计数,以警示小符号按需提供至多 12 条 +具体请求身份和原因;不展示未获读取资格的交办正文,不推定已取消或完成。刷新重新 +核验原 owner,不修改历史回执,也不解除执行或返回权限;其它读取缺口继续保持未知。 The automatic agenda is a partial read (`read_complete=false`): it cannot fulfill the full Inbox read or replace its requirements. Before acting, read the current diff --git a/loopx/control_plane/collaboration/conversation_inventory.py b/loopx/control_plane/collaboration/conversation_inventory.py index eab30c355d..f24c334285 100644 --- a/loopx/control_plane/collaboration/conversation_inventory.py +++ b/loopx/control_plane/collaboration/conversation_inventory.py @@ -11,7 +11,7 @@ import json from ..content_digest import BARE_SHA256_PATTERN -from .goal_instance_scope import collaboration_goal_scope, decide_collaboration_lifecycle +from .goal_instance_scope import CollaborationLifecycleRejected, collaboration_goal_scope, decide_collaboration_lifecycle from .inbox import _entry, _now, _read, _receipt, _request_lock, _root, read_inbox_transaction, reply_status from .links import read_linked_work from .source_chat_observation import is_source_chat_host, original_delivery_facts, original_turn_reader @@ -45,7 +45,7 @@ def read_conversation_requests(root, registry, store, session, *, goal_ids, core raise ValueError("request search must be at most 200 characters") query = search.strip().casefold() paths = sorted((_root(root) / "entries").glob("*/*.json")) - rows, unreadable, cache, turn_readers = [], 0, {}, {} + rows, unreadable, cache, turn_readers, diagnostics = [], 0, {}, {}, [] for path in paths[:2000]: try: candidate = _read(path) @@ -79,8 +79,17 @@ def read_conversation_requests(root, registry, store, session, *, goal_ids, core if row.get("source_kind") == "peer": continue grant = source_context_target_authority(root, source_session, turn, {"goal_id": gid, "agent_id": aid}) - decide_collaboration_lifecycle(scope, operation="original_request_inspect", record=row, route=route, - initial_delivery=original_delivery_facts(row, route, turn, source_id=grant["source_id"])) + try: + decide_collaboration_lifecycle(scope, operation="original_request_inspect", record=row, route=route, + initial_delivery=original_delivery_facts(row, route, turn, source_id=grant["source_id"])) + except CollaborationLifecycleRejected as exc: + # Only this typed refusal proves the route and source + # matched before the conflicting receipt was observed. + # Withhold the brief, work and completion state as before. + if exc.decision.get("delivery_proof_reason") == "handoff_receipt_conflict" and len(diagnostics) < 12: + diagnostics.append({"request_id": row["request_id"], "goal_id": gid, "agent_id": aid, + "code": exc.decision["code"], "reason": exc.decision["delivery_proof_reason"]}) + raise observed = read_inbox_transaction(root, {"operation": "receipt_states", "rows": [row], "include_agenda": True}) state, agenda = observed["items"][0], observed["agenda_items"][0] controlled = state.get("readback") @@ -129,7 +138,7 @@ def read_conversation_requests(root, registry, store, session, *, goal_ids, core (row.get("brief") or {}).get("purpose"), (row.get("brief") or {}).get("context")))] visible.sort(key=lambda row: (row.get("last_event_at") or "", row["request_id"]), reverse=True) page = visible[offset:offset + limit] - coverage = {"scan_complete": len(paths) <= 2000, "unreadable": unreadable} + coverage = {"scan_complete": len(paths) <= 2000, "unreadable": unreadable, "diagnostics": diagnostics} # A page fence over observations, not an atomic snapshot or a new ledger. snapshot_digest = hashlib.sha256(json.dumps([visible, coverage, query], sort_keys=True, ensure_ascii=False).encode()).hexdigest() return {"ok": True, "session_id": session["session_id"], "rows": page, diff --git a/loopx/control_plane/collaboration/goal_instance_lifecycle.ts b/loopx/control_plane/collaboration/goal_instance_lifecycle.ts index 41810dd95c..4af2ef0915 100644 --- a/loopx/control_plane/collaboration/goal_instance_lifecycle.ts +++ b/loopx/control_plane/collaboration/goal_instance_lifecycle.ts @@ -61,6 +61,7 @@ export type CollaborationLifecycleDecision = | "route_instance_mismatch" | "historical_mutation_forbidden" | "initial_delivery_unproved"; + delivery_proof_reason?: "handoff_receipt_conflict"; }>; function operation(value: unknown): CollaborationOperation { @@ -302,6 +303,19 @@ function decideExactLifecycle( } } +function withOriginalRequestDiagnostic( + decision: CollaborationLifecycleDecision, + raw: JsonObject, + selectedOperation: CollaborationOperation, +): CollaborationLifecycleDecision { + if (selectedOperation === "original_request_inspect" && decision.kind === "reject" + && decision.code === "initial_delivery_unproved" + && proveCommittedRequest(raw.initial_delivery).reason === "handoff_receipt_conflict") { + return { ...decision, delivery_proof_reason: "handoff_receipt_conflict" }; + } + return decision; +} + export function decideCollaborationLifecycle( value: unknown, ): CollaborationLifecycleDecision { @@ -314,7 +328,9 @@ export function decideCollaborationLifecycle( const selectedOperation = operation(raw.operation); if (raw.profile_id === null) { if (requiresRequestProof(selectedOperation) && !requestProof(raw, selectedOperation)) { - return { kind: "reject", code: "initial_delivery_unproved" }; + return withOriginalRequestDiagnostic( + { kind: "reject", code: "initial_delivery_unproved" }, raw, selectedOperation, + ); } return { kind: "legacy" }; } @@ -322,5 +338,7 @@ export function decideCollaborationLifecycle( return { kind: "reject", code: "unsupported_profile" }; } const facts = lifecycleFacts(raw, selectedOperation); - return "operation" in facts ? decideExactLifecycle(facts) : facts; + return withOriginalRequestDiagnostic( + "operation" in facts ? decideExactLifecycle(facts) : facts, raw, selectedOperation, + ); } diff --git a/loopx/control_plane/collaboration/goal_instance_scope.py b/loopx/control_plane/collaboration/goal_instance_scope.py index 7b6f71a572..7932328520 100644 --- a/loopx/control_plane/collaboration/goal_instance_scope.py +++ b/loopx/control_plane/collaboration/goal_instance_scope.py @@ -177,6 +177,14 @@ def capture_collaboration_goal_ref( ) +class CollaborationLifecycleRejected(ValueError): + """Retain the typed refusal for authorized diagnostic readers.""" + + def __init__(self, decision: dict[str, Any]): + self.decision = decision + super().__init__(f"collaboration lifecycle rejected: {decision.get('code')}") + + def decide_collaboration_lifecycle( scope: CollaborationGoalScope, *, @@ -206,7 +214,5 @@ def decide_collaboration_lifecycle( if not isinstance(result, dict): raise RuntimeError("collaboration lifecycle decision must be an object") if result.get("kind") == "reject": - raise ValueError( - f"collaboration lifecycle rejected: {result.get('code')}" - ) + raise CollaborationLifecycleRejected(result) return result diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index d812ee520d..4ed7fb0f50 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -5,6 +5,7 @@ teaches a reusable control-plane lesson. | Pattern | Symptoms | Evidence To Read | Likely Root | Durable Repair | | --- | --- | --- | --- | --- | +| `conversation_inventory_refusal_reason_loss` | A delegation inventory stays partial because an original request is rejected as `initial_delivery_unproved`, but the App shows only a generic unreadable count. | The current authorized source, committed request/route, trusted originating Turn receipt and typed lifecycle refusal; distinguish incomplete scans and unloaded pages. | The IO adapter collapses a typed receipt conflict into a generic exception and omits the row without retaining a diagnosable identity. | Preserve the existing TS refusal and expose bounded diagnostic identities only after source/route agreement is proved and current audience/Goal checks pass. Keep the count partial, brief/work withheld and controls/return rejected; disclose the cause through existing accessible help. Other failures remain unknown. Cover File/SQLite real HTTP, source/scope changes, bounded diagnostics and fresh read after source recovery. Never rewrite live receipts, infer cancellation from another target, remove proof checks or claim the historical conflict is repaired. | | `codex_usage_session_rebinding` | Cumulative Codex token usage moves to a different session after another `session_meta`, or replaying the same snapshot loses its recorded duration after repeated metadata. | The explicitly bound synthetic rollout's ordered session ids, first clock, usage snapshot identity, and before/after run-index bytes and aggregate counters. | The usage reader overwrites identity and start time while scanning, allowing later metadata to reinterpret earlier counters. | Require identified metadata before usage, reject conflicting session ids, and retain the first clock for repeated same-id headers. Preserve streaming and the existing booking lock. Cover File/SQLite refusal without an append and repaired same-snapshot zero-delta recovery; never discover another session or rewrite historical usage. | | `implicit_clock_precedes_state_lock` | Concurrent state mutations reject a healthy request as a backward timestamp even though a fresh retry succeeds. | The exact operation, persisted `updated_at`, and a real-clock reproduction where the first caller pauses before acquiring the lock and a later caller commits first. | Implicit time was sampled before lock acquisition, so request order and commit order diverged. | Sample implicit mutation time inside the existing locked state transition. Preserve explicit backward-time rejection and observation-specific out-of-order rules. Cover every adjacent writer with reversed lock acquisition, unchanged-state negative cases, and the real CLI/file-lock path; do not clamp explicit input, weaken the guard, or add retries. | | `stride_inline_outcome_basis_ordering` | A batched milestone dry-run succeeds, but execution returns `stride_review_basis_mismatch` after an outcome refinement has committed. | Exact Turn/obligation/context, the returned `goal_outcome_update` status and operation, current acceptance and run/spend receipts; compare due Stride with ordinary batching. | Sequential refinement changes the context that the decision references; a preview using pre-update facts hides that change. | Feed the typed acceptance owner's prospective contract to dry-run planning. For a changed due-review milestone, refine before a fresh same-Turn guard, read the new facts and author the complete decision; unchanged work needs no update. On partial failure retain applied/replayed receipts and original IDs, reassess fresh context, then settle once. Never assume rollback, blindly replace a context token, lower the gate or replay work. Cover real File/SQLite preview/refusal/recovery, ordinary batching and single debit. | diff --git a/tests/control_plane/test_chat_request_inventory.py b/tests/control_plane/test_chat_request_inventory.py index 2dfa337a62..7282fb9d07 100644 --- a/tests/control_plane/test_chat_request_inventory.py +++ b/tests/control_plane/test_chat_request_inventory.py @@ -227,3 +227,102 @@ def observe_scope(reader, session_id): code, denied = _inventory(api, route["session_id"]) assert code == 400 and denied["error_code"] == "request_inventory_unavailable" assert "rows" not in denied and "Check the current contract" not in str(denied) + + +def test_conflicting_receipt_is_diagnosable_but_does_not_authorize_content_or_controls(api): + control, _, route = _source(api) + _, f, server = api + store = server.chat_store + turn = store.load_turn(route["session_id"], route["turn_id"]) + original_response = turn["response"] + source = _root(f["runtime"]) + saved = {path: path.read_bytes() for path in source.rglob("*.json")} + store.update_turn(route["session_id"], route["turn_id"], response={"context_handoff_receipt": { + **original_response["context_handoff_receipt"], "request_id": "b" * 64, "agent_id": "agent-b"}}) + code, partial = _inventory(api, route["session_id"], view="history") + assert code == 200 and partial["rows"] == [] and partial["pending_count"] == 0 + assert partial["coverage"] == {"scan_complete": True, "unreadable": 1, "diagnostics": [{ + "request_id": route["request_id"], "goal_id": f["ref"]["goal_id"], "agent_id": "agent-a", + "code": "initial_delivery_unproved", "reason": "handoff_receipt_conflict"}]} + assert turn["message"] not in str(partial) and "Check the current contract" not in str(partial) + assert control()[0] == 400 + for operation in ["revise", "cancel", "recover"]: + assert control({**route, "operation": operation, "execute": True, "parameters": {}})[0] == 400 + assert {path: path.read_bytes() for path in saved} == saved + # Only restore the synthetic fixture's original source evidence. Inventory + # itself cannot repair or invent that evidence, nor retain an old refusal. + store.update_turn(route["session_id"], route["turn_id"], response=original_response) + recovered = _inventory(api, route["session_id"], view="history")[1] + assert recovered["coverage"] == {"scan_complete": True, "unreadable": 0, "diagnostics": []} + assert recovered["rows"][0]["request_id"] == route["request_id"] + assert partial["snapshot_digest"] != recovered["snapshot_digest"] + + +def test_a_later_handoff_does_not_cancel_the_conflicting_original_request(api): + _, _, route = _source(api) + _, f, server = api + store = server.chat_store + session = store.load_session(route["session_id"]) + turn = store.load_turn(route["session_id"], route["turn_id"]) + later = deliver(f["runtime"], f["registry"], session=session, turn=turn, + request={"goal_id": f["ref"]["goal_id"], "agent_id": "agent-b"}, source_store=store) + assert later["request_id"] != route["request_id"] + store.update_turn(route["session_id"], route["turn_id"], response={"context_handoff_receipt": later}) + source = _root(f["runtime"]) + saved = {path: path.read_bytes() for path in source.rglob("*.json")} + value = _inventory(api, route["session_id"], view="history")[1] + assert [row["request_id"] for row in value["rows"]] == [later["request_id"]] + assert value["pending_count"] == 1 and value["coverage"]["unreadable"] == 1 + assert value["coverage"]["diagnostics"][0]["request_id"] == route["request_id"] + assert {path: path.read_bytes() for path in saved} == saved + + +def test_conflict_diagnostics_do_not_disclose_a_changed_source_or_scope(api, monkeypatch): + from loopx.presentation.request_control_api import RequestControlMixin + + _, _, route = _source(api) + _, f, server = api + store = server.chat_store + turn = store.load_turn(route["session_id"], route["turn_id"]) + store.update_turn(route["session_id"], route["turn_id"], response={"context_handoff_receipt": { + **turn["response"]["context_handoff_receipt"], "request_id": "b" * 64}}) + scope_read = RequestControlMixin._request_inventory_scope + reads = [] + def changed_scope(reader, session_id): + observed, goals, core = scope_read(reader, session_id) + reads.append(session_id) + return (observed, [], core) if len(reads) == 2 else (observed, goals, core) + monkeypatch.setattr(RequestControlMixin, "_request_inventory_scope", changed_scope) + code, denied = _inventory(api, route["session_id"]) + assert code == 400 and "coverage" not in denied and route["request_id"] not in str(denied) + monkeypatch.setattr(RequestControlMixin, "_request_inventory_scope", scope_read) + from loopx.control_plane.collaboration.inbox import _read, _write + path = next(_root(f["runtime"]).glob("entries/*/" + route["request_id"] + ".json")) + _write(path, {**_read(path), "source_id": "web:another-source"}) + partial = _inventory(api, route["session_id"])[1] + assert partial["coverage"]["unreadable"] == 1 and partial["coverage"]["diagnostics"] == [] + + +def test_conflict_diagnostic_window_does_not_reduce_the_unreadable_count(api): + _, _, route = _source(api) + _, f, server = api + store = server.chat_store + session = store.load_session(route["session_id"]) + turns = [store.load_turn(route["session_id"], route["turn_id"])] + for number in range(12): + turn, _ = store.create_turn(session["session_id"], client_turn_id=f"conflict-{number}", + message=f"Review retained export {number}", origin="web") + receipt = deliver(f["runtime"], f["registry"], session=session, turn=turn, + request={"goal_id": f["ref"]["goal_id"], "agent_id": "agent-a"}, source_store=store) + store.update_turn(session["session_id"], turn["turn_id"], status="completed", response={"context_handoff_receipt": receipt}) + store.finalize_managed_turn_completion(session["session_id"], turn["turn_id"]) + turns.append(store.load_turn(session["session_id"], turn["turn_id"])) + for turn in turns: + store.update_turn(session["session_id"], turn["turn_id"], response={"context_handoff_receipt": { + **turn["response"]["context_handoff_receipt"], "request_id": "b" * 64}}) + value = _inventory(api, session["session_id"], view="history")[1] + assert value["coverage"]["unreadable"] == 13 and len(value["coverage"]["diagnostics"]) == 12 + assert value["rows"] == [] and value["matched"] == value["pending_count"] == 0 + later_page = _inventory(api, session["session_id"], view="history", offset=1)[1] + assert later_page["coverage"] == value["coverage"] + assert later_page["snapshot_digest"] == value["snapshot_digest"] diff --git a/tests/control_plane_ts/collaboration_goal_instance_lifecycle.test.ts b/tests/control_plane_ts/collaboration_goal_instance_lifecycle.test.ts index e5f6d7e94d..2ac7ba80ca 100644 --- a/tests/control_plane_ts/collaboration_goal_instance_lifecycle.test.ts +++ b/tests/control_plane_ts/collaboration_goal_instance_lifecycle.test.ts @@ -226,6 +226,26 @@ test("lost-answer recovery cannot substitute a source, request, instance or conf }); }); +test("original request diagnostics retain the receipt refusal without exposing mismatched sources", () => { + for (const profile_id of [null, "source_session_v1"]) { + const base = committedDelivery("completed", profile_id !== null); + const initial_delivery = {...base, turn: {...base.turn, + context_handoff_receipt: {...base.request, request_id: "b".repeat(64)}}}; + assert.deepEqual(decideCollaborationLifecycle(facts("original_request_inspect", {profile_id, initial_delivery})), { + kind: "reject", code: "initial_delivery_unproved", delivery_proof_reason: "handoff_receipt_conflict", + }); + for (const operation of ["original_return_admit", "original_return_settle"]) { + assert.deepEqual(decideCollaborationLifecycle(facts(operation, {profile_id, initial_delivery})), { + kind: "reject", code: "initial_delivery_unproved", + }); + } + assert.deepEqual(decideCollaborationLifecycle(facts("original_request_inspect", {profile_id, + initial_delivery: {...initial_delivery, authorized_source_id: "web:another-source"}})), { + kind: "reject", code: "initial_delivery_unproved", + }); + } +}); + test("readback observes a committed active request without permitting its result return", () => { for (const profile_id of [null, "source_session_v1"]) {