diff --git a/apps/presentation/dashboard/src/data/room-work.ts b/apps/presentation/dashboard/src/data/room-work.ts new file mode 100644 index 0000000000..bd7594bd3f --- /dev/null +++ b/apps/presentation/dashboard/src/data/room-work.ts @@ -0,0 +1,31 @@ +import {z} from "zod"; +import {requestJson} from "./chat"; + +const ownerSchema = z.object({todo_id: z.string(), current_owner: z.string().nullable(), current_claim_matches_actor: z.boolean()}); +const roomWorkSchema = z.object({ + ok: z.boolean(), status: z.string(), actor_id: z.string(), connection_id: z.string(), scope_revision: z.string(), + canonical_claim_accepted: z.boolean(), readback_verified: z.boolean(), execution_authority_granted: z.literal(false), claim_available: z.boolean(), + projection: z.object({source_revision: z.string(), generated_at: z.string(), + selected_todo: z.object({todo_id: z.string()}).nullable(), + counts: z.object({unclaimed: z.number(), user_gates: z.number()}), + inspected_todo: ownerSchema.nullable().optional(), + }).optional(), + receipt: ownerSchema.extend({receipt_id: z.string(), acceptance: z.string(), + current_owner: z.string().nullable().optional(), current_claim_matches_actor: z.boolean().optional(), + }).optional(), +}); +export type RoomWork = z.infer; +export const roomClaimIntentSchema = z.object({ + goal_id: z.string(), connection_id: z.string(), scope_revision: z.string(), + todo_id: z.string(), expected_revision: z.string(), idempotency_key: z.string(), +}); +export type RoomClaimIntent = z.infer; + +export async function fetchRoomWork(goalId: string, connectionId: string, todoId?: string) { + const query = new URLSearchParams({goal_id: goalId, connection_id: connectionId}); + if (todoId) query.set("todo_id", todoId); + return roomWorkSchema.parse(await requestJson(`/api/chat/lark/room-work?${query}`)); +} +export async function claimRoomWork(intent: RoomClaimIntent) { + return roomWorkSchema.parse(await requestJson("/api/chat/lark/room-work", {method: "POST", body: JSON.stringify(intent)})); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index c47489e0f9..f997d12e1f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -712,6 +712,32 @@ const en = { "lark.captureAll": "All messages in this Goal topic", "lark.captureScope": "Capture scope", "lark.captureScopeDescription": "Controls which Topic messages enter LoopX without expanding Agent permissions.", + "roomWork.readOnly": "Read-only: enable the existing Lark Goal Channel extension and permission before claiming.", + "roomWork.details": "Claim details", + "roomWork.receipt": "Receipt", + "roomWork.messageVerified": "Room receipt verified", + "roomWork.title": "Room work", + "roomWork.close": "Close room work", + "roomWork.unavailable": "Room work is unavailable. Check this connection and task service, then refresh.", + "roomWork.storageUnavailable": "This browser cannot preserve a recovery request. Enable session storage before claiming.", + "roomWork.scopeChanged": "This connection changed. Refresh before making a new claim.", + "roomWork.conflict": "Work changed since this view was read. Refresh before claiming again.", + "roomWork.deliveryPending": "The claim was accepted; its room message still needs recovery.", + "roomWork.unknown": "The response is uncertain. Reconcile this same request before claiming more work.", + "roomWork.boundary": "A claim records ownership only. Execution still requires current gates, quota and any lease. Private recall stays private.", + "roomWork.loading": "Reading current room work…", + "roomWork.agent": "Agent", + "roomWork.available": "Available tasks", + "roomWork.gates": "User decisions", + "roomWork.task": "Task", + "roomWork.none": "None", + "roomWork.observed": "Observed at", + "roomWork.accepted": "Historical claim accepted", + "roomWork.owner": "Current owner at last readback", + "roomWork.pending": "A request is awaiting reconciliation. Recovery reuses its original key.", + "roomWork.refresh": "Refresh current state", + "roomWork.recover": "Reconcile this claim", + "roomWork.claim": "Claim task and send receipt", "lark.closeConnection": "Close connection dialog", "lark.closeCreate": "Close create dialog", "lark.closeSettings": "Close Lark settings", @@ -2235,6 +2261,32 @@ const zhCN: Record = { "lark.captureAll": "接收此 Goal Topic 中的所有消息", "lark.captureScope": "接收范围", "lark.captureScopeDescription": "决定哪些 Topic 消息会进入 LoopX;不会扩大 Agent 权限。", + "roomWork.readOnly": "当前只读:领取前需启用已有飞书 Goal Channel 扩展及权限。", + "roomWork.details": "领取详情", + "roomWork.receipt": "凭据", + "roomWork.messageVerified": "房间凭据已确认", + "roomWork.title": "房间工作", + "roomWork.close": "关闭房间工作", + "roomWork.unavailable": "房间工作暂不可用。请检查此连接及任务服务后刷新。", + "roomWork.storageUnavailable": "浏览器无法保存恢复请求。请启用会话存储后再领取。", + "roomWork.scopeChanged": "连接已变化,请刷新后再发起新的领取。", + "roomWork.conflict": "工作状态已变化,请刷新后再领取。", + "roomWork.deliveryPending": "任务已领取,房间消息仍需恢复。", + "roomWork.unknown": "响应结果不确定,请先核对此次请求,再领取其他工作。", + "roomWork.boundary": "领取只确认归属,不会启动执行或发送私有回忆;执行仍需原有授权、配额及租约。", + "roomWork.loading": "正在读取当前房间工作…", + "roomWork.agent": "Agent", + "roomWork.available": "可领取任务", + "roomWork.gates": "用户待决事项", + "roomWork.task": "任务", + "roomWork.none": "无", + "roomWork.observed": "观察时间", + "roomWork.accepted": "历史领取已接受", + "roomWork.owner": "最近回读的当前归属", + "roomWork.pending": "有一项请求等待核对,恢复会沿用原请求键。", + "roomWork.refresh": "刷新当前状态", + "roomWork.recover": "核对此次领取", + "roomWork.claim": "领取任务并发送凭据", "lark.closeConnection": "关闭连接弹窗", "lark.closeCreate": "关闭创建弹窗", "lark.closeSettings": "关闭 Lark 设置", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx index cade2c603e..a341c1b022 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx @@ -1,4 +1,5 @@ import {PrivateConversationPanel} from "./private-conversation-panel"; +import {RoomWorkPanel} from "./room-work-panel"; import { useEffect, useMemo, useRef, useState } from "react"; import { Bot, @@ -207,6 +208,8 @@ export function LarkSettingsPage({ }) { const { t } = useWorkspaceI18n(); const [tab, setTab] = useState("connections"); + const [workConnection, setWorkConnection] = useState(null); + const workTrigger = useRef(null); const [apps, setApps] = useState([]); const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); @@ -594,6 +597,7 @@ export function LarkSettingsPage({ {connection.conversation_kind === "manager" ? t(connection.turn_trigger === "human_messages" ? "lark.triggerHuman" : "lark.triggerAddressed") : connection.capture_scope === "addressed_only" ? t("lark.mentionsOnly") : t("lark.allTopicMessages")} {connection.conversation_kind === "manager" ? t("lark.managerConversation") : ingressPresentation(connection.ingress_mode, t).label}{connection.conversation_kind === "manager" ? t("lark.managerConversationDescription") : connection.agent_id ?? ingressPresentation(connection.ingress_mode, t).detail} + {connection.agent_id && connection.conversation_kind !== "manager" ? : null} @@ -604,6 +608,7 @@ export function LarkSettingsPage({ ) : null} + {workConnection ? setWorkConnection(null)} /> : null} {modalOpen ? (
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/room-work-panel.tsx b/apps/presentation/dashboard/src/features/personal-workspace/room-work-panel.tsx new file mode 100644 index 0000000000..cbc236f97f --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/room-work-panel.tsx @@ -0,0 +1,108 @@ +import "./room-work.css"; +import {useEffect, useRef, useState} from "react"; +import {claimRoomWork, fetchRoomWork, roomClaimIntentSchema, type RoomClaimIntent, type RoomWork} from "../../data/room-work"; +import {ChatApiError, type LarkGoalConnection} from "../../data/chat"; +import {useWorkspaceI18n} from "./i18n"; + +/** Browser storage holds only an uncertain request tuple, never work authority. */ +export function RoomWorkPanel({connection, onClose, triggerElement}: {connection: LarkGoalConnection; onClose: () => void; triggerElement: HTMLElement | null}) { + const {t} = useWorkspaceI18n(); + const storageKey = `loopx.room-work:${connection.goal_id}:${connection.connection_id}`; + const [intent, setIntent] = useState(() => { + try { + const saved = roomClaimIntentSchema.safeParse(JSON.parse(sessionStorage.getItem(storageKey) ?? "null")); + return saved.success && saved.data.goal_id === connection.goal_id && saved.data.connection_id === connection.connection_id ? saved.data : null; + } catch {return null;} + }); + const [work, setWork] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const inFlight = useRef(false); + const alive = useRef(true); + const dialog = useRef(null); + useEffect(() => { + const prior = triggerElement ?? document.activeElement; + dialog.current?.focus(); + function keydown(event: KeyboardEvent) { + if (event.key === "Escape") {event.preventDefault(); event.stopPropagation(); onClose();} + if (event.key !== "Tab") return; + const controls = Array.from(dialog.current?.querySelectorAll("button:not(:disabled), summary") ?? []); + const first = controls[0], last = controls.at(-1); + const outside = !dialog.current?.contains(document.activeElement); + if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.current || outside)) {event.preventDefault(); last?.focus();} + else if (!event.shiftKey && (document.activeElement === last || outside)) {event.preventDefault(); first?.focus();} + } + document.addEventListener("keydown", keydown); + return () => { + document.removeEventListener("keydown", keydown); + if (prior instanceof HTMLElement && prior.isConnected) prior.focus(); + }; + }, []); + + async function refresh() { + if (inFlight.current) return; + inFlight.current = true; setBusy(true); setError(""); + try { + const next = await fetchRoomWork(connection.goal_id, connection.connection_id, intent?.todo_id ?? result?.receipt?.todo_id); + if (alive.current) {setWork(next); if (!next.ok) setError(t("roomWork.unavailable"));} + } catch {if (alive.current) {setWork(null); setError(t("roomWork.unavailable"));}} + finally {inFlight.current = false; if (alive.current) setBusy(false);} + } + useEffect(() => {alive.current = true; void refresh(); return () => {alive.current = false;};}, []); + + async function claim() { + if (inFlight.current) return; + const selected = work?.projection?.selected_todo; + if (!intent && (!work?.ok || !work.claim_available || !selected)) return; + const request = intent ?? {goal_id: connection.goal_id, connection_id: connection.connection_id, + scope_revision: work!.scope_revision, todo_id: selected!.todo_id, + expected_revision: work!.projection!.source_revision, idempotency_key: crypto.randomUUID()}; + // Preserve the original tuple before the effect; a lost response or reload + // can only retry this exact request, never generate another claim key. + try {sessionStorage.setItem(storageKey, JSON.stringify(request));} + catch {setError(t("roomWork.storageUnavailable")); return;} + setIntent(request); inFlight.current = true; setBusy(true); setError(""); + try { + const next = await claimRoomWork(request); + if (!alive.current) return; + setResult(next); setWork(next); + if (next.ok || next.status === "conflict" || next.status === "rejected") { + sessionStorage.removeItem(storageKey); setIntent(null); + } + if (!next.ok) setError(t(next.status === "conflict" ? "roomWork.conflict" : next.canonical_claim_accepted ? "roomWork.deliveryPending" : "roomWork.unavailable")); + } catch (cause) { + if (!alive.current) return; + if (cause instanceof ChatApiError && [400, 409].includes(Number(cause.payload.http_status))) { + sessionStorage.removeItem(storageKey); setIntent(null); setWork(null); + setError(t("roomWork.scopeChanged")); + } else setError(t("roomWork.unknown")); + } finally {inFlight.current = false; if (alive.current) setBusy(false);} + } + const observed = !work?.projection ? null : Object.hasOwn(work.projection, "inspected_todo") + ? work.projection.inspected_todo : result?.receipt; + return
+
+

{t("roomWork.title")}

+

{connection.chat_name} · {connection.goal_title}

+

{t("roomWork.agent")}: {work?.actor_id ?? connection.agent_id}

+

{t("roomWork.boundary")}

+ {work?.ok && !work.claim_available && !result ?

{t("roomWork.readOnly")}

: null} + {error ?

{error}

: null} + {busy ?

{t("roomWork.loading")}

: null} + {work?.projection ?
{t("roomWork.available")}
{work.projection.counts.unclaimed}
+
{t("roomWork.gates")}
{work.projection.counts.user_gates}
: null} + {result?.canonical_claim_accepted && result.receipt ?

{t("roomWork.accepted")}

: null} + {result?.readback_verified ?

{t("roomWork.messageVerified")}

: null} + {observed ?

{t("roomWork.owner")}: {observed.current_owner === undefined ? t("roomWork.unavailable") : observed.current_owner ?? t("roomWork.none")}

: null} + {intent ?

{t("roomWork.pending")}

: null} + {work?.projection ?
{t("roomWork.details")}
+
{t("roomWork.task")}
{intent?.todo_id ?? work.projection.inspected_todo?.todo_id ?? result?.receipt?.todo_id ?? work.projection.selected_todo?.todo_id ?? t("roomWork.none")}
+
{t("roomWork.observed")}
{work.projection.generated_at}
+ {result?.receipt ? <>
{t("roomWork.receipt")}
{result.receipt.receipt_id}
: null} +
: null} +
+ {intent || work?.projection?.selected_todo ? : null}
+
+
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/room-work.css b/apps/presentation/dashboard/src/features/personal-workspace/room-work.css new file mode 100644 index 0000000000..c3d9a1e1b5 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/room-work.css @@ -0,0 +1,11 @@ +/* Scoped to the existing connection dialog; shared theme tokens own presentation. */ +.personal-room-work p, .personal-room-work dd { overflow-wrap: anywhere; } +.personal-room-work { color: var(--pw-text); background: var(--pw-card); } +.personal-room-work dd { margin-inline-start: 0; } +.personal-room-work footer { flex-wrap: wrap; } + +.personal-room-work .personal-room-work-boundary { color: var(--pw-muted); font-size: var(--text-caption); } +.personal-room-work .personal-room-work-counts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.personal-room-work-counts dt { color: var(--pw-muted); font-size: var(--text-caption); } +.personal-room-work .personal-room-work-counts dd { font-size: var(--text-heading); line-height: var(--leading-heading); font-variant-numeric: tabular-nums; } +.personal-room-work header button { min-height: 44px; min-width: 44px; } diff --git a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md index 877d680aca..2888f2e500 100644 --- a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md +++ b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md @@ -284,6 +284,63 @@ Measure outcome quality rather than message volume: - false acceptance or rejection of controlled transitions; - verified goal outcomes produced after handoff. +## Current Delivery Checkpoint + +Task [#5198](https://github.com/loopx-project/loopx/issues/5198) tracks composition. +The first proposed implementation stage is the +[canonical room-work Agent CLI](../../reference/goal-channel-room-work.md): compact +actor-scoped orientation, revision-guarded claim, historical acceptance plus +current ownership, scoped IM claim-button offers/callbacks and existing verified +Goal Channel delivery/readback. It +reuses promoted File/SQLite authority and the typed Todo owner; it does not +replace the shared-service or provisioning owners. + +The claim callback retains its original offer binding witness through member +and card reads into the existing locked claim check. Retargeting during either +provider read rejects before canonical ownership or an acceptance receipt changes. +Its original broker/source registry witnesses now also survive those reads and +the native claim handoff. The existing cross-runtime source guards serialize +registry administration with the canonical commit. Real File/SQLite regressions +check unchanged full state and exact receipts after pre-commit route replacement; +these bounded checks do not qualify independent hosts or native provider IO. + +The proposed packaged workspace companion enters through the existing Lark +connection. It reads canonical room orientation, explicitly claims through the +same owner and reads historical acceptance, current ownership and message +delivery separately. Its HTTP adapter derives the actor from the current +connection and pins the preview's binding/source scope. An uncertain request +survives page reload only as an original retry tuple; the browser gains no +independent work or execution authority. Packaged browser and real File/SQLite +HTTP checks retain synthetic connection/provider transport and independent CLI +readback. These local results do not qualify native room rendering, an installed +App, independently authenticated hosts or live OpenViking. + +The private `work resume` path composes existing scoped Turn Recall with fresh +channel identity, canonical projection and quota observations. It skips memory +ingest, rechecks scope and state after retrieval, discards context on changes, +and carries only explicitly requested scoped record-artifact references covered +by current verified recall receipts. It does not send private context to a room +or accept/renew work. Source-session exact-instance profiles and arbitrary +external artifact targets remain outside this local qualification stage. + +Validation uses synthetic Lark transport with disposable real local stores. +Independent source CLI processes with separate TS runtimes compete for one +File/SQLite claim; a fresh process replays its exact receipt, and direct CLI +readback verifies current ownership. These local clients use trusted registered +Agent identities; independently authenticated remote hosts remain unqualified. +Real non-production room qualification, independent hosts, +daemon reconnect, native callback qualification, external artifact-target access and scoped live +OpenViking retrieval remain unqualified. Design acceptance, a proposed PR and +synthetic checks do not close those requirements or authorize promotion. + +The legacy-profile local stage can be qualified without enabling source-session +business effects. Exact GoalRef support must first qualify the existing shared +authority head/receipt owner, retirement and caller/writer fences under the +[Goal-instance contract](goal-instance-identity-and-orphan-recovery-v0.md). +That later profile boundary does not expand this composition stage into a +parallel authority migration. Existing claim wires reject explicit unqualified +GoalRef intent before provider or historical-receipt access. + ## Open Questions 1. Which LoopX projection fields are stable enough for the first public diff --git a/docs/reference/assets/room-work/claim-desktop.png b/docs/reference/assets/room-work/claim-desktop.png new file mode 100644 index 0000000000..dac4116d77 Binary files /dev/null and b/docs/reference/assets/room-work/claim-desktop.png differ diff --git a/docs/reference/assets/room-work/claim-mobile.png b/docs/reference/assets/room-work/claim-mobile.png new file mode 100644 index 0000000000..9ebcc8d494 Binary files /dev/null and b/docs/reference/assets/room-work/claim-mobile.png differ diff --git a/docs/reference/goal-channel-room-work.md b/docs/reference/goal-channel-room-work.md new file mode 100644 index 0000000000..f29e58b1af --- /dev/null +++ b/docs/reference/goal-channel-room-work.md @@ -0,0 +1,303 @@ +# Canonical room work through the Agent CLI + +This composition stage of [the Agent IM / LoopX / OpenViking RFC](../architecture/rfcs/agent-im-openviking-collaboration-v0.md) +lets a local registered Agent publish compact work orientation and a canonical +claim receipt into its existing Lark Goal Channel. It uses one already promoted +local File or SQLite authority. It does not qualify independent multi-host +service authority or live OpenViking integration. A scoped IM claim button +callback is implemented and tested with synthetic transport. + +The existing Goal Channel connection remains the configuration owner. An exact, +enabled Agent connection and a verified project Bot are required; a default or +sibling connection cannot stand in for the named Agent. No new capability, +provider, scheduler, memory store, or frontend setting is introduced. Python +adapts CLI/provider IO; the TypeScript Todo transaction retains state, CAS, +identity and receipt authority. Identity follows the existing trusted local CLI +model; `--agent-id` is not remote authentication. The typed room read model reuses that claim +admission rule. No existing command automatically publishes work cards. + +## Preview and publish + +### Packaged workspace + +In **Settings → Lark → Connections**, open **Room work** on the existing +Agent connection. Its Goal and Agent come from that connection; the browser +cannot supply a different actor. The view reads current canonical availability +without publishing a card. An inactive or incompatible Goal Channel extension +leaves the view read-only; the existing extension and permission remain the +configuration owner. + +**Claim task and send receipt** explicitly claims the next eligible task at +the displayed revision and sends its receipt through that connection. The +result separates historical acceptance, current ownership and verified room +delivery. It grants no execution authority. A stale revision requires a fresh +view; a changed connection or source route cannot reuse the earlier preview. + +If the response or delivery is uncertain, **Reconcile this claim** retries the +original request. Browser session storage retains only that request tuple so +reload can recover with the same key; it is neither Todo state nor authority. +If session storage cannot preserve the tuple, the browser does not submit the +claim. A failed refresh does not present an earlier ownership observation as +fresh. IDs, observation time and the receipt are available under **Claim +details**. Private recall never enters this panel or the room card. + +The packaged browser scenario `room-work` uses a real disposable local authority +and synthetic connection/provider transport. It verifies duplicate-click +isolation, uncertain delivery, reload/same-key recovery, independent CLI +ownership readback, narrow layouts and keyboard/bilingual presentation. HTTP +regressions also exercise File and SQLite stores, stale revision, connection +retargeting, inactive extensions and foreign-origin/actor input. This does not +qualify an already installed App, a live room or independently authenticated +hosts. + +The images below show the proposed Chinese-locale packaged interaction on +synthetic data: desktop before claiming, and the narrow result after recovery. +They illustrate the local proposal rather than an installed or live room. + +![Packaged room claim on a synthetic connection](assets/room-work/claim-desktop.png) + +[View the narrow recovered result](assets/room-work/claim-mobile.png). + +### Agent CLI + +From a source checkout use `uv run --extra test loopx` in place of `loopx`. +Use the source registry and a previously authorized non-production connection. +Do not promote an active Goal merely to try these commands. Follow the existing +[reviewed promotion](reviewed-coordination-promotion.md) procedure on a disposable +fixture if qualification needs canonical authority. + +```sh +loopx --registry .loopx/registry.json --format json goal-channel work project \ + --goal-id room-goal --agent-id agent-a +``` + +This preview reads canonical state and resolves the exact delivery binding. It +does not contact Lark, accept a claim, or send a message. The projection carries +`source_revision`, `generated_at`, the first eligible unclaimed Todo id and +actor-scoped counts. Task prose, notes, validation declarations, evidence, +artifacts and recalled context are omitted. `authority_state=available` describes +the provider read, not Goal completion or permission to execute. + +Add `--execute` to publish that orientation through the existing verified Bot: + +```sh +loopx --registry .loopx/registry.json --format json goal-channel work project \ + --goal-id room-goal --agent-id agent-a --execute +``` + +## Claim and receipt recovery + +Take `--expected-revision` from the fresh projection and choose a stable +idempotency key. Preview first by omitting `--execute`: + +```sh +loopx --registry .loopx/registry.json --format json goal-channel work claim \ + --goal-id room-goal --agent-id agent-a --todo-id todo_example \ + --expected-revision '' --idempotency-key room-claim-example +``` + +With `--execute`, the local Agent CLI asks the same canonical Todo owner used +by direct `todo claim`, then publishes the receipt and current orientation. +This explicit command authorizes those two effects. Room membership, delivery, +a card or recalled text supplies neither the Agent identity nor write scope. +Identity and binding are reread; binding/target revocation serialize with the +claim transaction. The facade retains original broker/source registry witnesses +before provider reads. Claim request v2 holds their existing cross-runtime +mutation guards through the native canonical commit; drift rejects before a +claim or receipt is written, and a busy source requires a fresh retry. Ordinary +direct claim v0/v1 requests keep their existing behavior and reject v2 guard +fields rather than silently dropping that obligation. + +The provider compares the expected revision inside the claim transaction. +Competing commands at one revision cannot both commit. A stale revision returns +`conflict`; it never silently refreshes its basis. Canonical claims now also +reject a Todo bound to another Agent, matching the lifecycle authority boundary. +This affects direct promoted claims as well as the optional room facade. + +On an uncertain response, retry the **same** Agent, Todo, revision and key. +Changing intent with the same key is rejected. `already_applied` returns +historical acceptance; it does not renew or acquire a lease. The receipt reports +`current_owner` and `current_claim_matches_actor`, and every result declares +`execution_authority_granted=false`. Execution still needs current quota, +applicable gates and, in hard-lease mode, a separate current lease acquisition. + +Check current canonical ownership through the existing direct CLI: + +```sh +loopx --registry .loopx/registry.json --format json todo list --goal-id room-goal +``` + +`canonical_claim_accepted` and `readback_verified` are separate facts. If a +claim committed but room send/readback failed, the result retains acceptance +and reports a recovery requirement. Unknown sends are conservatively reported +as possible external writes. Repeating the same semantic card uses exact Bot +history and provider idempotency rather than minting a new claim. Outages do +not queue unbounded writes or fall back to Markdown authority. After reconnect, +resolve the current binding and canonical revision again; a revoked connection +or identity cannot use an old receipt to resume this facade. + +## Offer a claim in the room + +A trusted local Agent CLI may offer exactly one revision-bound `claim_todo` +interaction to explicitly named Lark principals. This is a per-request grant, +not room membership, remote authentication, an execution lease or a remembered +approval. The broker runtime must still route the Goal to the same source +registry and authority root when a callback arrives. This local facade supports +the existing legacy registry profile. It rejects source-session exact-instance +profiles until the canonical claim wire is explicitly bound to their GoalRef; +an older offer cannot inherit a recreated instance. + +```sh +loopx --registry .loopx/registry.json --format json goal-channel work offer \ + --goal-id room-goal --agent-id agent-a --todo-id todo_example \ + --expected-revision '' --idempotency-key room-offer-example \ + --principal lark:ou_example --expires-at '' +``` + +Preview does not persist a grant, send a card or claim work. Add `--execute` to +persist the bounded private offer and publish its confirmation button through +the verified Goal Channel. The existing collector's explicit v1 +`operation_callbacks.enabled=true` configuration is required to consume +`card.action.trigger`; offering a card does not start or enable that collector. +Existing operation callbacks and CLI `project|claim` defaults remain unchanged. + +The incoming principal must pass current provider tenant/member verification +and the offer's explicit scope. The callback also rechecks Bot/profile, +originating message and exact action-card content, current Goal source route, +Agent registration, channel binding and expiry. Actor, Todo, revision and key +come from the private offer, never from incoming fields or memory. The canonical +Todo transaction then owns the claim and idempotent receipt; the result replaces +the initiating card after independent provider readback. A callback ACK alone +is not an accepted claim. No claim callback acquires or renews execution authority. + +The original broker/source witnesses survive remote member/card reads and the +claim handoff. A route replacement at any of those stages leaves the old-source +Todo, provider revision, exact operation receipt and room messages unchanged. +Normal registry administration serializes with the native claim commit; the +effect runtime owns those guards independently of the Python transport caller. + +Revoke that one offer through the same trusted CLI: + +```sh +loopx --registry .loopx/registry.json --format json goal-channel work revoke \ + --goal-id room-goal --agent-id agent-a --request-id '' --execute +``` + +Revocation, actor removal, channel retargeting, authority-route replacement and +expiry fail closed on subsequent callbacks, including replay. Revoke serializes +with dispatch for that offer. It does not undo a historical accepted claim. +Private offer files preserve intent/scope and message delivery metadata, not an +independently advanceable copy of Todo state. On restart the existing collector +may recover a recorded public result card; recovery only patches/reads that +message and never executes another canonical claim. The current binding and +offer scope still govern recovery. + +Validation uses synthetic Lark transport and real disposable File/SQLite +providers, including source CLI preview and the production collector dispatch +entrypoint. This implements the callback path in code; native Lark rendering, +console/listener setup and independent-host live qualification remain separate. + +## Private read-only reconnect context + +`work resume` composes the existing private +[Agent Turn Recall](../../loopx/capabilities/agent_turn_recall/README.md) path with +current channel identity, canonical projection and quota readback. Use the exact +admitted quota packet saved by the host; the command does not admit another Turn: + +```sh +loopx --registry .loopx/registry.json --format json goal-channel work resume \ + --goal-id room-goal --agent-id agent-a --turn-instance-id '' \ + --quota-decision-json .local/admitted-quota.json +``` + +Preview performs no provider retrieval, receipt write, claim or room send. +Add `--execute` to verify the current Bot identity and restore scoped context. +The existing Reward Memory configuration and Agent enablement receipts remain +the configuration owner; this command never enables or broadens recall. It +explicitly skips pending memory-ingest reconciliation even when ordinary +automatic recall enables that behavior. Normal automatic recall retains its +existing default. Retrieval may write the existing private local recall receipt; +it performs no memory-provider write, quota spend, lease renewal or room delivery. + +Restore always retrieves anew rather than trusting an earlier same-Turn context +receipt. It checks current source route, Agent registration, exact channel, +Bot identity and configured memory scope, then reads canonical state and quota. +After retrieval it checks those observations again. A changed binding, scope, +Todo revision, selected work or gate discards the context and references. A +failed scope/readback check also omits every earlier quota/projection snapshot; +those observations are not returned as current. A healthy fresh observation +with no selected work can still show the current gate while discarding context. +A provider outage or disabled configuration leaves the fresh LoopX observations +available with `status=context_unavailable`; it does not manufacture a user gate. +Exact-instance source-session profiles remain unsupported and fail closed. +The current direct canonical Todo facade is profile-gated too: its witnessed +claim wire and provider receipts identify `goal_id`, without an exact GoalRef. +Supporting that profile must first bind the existing Todo head/receipt owner to +the lifetime contract; a room adapter cannot bypass the codec gate or substitute +provider incarnation/revision for Goal identity. See the +[Goal-instance owner boundary](../architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.md#roadmap-placement-and-contract-cooperation). +The current Goal-id claim wire now rejects any explicit `goal_ref` property, +including null, before provider or old-receipt access. Previously that field was +ignored, so both a fresh claim and a same-key replay could look lifetime-bound +without actually checking the instance. Existing callers that omit the field +retain their behavior; supplying an unqualified lifetime reference is a +machine-enforced protocol failure, not optional guidance or instance support. + +To carry an inspected reference, repeat `--artifact-ref ''` up to +eight times. Only explicitly requested references inside the current configured +read scope and covered by the current accepted recall's verified application +receipt are returned. Unknown, expired or out-of-scope references are omitted. +This stage carries pointers to scoped retrieved record artifacts; it does not +qualify arbitrary external artifact targets or fetch them through a new provider. +A pointer never grants access to its target or proves completion. + +The JSON result has `visibility=private`, `private_context`, bounded +`artifact_references`, fresh `current_quota` and content-minimal `work_projection`. +Do not copy that private packet into a room, public evidence or shared logs. +Remembered approvals cannot satisfy current gates, and restored context grants +no execution authority. Reconcile an uncertain prior claim separately with the +original `work claim` tuple/key; resume itself never replays a work command. + +## Remaining qualification + +The new checks use synthetic Lark transport and real disposable File/SQLite +stores. Two independent source CLI processes, each served by a separate TS +runtime, compete at the same canonical revision. One accepts the claim; a new +client/runtime recovers its exact receipt without another transition or card. +A separate direct CLI reads current ownership. This proves the supported local +process boundary; each client still uses the trusted local Agent identity model. +They are not evidence of a real room, two independently authenticated hosts, +a live daemon reconnect, or OpenViking provider qualification. Those checks need +an explicitly authorized non-production room, actor bindings and read-only +resource scope. Existing shared authority and provisioning tasks retain their +ownership; this stage does not promote or deploy them. + +Source-session activation is a later owner boundary, not a prerequisite for +qualifying this legacy-profile local stage. Its lifetime-bound head and +operation receipts, retirement serialization and old-writer fencing belong to +the existing [shared authority](../architecture/rfcs/shared-goal-authority-state-provider-v0.md) +and [Goal-instance](../architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.md) +contracts. Keep that profile closed until those owners qualify the full path; +adding a GoalRef to a room request cannot provide that qualification. + +To disable publication, stop issuing the optional `work ... --execute` commands +or disconnect the exact connection through the existing Goal Channel connection +owner. This does not delete canonical claims or retract their receipts. Revert +this change to remove the optional CLI facade; existing direct Todo CLI and +Goal Channel configuration remain usable. + +## 中文边界 + +本阶段组合已有的 Agent CLI、Goal Channel 与 File/SQLite Todo 权威。默认仅预览; +显式 `--execute` 才请求领取并发布房间回执。没有新增配置 owner 或前端开关, +不自动发送卡片,也不把群成员、卡片、投递或记忆当作执行权限。 + +竞争领取在权威事务内校验 revision;相同 key 的重试恢复历史接受结果,不能续租。 +当前 owner、历史回执与消息读回分别呈现。领取成功但消息失败时保留接受事实; +重新连接后复核当前身份、连接、权威、quota 和 lease。绑定给其他 Agent 的任务会被拒绝, +这一修复也适用于直接的 promoted Todo claim。 + +公开卡片仅包含标识、计数与安全回执,不携带任务正文、证据、artifact 或记忆内容。 +已提供单次授权的 IM 领取按钮、显式撤销和结果卡片恢复;默认不启用 collector。 +真实房间、多主机、daemon reconnect、授权 artifact 引用和 OpenViking 只读检索仍待联调。 +合成 Lark transport 加真实隔离权威的测试不能宣称三方 RFC 已完成。 diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 80d6a4f4f9..e94e4314d5 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { selectScenarioShard } from "./personal-workspace-browser/shard.mjs"; +import {roomWorkScenario} from "./personal-workspace-browser/room-work.mjs"; import { zcodeGoalScenario } from "./personal-workspace-browser/zcode-goal.mjs"; import {goalWorkspaceCreateScenario} from "./personal-workspace-browser/goal-workspace-create.mjs"; import {agentModelSelectionScenario} from "./personal-workspace-browser/agent-model-selection.mjs"; @@ -121,6 +122,7 @@ scenarioCatalog.push(goalSuggestionsScenario); scenarioCatalog.push(goalSuggestionsLiveScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; scenarioCatalog.push(agentModelSelectionScenario); +scenarioCatalog.push(roomWorkScenario); const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) : selectScenarioShard(scenarioCatalog, process.env.LOOPX_PERSONAL_WORKSPACE_SHARD); diff --git a/examples/personal-workspace-browser/automation-cadence.mjs b/examples/personal-workspace-browser/automation-cadence.mjs index 050d5631f0..a30b566c81 100644 --- a/examples/personal-workspace-browser/automation-cadence.mjs +++ b/examples/personal-workspace-browser/automation-cadence.mjs @@ -5,14 +5,14 @@ import { resolve } from "node:path"; import { createInterface } from "node:readline"; import { promisify } from "node:util"; -import { resolveTestPython } from "../../scripts/test-python.mjs"; +import { testPythonCommand } from "../../scripts/test-python.mjs"; import { outputDir, repoRoot } from "./fixture.mjs"; import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs"; const execFileAsync = promisify(execFile); async function admitCadenceStart(root, agentId) { - const { stdout } = await execFileAsync(resolveTestPython(), ["-c", ` + const python = testPythonCommand(` import json from pathlib import Path import sys @@ -29,7 +29,8 @@ result = effect_runtime_result("quota.automation_cadence.admit", { "trigger_at_ms": now_ms, }, retry_safe=False) print(json.dumps(result)) -`, root, agentId], { cwd: repoRoot }); +`); + const { stdout } = await execFileAsync(python.command, [...python.args, root, agentId], { cwd: repoRoot }); return JSON.parse(stdout); } @@ -40,7 +41,8 @@ export async function startCadenceAuthority() { await writeFile(resolve(root, "registry.json"), JSON.stringify({ schema_version: "0.1", common_runtime_root: resolve(root, "runtime"), goals: [{ id: "multi-agent-projection" }], })); - const child = spawn(resolveTestPython(), ["-u", "-c", ` + // Match the main server's explicit source/installed package provenance. + const python = testPythonCommand(` from pathlib import Path import sys from loopx.chat_server import ChatHTTPServer, ChatRequestHandler @@ -55,7 +57,8 @@ assert Path(route["target_runtime_root"]).resolve() == server.runtime_root.resol server.verbose = False print(server.server_address[1], flush=True) server.serve_forever() -`, root], { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] }); +`); + const child = spawn(python.command, [...python.args, root], { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] }); let diagnostic = ""; child.stderr.on("data", (chunk) => { diagnostic = (diagnostic + chunk).slice(-2000); }); const lines = createInterface({ input: child.stdout }); diff --git a/examples/personal-workspace-browser/room-work.mjs b/examples/personal-workspace-browser/room-work.mjs new file mode 100644 index 0000000000..24153710e4 --- /dev/null +++ b/examples/personal-workspace-browser/room-work.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import {spawn, spawnSync} from "node:child_process"; +import {mkdtemp, realpath, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join, resolve} from "node:path"; +import {createInterface} from "node:readline"; +import {repoRoot, outputDir} from "./fixture.mjs"; +import {fetchWorkspaceBackend, openWorkspacePage} from "./scenario-context.mjs"; +import {testPythonCommand} from "../../scripts/test-python.mjs"; + +const connection = {agent_id:"agent-a",connection_id:"agent-a",conversation_kind:"goal",app_label:"Fixture Bot",app_ref:"room-fixture", + chat_name:"Product group",enabled:true,capture_scope:"addressed_only",goal_id:"product-release",goal_title:"Product Release", + health_error_code:null,incoming_mode:"mentions",ingress_mode:"async_inbox",event_count:1,last_event_reason:null,last_event_status:"replied_and_acknowledged", + listener_error_code:null,listener_status:"listening",replied_count:1,reply_ready:true,reply_mode:"topic_reply",target_ref:"fixture",topic_name:"Product Release",topic_setup_required:false}; + +export const roomWorkScenario = { + id: "room-work", + async run({browser, url}) { + const root = await realpath(await mkdtemp(join(tmpdir(), "loopx-room-work-browser-"))); + const code = `import runpy; runpy.run_path(${JSON.stringify(resolve(repoRoot,"examples/personal-workspace-browser/room_work_fixture.py"))},run_name="__main__")`; + const python = testPythonCommand(code); + const child = spawn(python.command, [...python.args, root], {cwd:repoRoot, stdio:["ignore","pipe","pipe"]}); + let diagnostics="", context; + child.stderr.on("data", x => {diagnostics=(diagnostics+x).slice(-8000);}); + const exited = new Promise(accept => child.once("exit",accept)); + try { + const ready = await new Promise((accept,reject) => { + const lines=createInterface({input:child.stdout}); + const timer=setTimeout(()=>reject(new Error(`room backend readiness: ${diagnostics}`)),30000); + lines.once("line", line=>{clearTimeout(timer);lines.close();try{accept(JSON.parse(line));}catch(e){reject(e);}}); + child.once("exit", code=>{clearTimeout(timer);reject(new Error(`room backend exited ${code}: ${diagnostics}`));}); + }); + const backend=`http://127.0.0.1:${ready.port}`; + const before=await (await fetch(backend+"/fixture/readback")).json(); + const submitted=[], responses=[]; + context=await openWorkspacePage(browser,url,{beforeGoto:async(api,page)=>{ + api.larkConnections.push(connection); + await page.route("**/api/chat/lark/room-work**",async route=>{ + if(route.request().method()==="POST")submitted.push(route.request().postDataJSON()); + const response=await fetchWorkspaceBackend(route,backend); + responses.push(await response.json());await route.fulfill({response}); + }); + }}); + const {page}=context; + async function open() { + await page.getByRole("button",{name:"设置",exact:true}).click(); + await page.locator(".personal-settings-tabs").getByRole("button",{name:"Lark",exact:true}).click(); + await page.screenshot({path:resolve(outputDir,"room-work-connections.png"),animations:"disabled"}); + await page.getByRole("button",{name:"房间工作",exact:true}).click(); + await page.getByRole("button",{name:"刷新当前状态"}).waitFor(); + await page.getByRole("button",{name:"刷新当前状态"}).waitFor({state:"visible"}); + await page.waitForFunction(()=>!document.querySelector("[role=dialog] button:disabled")); + } + await open(); + await page.screenshot({path:resolve(outputDir,"room-work-before-claim.png"),animations:"disabled"}); + await page.getByRole("dialog").screenshot({path:resolve(outputDir,"room-work-dialog-desktop.png"),animations:"disabled"}); + await page.evaluate(()=>{ + window.__roomWorkSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = function(key,value) { + if (this === sessionStorage && key.startsWith("loopx.room-work:")) throw new DOMException("fixture storage unavailable"); + return window.__roomWorkSetItem.call(this,key,value); + }; + }); + await page.getByRole("button",{name:"领取任务并发送凭据"}).click(); + await page.getByText("浏览器无法保存恢复请求。请启用会话存储后再领取。",{exact:true}).waitFor(); + assert.equal(submitted.length,0,"unrecoverable browser storage cannot start a claim"); + await page.evaluate(()=>{Storage.prototype.setItem = window.__roomWorkSetItem; delete window.__roomWorkSetItem;}); + await fetch(backend+"/fixture/lose-message-response",{method:"POST"}); + await page.getByRole("button",{name:"领取任务并发送凭据"}).evaluate(button=>{button.click();button.click();}); + await page.getByText("任务已领取,房间消息仍需恢复。",{exact:true}).waitFor(); + assert.equal(submitted.length,1,"synchronous duplicate click does not mint another request"); + const accepted=await (await fetch(backend+"/fixture/readback")).json(); + assert.notEqual(accepted.snapshot.provider_revision,before.snapshot.provider_revision); + await page.reload({waitUntil:"networkidle"}); + await page.getByTestId("personal-goal-home").waitFor(); + await open(); + await page.getByRole("button",{name:"核对此次领取"}).click(); + await page.getByText("历史领取已接受",{exact:true}).waitFor(); + await page.getByText("房间凭据已确认",{exact:true}).waitFor(); + assert.deepEqual(submitted[1],submitted[0],"reload recovery reuses exact intent and original key"); + const final=await (await fetch(backend+"/fixture/readback")).json(); + assert.equal(final.messages,1);assert.equal(final.snapshot.provider_revision,accepted.snapshot.provider_revision); + assert.equal(final.snapshot.todos[0].claimed_by,"agent-a"); + const cli=testPythonCommand(`from loopx.cli import main; main(["--registry",${JSON.stringify(ready.registry)},"--format","json","todo","list","--goal-id","product-release"])`); + const direct=spawnSync(cli.command,cli.args,{cwd:repoRoot,encoding:"utf8"}); + assert.equal(direct.status,0,direct.stderr);assert.equal(JSON.parse(direct.stdout).todos[0].claimed_by,"agent-a"); + await page.getByRole("button",{name:"刷新当前状态"}).click(); + await page.getByText("最近回读的当前归属: agent-a",{exact:true}).waitFor(); + assert.equal(JSON.stringify(responses).includes("Synthetic private task text"),false); + assert.ok(responses.every(p=>p.execution_authority_granted===false)); + await page.screenshot({path:resolve(outputDir,"room-work-recovered.png"),animations:"disabled"}); + await page.setViewportSize({width:390,height:844}); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); + await page.screenshot({path:resolve(outputDir,"room-work-mobile.png"),animations:"disabled"}); + await page.getByRole("dialog").screenshot({path:resolve(outputDir,"room-work-dialog-mobile.png"),animations:"disabled"}); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({state:"hidden"}); + await page.waitForFunction(()=>document.activeElement?.textContent === "房间工作"); + await page.getByRole("button",{name:"房间工作",exact:true}).click(); + await page.getByRole("dialog").waitFor(); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({state:"hidden"}); + await page.setViewportSize({width:1512,height:982}); + await page.addInitScript(()=>localStorage.setItem("loopx-pw-locale","en")); + await page.reload({waitUntil:"networkidle"}); + await page.getByTestId("personal-goal-home").waitFor(); + await page.getByRole("button",{name:"Settings",exact:true}).click(); + await page.locator(".personal-settings-tabs").getByRole("button",{name:"Lark",exact:true}).click(); + await page.getByRole("button",{name:"Room work",exact:true}).click(); + await page.getByText("Available tasks",{exact:true}).waitFor(); + await page.keyboard.press("Tab"); + assert.equal(await page.getByRole("button",{name:"Close room work"}).evaluate(button=>button===document.activeElement),true); + await page.keyboard.press("Shift+Tab"); + assert.equal(await page.getByRole("button",{name:"Refresh current state"}).evaluate(button=>button===document.activeElement),true); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); + await page.setViewportSize({width:390,height:844}); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); + await page.screenshot({path:resolve(outputDir,"room-work-english.png"),animations:"disabled"}); + return {coverageEntries:context.coverageEntries,errors:context.errors,note:"Packaged room UI -> real File authority, explicit claim, duplicate click, uncertain delivery plus reload/same-key recovery, one room card and independent CLI current-owner readback; provider transport and connection discovery synthetic."}; + } finally { + await context?.close();child.kill("SIGTERM");const timer=setTimeout(()=>child.kill("SIGKILL"),5000); + try{await exited;}finally{clearTimeout(timer);await rm(root,{recursive:true,force:true,maxRetries:10,retryDelay:100});} + } + }, +}; diff --git a/examples/personal-workspace-browser/room_work_fixture.py b/examples/personal-workspace-browser/room_work_fixture.py new file mode 100644 index 0000000000..2df225358f --- /dev/null +++ b/examples/personal-workspace-browser/room_work_fixture.py @@ -0,0 +1,102 @@ +"""Synthetic Lark transport around a disposable real room-work HTTP backend.""" +import json +import os +import signal +from pathlib import Path +import sys +import tempfile + +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.extensions.lark import goal_channel_work as work +from loopx.extensions.lark.goal_channel_contracts import write_goal_channel_binding + +ROOT = Path(__file__).resolve().parents[2] + + +class SyntheticRoom: + def __init__(self): + self.messages = [] + self.fail_send = False + + def __call__(self, args, cwd, timeout): + payload = {} + if "auth" in args: + payload = {"appId": "cli_room_fixture", "identities": {"bot": {"available": True, "verified": True, "appName": "Fixture Bot"}}} + elif "+chat-members-list" in args: + payload = {"data": {"bots": [{"app_id": "cli_room_fixture"}]}} + elif "+messages-send" in args: + card = json.loads(args[args.index("--content") + 1]) + message = {"message_id": f"om_room_{len(self.messages)}", "chat_id": "oc_room_fixture", + "sender": {"sender_type": "app", "id": "cli_room_fixture"}, "body": {"content": json.dumps(card)}} + self.messages.append(message) + if self.fail_send: + self.fail_send = False + return {"returncode": 1, "stdout": "", "stderr": "", "timed_out": True} + payload = {"data": {"message_id": message["message_id"]}} + elif "+messages-mget" in args or "+chat-messages-list" in args: + payload = {"data": {"items": self.messages, "has_more": False}} + return {"returncode": 0, "stdout": json.dumps(payload), "stderr": ""} + + +def serve(root): + sys.path.insert(0, str(ROOT / "tests/control_plane")) + from canonical_authority_fixture import initialize_canonical_authority + + runtime, registry, state = root / "runtime", root / ".loopx/registry.json", root / "state.md" + registry.parent.mkdir() + state.write_text("## Agent Todo\n") + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{"id": "product-release", "repo": str(root), + "state_file": str(state), "coordination": {"registered_agents": ["agent-a", "agent-b"]}}]})) + todos = [{"schema_version": "todo_item_v0", "todo_id": "todo_room_fixture", "role": "agent", "status": "open", "done": False, + "text": "Synthetic private task text", "archive_state": "active", "source_section": "Agent Todo", "index": 1, "task_class": "advancement_task"}] + projection = build_todo_runtime_shadow_projection(goal_id="product-release", todos=todos, handoff_mode="soft_claim") + initialize_canonical_authority(runtime, "product-release", projection, state_path=state) + binding_path = registry.parent / "goal-channel.json" + binding = {"schema_version": "loopx_goal_channel_lark_binding_v0", "bindings": {"product-release": { + "schema_version": "loopx_goal_channel_connection_set_v0", "connections": {actor: {"goal_id": "product-release", "agent_id": actor, + "provider": "lark", "enabled": True, "channel": {"chat_id": "oc_room_fixture"}, "identity": {"mode": "project_bot", + "sender_identity": "bot", "sender_profile": "room-fixture", "bot_app_id": "cli_room_fixture", "bot_display_name": "Fixture Bot", "cli_bin": "fixture-cli"}} + for actor in ["agent-a", "agent-b"]}}}} + write_goal_channel_binding(binding_path, binding) + room = SyntheticRoom() + # The fixture explicitly enables its synthetic extension; no real profile is changed. + work.resolve_extension_activation = lambda *a, **k: {"enabled": True} + + class Handler(ChatRequestHandler): + def do_GET(self): + if self.path == "/fixture/readback": + return self._send_json({"snapshot": read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="product-release"), + "messages": len(room.messages)}) + return super().do_GET() + + def do_POST(self): + if self.path == "/fixture/lose-message-response": + room.fail_send = True + return self._send_json({"ok": True}) + return super().do_POST() + + server = ChatHTTPServer(("127.0.0.1", 0), Handler) + server.registry_path = registry + server.runtime_root = runtime + server.runtime_root_override = str(runtime) + server.lark_runner = room + server.verbose = False + print(json.dumps({"port": server.server_port, "registry": str(registry), "module": work.__file__}), flush=True) + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) + try: + server.serve_forever() + finally: + from loopx.control_plane.effect_runtime import effect_runtime_result + effect_runtime_result("runtime.shutdown", {}, retry_safe=False) + server.server_close() + + +if __name__ == "__main__": + root = Path(sys.argv[1]) + scratch = root / "tmp" + scratch.mkdir() + os.environ["TMPDIR"] = str(scratch) + tempfile.tempdir = str(scratch) + serve(root) diff --git a/loopx/capabilities/agent_turn_recall/README.md b/loopx/capabilities/agent_turn_recall/README.md index 55543d7678..43cbe648d5 100644 --- a/loopx/capabilities/agent_turn_recall/README.md +++ b/loopx/capabilities/agent_turn_recall/README.md @@ -85,6 +85,13 @@ Retrieval relevance and action applicability remain separate. Recalled guidance is conditional private context; the agent must compare it with the exact turn situation and current authority before acting. +The optional Goal Channel `work resume` composition uses a read-only invocation: +it refreshes recall instead of reusing a previous receipt and disables pending +ingest reconciliation for that invocation. Ordinary automatic admission retains +its existing reconciliation behavior. See the +[room-work usage contract](../../../docs/reference/goal-channel-room-work.md#private-read-only-reconnect-context) +for current authority readback and private reference handling. + ## Freshness And Failure Active Reward Memory records may carry `lifecycle.expires_at`. Retrieval ignores diff --git a/loopx/capabilities/agent_turn_recall/cli.py b/loopx/capabilities/agent_turn_recall/cli.py index f8a7905554..e8041d9403 100644 --- a/loopx/capabilities/agent_turn_recall/cli.py +++ b/loopx/capabilities/agent_turn_recall/cli.py @@ -100,7 +100,7 @@ def _receipt_path(repo: Path, goal_id: str, agent_id: str) -> Path: ) -def _quota_decision(path_value: str) -> dict[str, Any]: +def load_turn_quota_decision(path_value: str) -> dict[str, Any]: try: if path_value == "-": payload = json.load(sys.stdin) @@ -206,7 +206,7 @@ def handle_agent_turn_recall_command( } else: identity = _identity_scope(config) - quota_decision = _quota_decision(args.quota_decision_json) + quota_decision = load_turn_quota_decision(args.quota_decision_json) _validate_quota_identity( quota_decision, goal_id=args.goal_id, diff --git a/loopx/capabilities/agent_turn_recall/runtime.py b/loopx/capabilities/agent_turn_recall/runtime.py index b6173ff93c..e3f998b28e 100644 --- a/loopx/capabilities/agent_turn_recall/runtime.py +++ b/loopx/capabilities/agent_turn_recall/runtime.py @@ -226,6 +226,7 @@ def run_configured_agent_turn_recall( execute: bool, session_ref: str | None = None, force_refresh: bool = False, + reconcile_pending_ingests: bool = True, observed_at: str | None = None, provider: ContextProvider | None = None, ) -> dict[str, Any]: @@ -243,7 +244,7 @@ def run_configured_agent_turn_recall( status=experiment_status, ) reconciliation: dict[str, Any] | None = None - if execute and config["automation"]["automatic_ingest"] is True: + if execute and reconcile_pending_ingests and config["automation"]["automatic_ingest"] is True: from ..reward_memory.outcome_lifecycle import ( reconcile_pending_turn_outcome_ingests_fail_open, ) diff --git a/loopx/chat_lark_api.py b/loopx/chat_lark_api.py index 9b3291380c..9b4d763923 100644 --- a/loopx/chat_lark_api.py +++ b/loopx/chat_lark_api.py @@ -29,6 +29,7 @@ from .extensions.lark.goal_topic_batch import connect_lark_goal_topics from .extensions.lark.cli_transport import CommandRunner, default_subprocess_runner from .chat_agent import CodexChatAgentError +from .presentation.room_work_api import RoomWorkChatRequestMixin from .chat_manager import ( controller_runtime_root, manager_channel, @@ -329,7 +330,7 @@ def session_matches( } -class LarkChatRequestMixin: +class LarkChatRequestMixin(RoomWorkChatRequestMixin): """Loopback HTTP handlers for Goal repository and Lark topic connections.""" server: Any diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 488818eb06..74877f825b 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -66,6 +66,7 @@ SSH_HOST_CATALOG_PATH, ssh_host_catalog_payload, ) +from .presentation.room_work_api import ROOM_WORK_PATH from .chat_lark_api import ( LarkChatRequestMixin, build_goal_repository_contexts as build_goal_repository_contexts, @@ -1531,6 +1532,7 @@ def do_GET(self) -> None: CHAT_LARK_APPS_PATH: self._lark_apps, CHAT_LARK_CHATS_PATH: self._lark_chats, CHAT_LARK_CONNECTIONS_PATH: self._lark_connections, + ROOM_WORK_PATH: self._room_work, PRIVATE_CONVERSATIONS_PATH: self._private_conversations, CHAT_GOAL_CHANNEL_TARGETS_PATH: self._goal_channel_targets, **self._configuration_get_routes(), @@ -1584,6 +1586,7 @@ def do_POST(self) -> None: CHAT_GOAL_CHANNEL_CONFIGURE_PATH: self._goal_channel_configure, CHAT_LARK_APP_SETUPS_PATH: self._lark_setup_start, CHAT_LARK_CONNECTIONS_PATH: self._lark_connect, + ROOM_WORK_PATH: lambda: self._room_work(claim=True), PRIVATE_CONVERSATIONS_PATH: self._private_conversation_connect, PRIVATE_CONVERSATIONS_PATH + "/agent-targets": self._private_conversation_agent_target, **self._configuration_post_routes(), diff --git a/loopx/cli_commands/goal_channel.py b/loopx/cli_commands/goal_channel.py index e8dd0d50f3..ca2733ff9a 100644 --- a/loopx/cli_commands/goal_channel.py +++ b/loopx/cli_commands/goal_channel.py @@ -231,6 +231,30 @@ def register_goal_channel_commands( _add_common_args, ) + work = sub.add_parser("work", help="Publish canonical room orientation or claim through the local Agent CLI.") + work_sub = work.add_subparsers(dest="goal_channel_work_command", required=True) + for work_command in ("project", "claim", "offer", "revoke", "resume"): + work_parser = work_sub.add_parser(work_command) + add_subcommand_format(work_parser) + _add_common_args(work_parser) + work_parser.add_argument("--agent-id", required=True) + work_parser.add_argument("--execute", action="store_true") + if work_command in {"claim", "offer"}: + work_parser.add_argument("--todo-id", required=True) + work_parser.add_argument("--expected-revision", required=True) + work_parser.add_argument("--idempotency-key", required=True) + if work_command == "offer": + work_parser.add_argument("--principal", action="append", required=True) + work_parser.add_argument("--expires-at", required=True) + if work_command == "revoke": + work_parser.add_argument("--request-id", required=True) + if work_command == "resume": + work_parser.add_argument("--turn-instance-id", required=True) + work_parser.add_argument("--quota-decision-json", required=True, + help="Exact admitted quota packet saved in local private state.") + work_parser.add_argument("--artifact-ref", action="append", + help="Carry only this reference if accepted by the current scoped recall; repeat up to eight.") + register_goal_channel_runtime_commands(sub, add_subcommand_format) @@ -470,6 +494,45 @@ def handle_goal_channel_command( runtime_root_arg, registry_path=registry_path, ) + if command == "work": + from ..extensions.lark.goal_channel_work import run_goal_channel_work + + assert goal_id is not None + _, source_registry_path, work_binding_path, source_runtime_root = _source_context( + registry=registry, registry_path=registry_path, goal_id=goal_id, + binding_path_arg=getattr(args, "binding_path", None)) + if args.goal_channel_work_command == "resume": + from ..capabilities.agent_turn_recall.cli import load_turn_quota_decision + from ..extensions.lark.room_resume import run_room_resume + + payload = run_room_resume(registry_path=source_registry_path, authority_root=source_runtime_root, + broker_root=runtime_root, binding_path=work_binding_path, target_path=_target_path(args, runtime_root), + goal_id=goal_id, actor_id=args.agent_id, turn_instance_id=args.turn_instance_id, + quota_decision=load_turn_quota_decision(args.quota_decision_json), execute=execute, + artifact_refs=args.artifact_ref, + read_current_quota=lambda: _quota_packet(registry_path=source_registry_path, + runtime_root_arg=str(source_runtime_root), goal_id=goal_id, agent_id=args.agent_id)) + elif args.goal_channel_work_command in {"offer", "revoke"}: + from ..extensions.lark.room_claim import run_room_claim_offer + + payload = run_room_claim_offer(registry_path=source_registry_path, + authority_root=source_runtime_root, broker_root=runtime_root, + binding_path=work_binding_path, target_path=_target_path(args, runtime_root), + goal_id=goal_id, actor_id=args.agent_id, command=args.goal_channel_work_command, + execute=execute, todo_id=getattr(args, "todo_id", None), + expected_revision=getattr(args, "expected_revision", None), + idempotency_key=getattr(args, "idempotency_key", None), + principals=getattr(args, "principal", None), expires_at=getattr(args, "expires_at", None), + request_id=getattr(args, "request_id", None)) + else: + payload = run_goal_channel_work(registry_path=source_registry_path, + runtime_root=source_runtime_root, binding_path=work_binding_path, + target_path=_target_path(args, runtime_root), goal_id=goal_id, actor_id=args.agent_id, + command=args.goal_channel_work_command, execute=execute, + todo_id=getattr(args, "todo_id", None), expected_revision=getattr(args, "expected_revision", None), + idempotency_key=getattr(args, "idempotency_key", None), invoked_registry_path=registry_path) + print_payload(payload, output_format(args), render_goal_channel_markdown) + return 0 if payload.get("ok") else 1 if command == "runtime": assert goal_id is not None source_registry, source_registry_path, _, _ = _source_context( diff --git a/loopx/control_plane/coordination/local_authority.py b/loopx/control_plane/coordination/local_authority.py index 689fb1aea8..1d0ef34abc 100644 --- a/loopx/control_plane/coordination/local_authority.py +++ b/loopx/control_plane/coordination/local_authority.py @@ -34,6 +34,7 @@ LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA = ( "loopx_local_coordination_todo_claim_request_v1" ) +LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA = "loopx_local_coordination_todo_claim_request_v2" LOCAL_COORDINATION_TODO_CLAIM_METHOD = "coordination.local_authority.todo_claim" @@ -97,10 +98,12 @@ def claim_canonical_todo_if_promoted( actor_agent_id: str | None, dry_run: bool, operation_id: str | None = None, + expected_provider_revision: str | None = None, task_lease_idempotency_key: str | None = None, task_lease_expected_version: int | None = None, project: Path | None = None, state_file: Path | None = None, + registry_sources: list[dict[str, str]] | None = None, ) -> dict[str, Any] | None: """Route a post-cutover claim to the TypeScript transaction owner.""" @@ -111,7 +114,8 @@ def claim_canonical_todo_if_promoted( result = effect_runtime_result( LOCAL_COORDINATION_TODO_CLAIM_METHOD, { - "schema_version": LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA, + "schema_version": (LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA + if registry_sources is None else LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA), "runtime_root": str(runtime_root.expanduser().resolve(strict=False)), "goal_id": goal_id, "todo_id": todo_id, @@ -120,11 +124,14 @@ def claim_canonical_todo_if_promoted( "actor_agent_id": actor_agent_id, "registered_agents": registered, "registry_source": registry_source, + **({"registry_sources": registry_sources} if registry_sources is not None else {}), "operation_id": ( operation_id if operation_id is not None else f"todo-claim:{goal_id}:{todo_id}:{uuid4().hex}" ), + **({"expected_provider_revision": expected_provider_revision} + if expected_provider_revision is not None else {}), "lease_request": ( { "idempotency_key": task_lease_idempotency_key, diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 4e7760e5e1..aebbe19ed6 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1,4 +1,4 @@ -import {requirePromotionRegisteredAgents} from "./shadow_registry_source.ts"; +import {requirePromotionRegisteredAgents, withRegistryAuthoritySources} from "./shadow_registry_source.ts"; import {verifyReentrySettlement} from "./todo_terminal_reentry.ts"; import {readPromotionReceipt, commitPromotionAndReadBack} from './promotion_receipt.ts'; import {reviewedPromotionPlan, promotionPlanDigest, decodeReviewedPromotionOperation, REVIEWED_PROMOTION_OPERATION_RESULT_SCHEMA} from './reviewed_promotion_plan.ts'; @@ -65,6 +65,7 @@ import { import { COORDINATION_TODO_CLAIM_RESULT_SCHEMA, executeCoordinationTodoClaim, + unqualifiedGoalRefClaimFailure, } from "./todo_claim.ts"; import { COORDINATION_TODO_CREATE_RESULT_SCHEMA, @@ -105,6 +106,7 @@ import { export const LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA = "loopx_local_coordination_todo_claim_request_v0"; export const LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA = "loopx_local_coordination_todo_claim_request_v1"; +export const LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA = "loopx_local_coordination_todo_claim_request_v2"; export const LOCAL_COORDINATION_TODO_CREATE_REQUEST_SCHEMA = "loopx_local_coordination_todo_create_request_v0"; export const LOCAL_COORDINATION_TODO_CREATE_WITNESSED_REQUEST_SCHEMA = "loopx_local_coordination_todo_create_request_v1"; @@ -1025,9 +1027,34 @@ export async function claimLocalCoordinationTodo( try { const input = requireJsonObject(value, "local coordination Todo claim request"); if (input.schema_version !== LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA && - input.schema_version !== LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA) { + input.schema_version !== LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA && + input.schema_version !== LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA) { throw new Error("local coordination Todo claim request schema mismatch"); } + const scopeFailure = unqualifiedGoalRefClaimFailure(input); + if (scopeFailure !== null) return {...scopeFailure, source_authority: null, + decision_read_from_provider: false, legacy_fallback_used: false}; + if (input.schema_version === LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA) { + try { + const guarded = structuredClone(input); + const sources = guarded.registry_sources; + delete guarded.registry_sources; + guarded.schema_version = LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA; + return await withRegistryAuthoritySources(sources, guarded.registry_source, + () => claimLocalCoordinationTodo(guarded, dependencies)); + } catch (error) { + return {schema_version: COORDINATION_TODO_CLAIM_RESULT_SCHEMA, status: "failed", + reason_code: error instanceof ShadowManagementError ? error.reason_code : "invalid_local_coordination_todo_claim_request", + reason: error instanceof Error ? error.message : "invalid registry source guards", + source_authority: null, decision_read_from_provider: false, legacy_fallback_used: false}; + } + } + if (Object.hasOwn(input, "registry_sources")) { + return {schema_version: COORDINATION_TODO_CLAIM_RESULT_SCHEMA, status: "failed", + reason_code: "invalid_local_coordination_todo_claim_request", failure_kind: "protocol_failure", + reason: "registry_sources requires claim request v2", source_authority: null, + decision_read_from_provider: false, legacy_fallback_used: false}; + } const authoritySourcesCurrent = registryAuthoritySourceCheck(input, input.schema_version === LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA); if (typeof input.dry_run !== "boolean") { @@ -1074,6 +1101,9 @@ export async function claimLocalCoordinationTodo( : requireAuthorityStoreId(input.role, "role"), registered_agents: registeredAgents, operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), + ...(input.expected_provider_revision === undefined ? {} : { + expected_provider_revision: requireAuthorityStoreId(input.expected_provider_revision, "expected provider revision"), + }), lease_request: leaseRequest, dry_run: input.dry_run === true, now: claimObservedAt(input.observed_at), diff --git a/loopx/control_plane/coordination/shadow_registry_source.ts b/loopx/control_plane/coordination/shadow_registry_source.ts index 4c6eb68aae..3d2e82e0f8 100644 --- a/loopx/control_plane/coordination/shadow_registry_source.ts +++ b/loopx/control_plane/coordination/shadow_registry_source.ts @@ -3,11 +3,12 @@ * Recovery of an already fenced operation deliberately does not use this owner. */ import {isAbsolute, resolve} from "node:path"; +import {requireJsonObject} from "../runtime_decode.ts"; import type {JsonObject} from "../effect_program.ts"; import {withFileMutationLock} from "../effect_runtime_io.ts"; import {EffectRuntimeLockTimeoutError} from "../effect_runtime_errors.ts"; import {canonicalAuthorityBytes, hasExactAuthorityKeys} from "./authority_store_codec.ts"; -import {registryAuthoritySourceCheck} from "./authority_source.ts"; +import {AUTHORITY_SOURCE_CHANGED, registryAuthoritySourceCheck} from "./authority_source.ts"; import {ShadowManagementError} from "./shadow_management.ts"; import {normalizeRegisteredTodoAgents} from "./todo_agents.ts"; import { BARE_SHA256_PATTERN } from "../content_digest.ts"; @@ -68,3 +69,45 @@ export async function withShadowRegistrySource(snapshot: JsonObject, operatio throw error; } } + +/** Retain the already observed source bytes through a native canonical effect. + * Nonwaiting acquisition follows the existing source-guard rule: release and + * retry on contention rather than waiting in a reverse registry/writer order. + * The native effect owns these guards even if its transport caller exits. + */ +export async function withRegistryAuthoritySources( + value: unknown, primaryValue: unknown, operation: () => Promise, +): Promise { + if (!Array.isArray(value) || value.length < 1 || value.length > 2) { + throw new TypeError("registry_sources must contain one or two source witnesses"); + } + const primary = requireJsonObject(primaryValue, "registry_source"); + registryAuthoritySourceCheck({registry_source: primary}, true); + const sources = value.map(raw => { + const source = requireJsonObject(raw, "registry_sources entry"); + const check = registryAuthoritySourceCheck({registry_source: source}, true); + return {path: source.path as string, sha256: source.sha256 as string, check}; + }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + if (new Set(sources.map(source => source.path)).size !== sources.length || + !sources.some(source => source.path === primary.path && source.sha256 === primary.sha256)) { + throw new TypeError("registry_sources must be distinct and retain the primary registry_source"); + } + let sourcesAcquired = false; + const lock = async (index: number): Promise => { + if (index < sources.length) { + return withFileMutationLock(sources[index]!.path, () => lock(index + 1), 0); + } + sourcesAcquired = true; + for (const source of sources) { + if (!await source.check()) throw new ShadowManagementError(AUTHORITY_SOURCE_CHANGED.code); + } + return operation(); + }; + try { return await lock(0); } + catch (error) { + if (!sourcesAcquired && error instanceof EffectRuntimeLockTimeoutError) { + throw new ShadowManagementError("source_registry_busy_retry"); + } + throw error; + } +} diff --git a/loopx/control_plane/coordination/todo_claim.ts b/loopx/control_plane/coordination/todo_claim.ts index 2ecd77edaa..e2f29be52d 100644 --- a/loopx/control_plane/coordination/todo_claim.ts +++ b/loopx/control_plane/coordination/todo_claim.ts @@ -145,6 +145,12 @@ function decisionFailure( }; } +/** The legacy claim contract must never imply unimplemented lifetime binding. */ +export function unqualifiedGoalRefClaimFailure(value: object): CoordinationTodoClaimResult | null { + return Object.hasOwn(value, "goal_ref") ? failure("goal_ref_claim_contract_unqualified", + "This claim contract does not bind the head and operation receipt to GoalRef; a qualified lifetime-bound claim owner is required.") : null; +} + function rejectInvalidClaimActor( input: CoordinationTodoClaimInput, ): ClaimRejection { @@ -192,6 +198,10 @@ function rejectIneligibleTodo( { requested_role: input.expected_role, todo_role: todo.role }, ); } + if (todo.bound_agent != null && todo.bound_agent !== "" && + normalizeTodoAgent(todo.bound_agent, "todo.bound_agent") !== input.claimed_by) { + return decisionFailure("bound_agent_mismatch", "Todo is bound to another agent"); + } if (todo.status !== "open") { return decisionFailure( "todo_not_open", @@ -353,6 +363,8 @@ async function executeClaimAttempt( ): Promise { let input: CoordinationTodoClaimInput; try { + const scopeFailure = unqualifiedGoalRefClaimFailure(rawInput); + if (scopeFailure !== null) return scopeFailure; input = { ...rawInput, goal_id: requireAuthorityStoreId(rawInput.goal_id, "goal id"), diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 7e0b25b5d9..b910c38b72 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -423,6 +423,11 @@ export function createEffectRuntimeHandlers( ["coordination.local_authority.promotion_review", lazyHandler(() => Promise.all([import("./coordination/local_authority_runtime.ts"), import("./coordination/source_transfer.ts")]), ([{reviewLocalCoordinationAuthorityPromotion}, {withCoordinationSourceTransfer}]) => withCoordinationSourceTransfer("coordination.local_authority.promotion_review", reviewLocalCoordinationAuthorityPromotion))], ["coordination.local_authority.promotion_reviewed", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({executeReviewedCoordinationPromotion}) => executeReviewedCoordinationPromotion)], ["coordination.local_authority.todo_continuation", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({continueLocalTodo}) => continueLocalTodo)], + ["goal_channel.work.project", lazyHandler(() => import("./goals/room_work_projection.ts"), ({projectRoomWork}) => projectRoomWork)], + ["goal_channel.work.claim_request", lazyHandler(() => import("./goals/room_claim_request.ts"), ({decodeRoomClaimRequest}) => decodeRoomClaimRequest)], + ["goal_channel.work.claim_admission", lazyHandler(() => import("./goals/room_claim_request.ts"), ({admitRoomClaimCallback}) => admitRoomClaimCallback)], + ["goal_channel.work.resume_input", lazyHandler(() => import("./goals/room_resume.ts"), ({validateRoomResumeInput}) => validateRoomResumeInput)], + ["goal_channel.work.resume_readback", lazyHandler(() => import("./goals/room_resume.ts"), ({projectRoomResumeReadback}) => projectRoomResumeReadback)], ["coordination.local_authority.todo_claim", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({claimLocalCoordinationTodo}) => claimLocalCoordinationTodo)], ["coordination.local_authority.todo_create", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({createLocalCoordinationTodo}) => createLocalCoordinationTodo)], ["work_items.team_plan.preview", lazyHandler(() => import("./work_items/team_plan.ts"), ({previewTeamPlan}) => previewTeamPlan)], diff --git a/loopx/control_plane/goals/room_claim_request.ts b/loopx/control_plane/goals/room_claim_request.ts new file mode 100644 index 0000000000..3d901be24e --- /dev/null +++ b/loopx/control_plane/goals/room_claim_request.ts @@ -0,0 +1,44 @@ +import {requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts"; +import type {JsonObject} from "../effect_program.ts"; +import {requireAuthorityStoreId} from "../coordination/authority_store_codec.ts"; +import {normalizeTodoAgent} from "../coordination/todo_agents.ts"; + +/** One scoped claim intent, not an actor lifecycle or execution grant. */ +export function decodeRoomClaimRequest(value: unknown): JsonObject { + const p = requireJsonObject(value, "room claim request"); + const fields = ["schema_version", "command", "goal_id", "actor_id", "todo_id", "expected_revision", + "idempotency_key", "authorized_principals", "expires_at"]; + if (Object.keys(p).length !== fields.length || fields.some(k => !Object.hasOwn(p, k)) || + p.schema_version !== "loopx_room_claim_request_v0" || p.command !== "claim_todo") { + throw new Error("unsupported room claim request"); + } + const principals = requireStringArray(p.authorized_principals, "authorized principals"); + if (!principals.length || principals.length > 8 || new Set(principals).size !== principals.length || + principals.some(v => !/^[a-z][a-z0-9_-]*:[A-Za-z0-9][A-Za-z0-9_.:-]{0,200}$/.test(v))) { + throw new Error("invalid scoped principals"); + } + const expires = requireNonEmptyString(p.expires_at, "expires_at"); + if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(expires) || Number.isNaN(new Date(expires).valueOf())) { + throw new Error("invalid room claim expiry"); + } + return {...p, goal_id: requireAuthorityStoreId(p.goal_id, "goal id"), + actor_id: normalizeTodoAgent(p.actor_id, "actor id"), todo_id: requireAuthorityStoreId(p.todo_id, "todo id"), + expected_revision: requireAuthorityStoreId(p.expected_revision, "expected revision"), + idempotency_key: requireAuthorityStoreId(p.idempotency_key, "idempotency key"), + authorized_principals: [...principals].sort(), expires_at: new Date(expires).toISOString()}; +} + +/** Provider authentication is done by the Lark boundary before this admission. */ +export function admitRoomClaimCallback(value: unknown): JsonObject { + const p = requireJsonObject(value, "room claim callback admission"); + const request = decodeRoomClaimRequest(p.request); + const now = new Date(requireNonEmptyString(p.observed_at, "observation time")); + if (Number.isNaN(now.valueOf()) || !["active", "revoked"].includes(String(p.scope_state))) { + throw new Error("invalid room claim scope observation"); + } + const reason = p.scope_state === "revoked" ? "offer_revoked" : + now >= new Date(String(request.expires_at)) ? "offer_expired" : + !(request.authorized_principals as string[]).includes(String(p.principal)) ? "principal_not_authorized" : null; + return {schema_version: "loopx_room_claim_admission_v0", allowed: reason === null, + reason_code: reason, execution_authority_granted: false}; +} diff --git a/loopx/control_plane/goals/room_resume.ts b/loopx/control_plane/goals/room_resume.ts new file mode 100644 index 0000000000..198c6768f9 --- /dev/null +++ b/loopx/control_plane/goals/room_resume.ts @@ -0,0 +1,73 @@ +import type {JsonObject} from "../effect_program.ts"; +import {createHash} from "node:crypto"; +import {requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts"; + +/** Compare observations; this read model never accepts work or grants access. */ +function quotaKey(value: unknown, goal: string, actor: string): string { + const q = requireJsonObject(value, "resume quota"); + const identity = requireJsonObject(q.agent_identity, "quota actor"); + const selected = q.selected_todo == null ? {} : requireJsonObject(q.selected_todo, "selected Todo"); + if (q.ok !== true || q.status_health_ok !== true || q.goal_id !== goal || identity.agent_id !== actor || + (selected.claimed_by != null && selected.claimed_by !== actor)) throw new Error("resume quota scope unavailable"); + const interaction = requireJsonObject(q.interaction_contract, "quota interaction"); + const agent = requireJsonObject(interaction.agent_channel, "quota agent channel"); + const selectedId = q.selected_todo == null ? null : requireNonEmptyString(selected.todo_id, "selected Todo id"); + return JSON.stringify([selectedId, selected.status ?? null, + selected.claimed_by ?? null, selected.task_class ?? null, selected.action_kind ?? null, + q.should_run === true, q.state ?? null, q.effective_action ?? null, agent.must_attempt === true, + agent.delivery_allowed === true, interaction.mode ?? null]); +} + +export function validateRoomResumeInput(value: unknown): JsonObject { + const p = requireJsonObject(value, "room resume input"); + const goal = requireNonEmptyString(p.goal_id, "goal id"); + const actor = requireNonEmptyString(p.actor_id, "actor id"); + const turn = requireNonEmptyString(p.turn_instance_id, "turn identity"); + const q = requireJsonObject(p.quota, "input quota"); + const receipt = requireJsonObject(q.heartbeat_receipt, "quota receipt"); + requireNonEmptyString(requireJsonObject(q.selected_todo, "admitted selected Todo").todo_id, "admitted Todo id"); + if (q.mode !== "should-run" || receipt.turn_instance_id !== turn || + !["committed", "replayed"].includes(String(receipt.status))) throw new Error("resume Turn receipt mismatch"); + const refs = requireStringArray(p.artifact_refs, "requested artifact references"); + if (refs.length > 8 || new Set(refs).size !== refs.length || refs.some(ref => + !/^viking:\/\/[A-Za-z0-9_./:-]+$/.test(ref) || ref.split("/").some(part => part === "." || part === ".."))) { + throw new Error("invalid bounded artifact references"); + } + const matches = quotaKey(q, goal, actor) === quotaKey(p.current_quota, goal, actor); + return {schema_version: "loopx_room_resume_input_check_v0", matches, + reason_code: matches ? null : "quota_observation_changed", execution_authority_granted: false}; +} + +export function projectRoomResumeReadback(value: unknown): JsonObject { + const p = requireJsonObject(value, "room resume readback"); + const goal = requireNonEmptyString(p.goal_id, "goal id"); + const actor = requireNonEmptyString(p.actor_id, "actor id"); + const before = requireJsonObject(p.before_projection, "before work projection"); + const after = requireJsonObject(p.after_projection, "after work projection"); + for (const projection of [before, after]) { + if (projection.goal_id !== goal || projection.actor_id !== actor || projection.authority_state !== "available") { + throw new Error("resume projection scope mismatch"); + } + } + const stable = requireNonEmptyString(before.source_revision, "before revision") === + requireNonEmptyString(after.source_revision, "after revision") && + requireNonEmptyString(p.before_scope, "before scope") === requireNonEmptyString(p.after_scope, "after scope") && + quotaKey(p.before_quota, goal, actor) === quotaKey(p.after_quota, goal, actor); + const recall = requireJsonObject(p.recall, "recall observation"); + const requested = requireStringArray(p.artifact_refs, "requested references"); + const application = recall.application == null ? {} : requireJsonObject(recall.application, "recall application"); + const receipt = application.receipt == null ? {} : requireJsonObject(application.receipt, "recall receipt"); + const accepted = receipt.memory_ref_digests == null ? [] : requireStringArray(receipt.memory_ref_digests, "accepted reference digests"); + const roots = requireStringArray(p.artifact_scope_refs, "configured artifact scopes"); + // A CLI request cannot turn an arbitrary or remembered pointer into read scope. + const applied = recall.ok === true && recall.status === "applied" && receipt.outcome === "applied" && + receipt.current_artifact_verified === true && receipt.result_readback_verified === true; + const authorized = stable && applied ? requested.filter(ref => + roots.some(root => ref === root || ref.startsWith(root.replace(/\/$/, "") + "/")) && + accepted.includes(createHash("sha256").update(ref).digest("hex").slice(0, 16))) : []; + return {schema_version: "loopx_room_resume_readback_v0", authority_observation_stable: stable, + context_usable: stable && applied && recall.context != null, + artifact_references: authorized.map(ref => ({ref, source: "current_scoped_recall", target_access_granted: false})), + omitted_reference_count: requested.length - authorized.length, + execution_authority_granted: false, memory_grants_authority: false}; +} diff --git a/loopx/control_plane/goals/room_work_projection.ts b/loopx/control_plane/goals/room_work_projection.ts new file mode 100644 index 0000000000..34b2364c96 --- /dev/null +++ b/loopx/control_plane/goals/room_work_projection.ts @@ -0,0 +1,57 @@ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts"; +import {evaluateCoordinationTodoClaimDecision} from "../coordination/todo_claim.ts"; +import {normalizeTodoAgent} from "../coordination/todo_agents.ts"; + +/** Content-minimal room orientation. Claim admission remains in the Todo owner. */ +export function projectRoomWork(value: unknown): JsonObject { + const input = requireJsonObject(value, "room work projection"); + const goal = requireNonEmptyString(input.goal_id, "goal_id"); + const actor = requireNonEmptyString(input.actor_id, "actor_id"); + const registered = requireStringArray(input.registered_agents, "registered_agents"); + if (!registered.includes(actor)) throw new Error("room actor is not registered"); + const snapshot = requireJsonObject(input.snapshot, "canonical snapshot"); + if (snapshot.status !== "loaded" || snapshot.decision_read_from_provider !== true || + snapshot.legacy_fallback_used !== false || !Array.isArray(snapshot.todos)) { + throw new Error("canonical room snapshot unavailable"); + } + const revision = requireNonEmptyString(snapshot.provider_revision, "provider_revision"); + const observed = requireNonEmptyString(input.observed_at, "observed_at"); + const now = new Date(observed); + if (Number.isNaN(now.valueOf())) throw new Error("invalid observation time"); + const guards = snapshot.goal_acceptance_work_guards == null ? {} : + requireJsonObject(snapshot.goal_acceptance_work_guards, "acceptance guards"); + const available: JsonObject[] = []; + let gates = 0; + for (const item of snapshot.todos) { + const todo = requireJsonObject(item, "canonical Todo"); + if (todo.role === "user" && todo.status === "open" && todo.task_class === "user_gate" && + (todo.global_gate === true || todo.blocks_agent === actor || todo.bound_agent === actor)) gates++; + if (todo.role === "agent" && todo.task_class != null && todo.task_class !== "advancement_task") continue; + if (todo.claimed_by != null && todo.claimed_by !== "") continue; + const id = requireNonEmptyString(todo.todo_id, "todo_id"); + const guard = guards[id]; + if (guard != null && requireJsonObject(guard, "acceptance guard").allowed === false) continue; + const decision = evaluateCoordinationTodoClaimDecision(todo, { + goal_id: goal, todo_id: id, claimed_by: actor, actor_agent_id: actor, + expected_role: "agent", registered_agents: registered, operation_id: "room-orientation", + expected_provider_revision: revision, dry_run: true, now, + }); + if (decision.status === "accepted") available.push({todo_id: id, claimed_by: null, + actionability: "unclaimed"}); + } + const inspectedId = input.inspected_todo_id == null ? null : requireNonEmptyString(input.inspected_todo_id, "inspected_todo_id"); + const inspected = inspectedId == null ? null : snapshot.todos.map(item => requireJsonObject(item, "canonical Todo")) + .find(todo => todo.todo_id === inspectedId && todo.role === "agent" && + (todo.bound_agent == null || todo.bound_agent === "" || normalizeTodoAgent(todo.bound_agent, "todo.bound_agent") === actor)); + return {schema_version: "loopx_room_work_projection_v0", goal_id: goal, actor_id: actor, + authority_state: "available", mode: "read_only", source_revision: revision, + generated_at: observed, selected_todo: available[0] ?? null, + counts: {unclaimed: available.length, user_gates: gates}, + ...(inspectedId == null ? {} : {inspected_todo: inspected == null ? null : { + todo_id: inspectedId, current_owner: inspected.claimed_by === "" ? null : inspected.claimed_by ?? null, + current_claim_matches_actor: inspected.claimed_by === actor, + }}), + truth_contract: {projection_grants_authority: false, memory_grants_authority: false, + private_task_content_included: false}}; +} diff --git a/loopx/extensions/lark/event_collector_runtime.py b/loopx/extensions/lark/event_collector_runtime.py index c645ce33d0..15b5924f4e 100644 --- a/loopx/extensions/lark/event_collector_runtime.py +++ b/loopx/extensions/lark/event_collector_runtime.py @@ -27,6 +27,7 @@ recover_goal_channel_operation_results, recover_goal_channel_simulation_claims, ) +from .room_claim import is_room_claim_callback, handle_room_claim_callback, recover_room_claim_results from .private_json import write_private_json_atomic from .goal_channel_message_delivery import normalized_card_text @@ -932,23 +933,33 @@ def consume_operation_callbacks() -> None: raise ValueError( "collector Bot application identity is unverified" ) - receipt = handle_goal_channel_operation_callback( - payload, - runtime_root=resolved_runtime_root, - action_store_root=resolved_runtime_root - / "chat" - / "actions", - profile_app_id=profile_app_id, - cli_bin=lark_cli_executable, - profile=str(config["profile"]), - runner=transport_runner, - # Read the original operator owner at the event - # boundary; removing a wake grant takes effect - # without restarting a long-lived collector. - managed_turn_wake=load_lark_event_collector_config( - project=project, config_path=config_path - )["operation_callbacks"]["managed_turn_wake"], - ) + if is_room_claim_callback(payload): + receipt = handle_room_claim_callback( + payload, + runtime_root=resolved_runtime_root, + profile_app_id=profile_app_id, + cli_bin=lark_cli_executable, + profile=str(config["profile"]), + runner=transport_runner, + ) + else: + receipt = handle_goal_channel_operation_callback( + payload, + runtime_root=resolved_runtime_root, + action_store_root=resolved_runtime_root + / "chat" + / "actions", + profile_app_id=profile_app_id, + cli_bin=lark_cli_executable, + profile=str(config["profile"]), + runner=transport_runner, + # Read the original operator owner at the event + # boundary; removing a wake grant takes effect + # without restarting a long-lived collector. + managed_turn_wake=load_lark_event_collector_config( + project=project, config_path=config_path + )["operation_callbacks"]["managed_turn_wake"], + ) if receipt.get("ok") is not True: raise RuntimeError( "operation callback result delivery was not verified" @@ -1012,6 +1023,12 @@ def recover_operation_results() -> None: ) except Exception: # noqa: BLE001 result = {"attempted": 1, "delivered": 0, "failed": 1} + room_result = recover_room_claim_results(runtime_root=resolved_runtime_root, + profile_app_id=str(profile_app_id or ""), allowed_chat_ids=set(routes_by_chat), + cli_bin=lark_cli_executable, profile=str(config["profile"]), + runner=_operation_transport_runner(runner, command_prefix=command_prefix)) + for key in result_recovery_stats: + result[key] = int(result.get(key) or 0) + int(room_result.get(key) or 0) for key in simulation_recovery_stats: simulation_recovery_stats[key] += int( simulation_result.get(key) or 0 diff --git a/loopx/extensions/lark/goal_channel_work.py b/loopx/extensions/lark/goal_channel_work.py new file mode 100644 index 0000000000..ef5e2ad7fe --- /dev/null +++ b/loopx/extensions/lark/goal_channel_work.py @@ -0,0 +1,209 @@ +"""Local Agent CLI composition of canonical work and existing Goal Channel delivery. + +No IM principal, recalled text, or displayed card can supply an Agent identity. +Python owns provider IO; the typed Todo owner owns every state transition. +""" +from __future__ import annotations + +import hashlib +import json +from contextlib import ExitStack, contextmanager +from pathlib import Path +from typing import Any, Iterator + +from ...agent_registry import registered_agent_ids_from_registry +from ...control_plane.coordination.local_authority import ( + LocalCoordinationAuthorityUnavailable, claim_canonical_todo_if_promoted, + read_canonical_todos_if_promoted, +) +from ...control_plane.effect_runtime import effect_runtime_result +from ...control_plane.coordination.authority_source_capture import authority_registry_source +from ...control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route +from ...control_plane.runtime.public_safety import validate_public_safe_value +from ...control_plane.runtime.time import now_local_iso +from ...file_lock import exclusive_file_lock +from ..runtime import default_extension_state_file, resolve_extension_activation +from . import LARK_EXTENSION_ID, LARK_GOAL_CHANNEL_PERMISSION +from .goal_channel_contracts import binding_for_goal, read_goal_channel_binding +from .goal_channel_delivery_contract import goal_channel_binding_digest, goal_channel_delivery_route +from .goal_channel_message_delivery import ( + GoalChannelDeliveryStageError, GoalChannelMessageDeliverySession, resolve_bound_goal_channel, +) +from .cli_transport import CommandRunner, default_subprocess_runner + +GOAL_CHANNEL_WORK_RESULT_SCHEMA = "loopx_goal_channel_work_result_v0" + + +@contextmanager +def room_work_sources(registry_path: Path, invoked_registry_path: Path | None = None) -> Iterator[list[dict[str, str]]]: + """Capture correlated IO witnesses; native claim v2 owns their effect guards.""" + paths = {registry_path.resolve(), (invoked_registry_path or registry_path).resolve()} + with ExitStack() as reads: + sources = [reads.enter_context(authority_registry_source(path)) for path in sorted(paths)] + yield sources + + +def _resolve_binding(binding_path: Path, target_path: Path, goal_id: str, actor: str) -> dict[str, Any]: + # The delivery helper supports default routes; this work facade requires an + # exact Agent route and must never fall back to a sibling or default lane. + raw = binding_for_goal(read_goal_channel_binding(binding_path), goal_id, agent_id=actor) + if raw is None or raw.get("agent_id") != actor or raw.get("enabled") is not True: + raise ValueError("exact enabled Agent channel required") + resolved = resolve_bound_goal_channel(binding_path=binding_path, target_path=target_path, + goal_id=goal_id, agent_id=actor) + goal_channel_delivery_route(goal_id, lambda _: resolved) + return resolved + + +def build_work_card(packet: dict[str, Any]) -> dict[str, Any]: + """Only content-minimal, validated projection/receipt fields reach the room.""" + validate_public_safe_value(packet) + projection = packet.get("projection") or {} + receipt = packet.get("receipt") + selected = projection.get("selected_todo") or {} + counts = projection.get("counts") or {} + body = (f"Goal: {packet['goal_id']}\nAgent: {packet['actor_id']}\n" + f"Available tasks: {counts.get('unclaimed', 0)} · User gates: {counts.get('user_gates', 0)}\n" + f"Selected task: {selected.get('todo_id') or 'none'}\n" + f"Revision: {projection.get('source_revision') or 'unavailable'}") + if receipt: + # Replays publish the same semantic acceptance card and provider key. + body += f"\nHistorical claim receipt: {receipt['acceptance']} · Task: {receipt['todo_id']}\nCurrent owner: {receipt.get('current_owner') or 'none'}\nReceipt: {receipt['receipt_id']}" + body += "\nRead-only orientation. Claim through the scoped Agent CLI; recheck current authority after reconnect." + return {"config": {"wide_screen_mode": True}, + "header": {"title": {"tag": "plain_text", "content": "LoopX work"}}, + "elements": [{"tag": "div", "text": {"tag": "plain_text", "content": body}}]} + + +def run_goal_channel_work(*, registry_path: Path, runtime_root: Path, binding_path: Path, + target_path: Path, goal_id: str, actor_id: str, command: str, execute: bool, + todo_id: str | None = None, expected_revision: str | None = None, + idempotency_key: str | None = None, runner: CommandRunner = default_subprocess_runner, + publish_receipt: bool = True, expected_connection_id: str | None = None, + expected_binding_digest: str | None = None, + invoked_registry_path: Path | None = None, + registry_sources: list[dict[str, str]] | None = None) -> dict[str, Any]: + packet: dict[str, Any] = {"schema_version": GOAL_CHANNEL_WORK_RESULT_SCHEMA, "ok": False, "goal_id": goal_id, + "actor_id": actor_id, "operation": f"work_{command}", "execute": execute, + "status": "failed", "external_write_performed": False, "readback_verified": False, + "canonical_claim_accepted": False, "execution_authority_granted": False, "public_summary": "Room work is unavailable."} + stage = "scope" + try: + validate_public_safe_value({"goal_id": goal_id, "actor_id": actor_id}) + if command not in {"project", "claim"}: + raise ValueError("unsupported work command") + if command == "claim" and (not todo_id or not expected_revision or not idempotency_key): + raise ValueError("claim identity is incomplete") + if command == "claim" and registry_sources is None: + with room_work_sources(registry_path, invoked_registry_path) as sources: + route = resolve_goal_source_runtime_route( + registry_path=invoked_registry_path or registry_path, goal_id=goal_id) + if (Path(route["source_registry"]).resolve() != registry_path.resolve() or + Path(route["source_runtime_root"]).resolve() != runtime_root.resolve()): + raise ValueError("source route changed") + registry_sources = sources + registered = registered_agent_ids_from_registry(registry_path, goal_id) + if actor_id not in registered: + raise ValueError("actor is not registered") + def current_binding() -> dict[str, Any]: + if actor_id not in registered_agent_ids_from_registry(registry_path, goal_id): + raise ValueError("actor scope was revoked") + if execute: + resolve_extension_activation(LARK_EXTENSION_ID, + state_file=default_extension_state_file(runtime_root), + required_permissions=(LARK_GOAL_CHANNEL_PERMISSION,)) + current = _resolve_binding(binding_path, target_path, goal_id, actor_id) + if expected_connection_id is not None and current.get("connection_id") != expected_connection_id: + raise ValueError("connection changed") + if expected_binding_digest is not None and goal_channel_binding_digest(current) != expected_binding_digest: + raise ValueError("binding changed") + return current + + binding = current_binding() + session = GoalChannelMessageDeliverySession(goal_id=goal_id, binding=binding, + binding_lock_path=binding_path, target_lock_path=target_path, + history_start_at=str(binding.get("created_at") or "1970-01-01T00:00:00Z"), + resolve_current_binding=current_binding, + runner=runner) + route = goal_channel_delivery_route(goal_id, session.resolve) + if execute: + stage = "delivery_preflight" + activation = resolve_extension_activation(LARK_EXTENSION_ID, + state_file=default_extension_state_file(runtime_root), + required_permissions=(LARK_GOAL_CHANNEL_PERMISSION,)) + if activation.get("enabled") is not True or not session.verify(route): + raise ValueError("delivery is unavailable") + if command == "claim": + stage = "claim" + # Binding revocation and re-targeting serialize with the canonical + # write. Registry identity is separately witnessed by the Todo owner. + with ExitStack() as locks: + locks.enter_context(exclusive_file_lock(binding_path, operation="room_work_claim")) + if target_path != binding_path: + locks.enter_context(exclusive_file_lock(target_path, operation="room_work_claim")) + if current_binding() != binding: + raise ValueError("binding changed") + result = claim_canonical_todo_if_promoted(registry_path=registry_path, + runtime_root=runtime_root, goal_id=goal_id, todo_id=str(todo_id), role="agent", + claimed_by=actor_id, actor_agent_id=actor_id, dry_run=not execute, + operation_id=str(idempotency_key), expected_provider_revision=str(expected_revision), + registry_sources=registry_sources) + if result is None: + raise ValueError("canonical promotion required") + accepted = result.get("status") in {"applied", "replayed", "recovered", "no_change"} + packet["canonical_claim_accepted"] = accepted + packet["status"] = ("already_applied" if result.get("status") in {"replayed", "recovered"} + else "applied" if accepted else "planned") + # Receipts never copy arbitrary provider payloads, original requests, + # lease secrets, validation paths or exception text to the room. + packet["receipt"] = {"acceptance": "accepted" if accepted else "planned", + "todo_id": str(todo_id), "actor_id": actor_id, "scope": "historical_acceptance_only", + "receipt_id": "sha256:" + hashlib.sha256(str(idempotency_key).encode()).hexdigest()} + stage = "projection" + snapshot = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id) + if snapshot is None: + raise ValueError("canonical promotion required") + packet["projection"] = effect_runtime_result("goal_channel.work.project", { + "goal_id": goal_id, "actor_id": actor_id, "registered_agents": registered, + "snapshot": snapshot, "observed_at": now_local_iso(), + **({"inspected_todo_id": todo_id} if command == "project" and todo_id is not None else {})}) + if command == "claim": + current: dict[str, Any] = next((todo for todo in snapshot["todos"] if todo["todo_id"] == todo_id), {}) + packet["receipt"]["current_owner"] = current.get("claimed_by") + packet["receipt"]["current_claim_matches_actor"] = current.get("claimed_by") == actor_id + if command == "project": + packet["status"] = "projected" + packet["public_summary"] = "Canonical room work readback is ready." + validate_public_safe_value(packet) + if execute and publish_receipt: + stage = "delivery" + # Stable card identity permits lost-response recovery independently + # of the accepted state transition. No new claim is minted here. + card = build_work_card(packet) + key = "room-work:" + hashlib.sha256(json.dumps(card, sort_keys=True).encode()).hexdigest() + sent = session.send(card, key, route) + packet["external_write_performed"] = sent.get("external_write_performed") is True + readback = session.readback(str(sent["message_id"])) + packet["readback_verified"] = readback.get("verified") is True + if not packet["readback_verified"]: + raise ValueError("delivery readback failed") + packet["ok"] = True + return packet + except LocalCoordinationAuthorityUnavailable as exc: + packet["status"] = "conflict" if exc.code == "provider_revision_mismatch" else "rejected" if exc.payload.get("failure_kind") == "decision_rejection" else "failed" + packet["blocker"] = packet["status"] + except GoalChannelDeliveryStageError as exc: + packet["external_write_performed"] = exc.external_write_performed is not False + packet["blocker"] = exc.blocker + except Exception: + packet["blocker"] = f"{stage}_unavailable" + packet["failure_stage"] = stage + packet["public_summary"] = ("Claim accepted; room delivery/readback needs recovery using the same key." + if packet["canonical_claim_accepted"] else "No claim acceptance is reported; refresh scope and canonical state.") + try: + validate_public_safe_value(packet) + except ValueError: + return {"schema_version": GOAL_CHANNEL_WORK_RESULT_SCHEMA, "ok": False, "status": "failed", + "blocker": "public_projection_invalid", "canonical_claim_accepted": packet["canonical_claim_accepted"], + "external_write_performed": packet["external_write_performed"], "readback_verified": False} + return packet diff --git a/loopx/extensions/lark/room_claim.py b/loopx/extensions/lark/room_claim.py new file mode 100644 index 0000000000..1621f1b061 --- /dev/null +++ b/loopx/extensions/lark/room_claim.py @@ -0,0 +1,331 @@ +"""One-operation room claim grants and Lark transport; Todo owns transitions. + +Offers are authored by a trusted local Agent CLI. Room membership never grants +claim scope. Private offer files contain only intent/scope and delivery facts; +canonical receipts remain the sole source of accepted work transitions. +""" +from __future__ import annotations + +import hashlib +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +from ...agent_registry import registered_agent_ids_from_registry +from ...control_plane.coordination.local_authority import claim_canonical_todo_if_promoted +from ...control_plane.collaboration.goal_instance_scope import collaboration_goal_scope +from ...control_plane.effect_runtime import effect_runtime_result +from ...control_plane.runtime.public_safety import validate_public_safe_value +from ...control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route +from ...file_lock import exclusive_file_lock +from ..runtime import default_extension_state_file, resolve_extension_activation +from . import LARK_EXTENSION_ID, LARK_GOAL_CHANNEL_PERMISSION +from .card_callback import operator_membership_verified, read_callback_card_content, update_callback_card, patch_result_card +from .goal_channel_delivery_contract import goal_channel_binding_digest, goal_channel_delivery_route +from .goal_channel_message_delivery import GoalChannelMessageDeliverySession, GoalChannelDeliveryStageError +from .goal_channel_work import _resolve_binding, build_work_card, room_work_sources, run_goal_channel_work +from .cli_transport import CommandRunner, default_subprocess_runner +from .goal_channel_transport import verified_app_id +from .goal_channel_operation import _card_text +from .private_json import write_private_json_atomic + +ROOM_CLAIM_OFFER_RECORD_SCHEMA = "loopx_lark_room_claim_offer_record_v0" +ROOM_CLAIM_OFFER_RESULT_SCHEMA = "loopx_lark_room_claim_offer_result_v0" +ROOM_CLAIM_ACTION_SCHEMA = "loopx_room_claim_action_v0" +ROOM_CLAIM_CALLBACK_SCHEMA = "loopx_lark_room_claim_callback_v0" +_OFFER_ID = re.compile(r"^rc_[a-f0-9]{32}$") + + +class RoomClaimCallbackError(ValueError): + def __init__(self, code: str, stage: str = "room_claim_scope") -> None: + super().__init__("Room claim callback could not be admitted or delivered") + self.code = code + self.failure_stage = stage + + +def _digest(value: object) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _path(root: Path, request_id: str) -> Path: + if not _OFFER_ID.fullmatch(request_id): + raise RoomClaimCallbackError("invalid_offer_id") + return root / "room-claim-offers" / (request_id + ".json") + + +def _read(path: Path) -> dict[str, Any]: + if path.is_symlink(): + raise RoomClaimCallbackError("offer_unavailable") + try: + p = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(p, dict) or p.get("schema_version") != ROOM_CLAIM_OFFER_RECORD_SCHEMA: + raise ValueError("invalid offer") + normalized = effect_runtime_result("goal_channel.work.claim_request", p["request"]) + if normalized != p["request"] or p["intent_digest"] != _digest(normalized): + raise ValueError("intent drift") + if (p["request_id"] != "rc_" + p["intent_digest"][:32] or path.stem != p["request_id"] or + p["scope_state"] not in {"active", "revoked"}): + raise ValueError("scope drift") + return p + except RoomClaimCallbackError: + raise + except Exception as exc: + raise RoomClaimCallbackError("offer_unavailable") from exc + + +def _scope(p: Mapping[str, Any], broker_root: Path) -> dict[str, Any]: + request = p["request"] + # The current broker route must still name the original source and provider + # root. Reconnect cannot silently follow a replacement Goal authority. + route = resolve_goal_source_runtime_route(registry_path=broker_root / "registry.global.json", + goal_id=request["goal_id"]) + if (Path(route["source_registry"]).resolve() != Path(p["source_registry"]).resolve() or + Path(route["source_runtime_root"]).resolve() != Path(p["authority_root"]).resolve()): + raise RoomClaimCallbackError("source_route_changed") + with collaboration_goal_scope(Path(p["source_registry"]), goal_id=request["goal_id"], + agents=(request["actor_id"],), require_active=True) as scope: + # This facade currently uses the existing goal_id-bound canonical claim + # wire. An exact-instance profile cannot inherit a legacy offer. + if scope.exact: + raise RoomClaimCallbackError("exact_instance_claim_unqualified") + if request["actor_id"] not in registered_agent_ids_from_registry(Path(p["source_registry"]), request["goal_id"]): + raise RoomClaimCallbackError("actor_scope_revoked") + resolve_extension_activation(LARK_EXTENSION_ID, + state_file=default_extension_state_file(Path(p["authority_root"])), + required_permissions=(LARK_GOAL_CHANNEL_PERMISSION,)) + binding = _resolve_binding(Path(p["binding_path"]), Path(p["target_path"]), request["goal_id"], request["actor_id"]) + if goal_channel_binding_digest(binding) != p["binding_digest"]: + raise RoomClaimCallbackError("binding_changed") + return binding + + +def build_room_claim_card(request: Mapping[str, Any], request_id: str) -> dict[str, Any]: + text = f"Task: {_card_text(request['todo_id'])}\nGoal: {_card_text(request['goal_id'])}\nClaim as: {_card_text(request['actor_id'])}" + return {"schema": "2.0", "config": {"update_multi": True}, + "header": {"title": {"tag": "plain_text", "content": "Claim task"}}, + "body": {"elements": [{"tag": "markdown", "content": text}, + {"tag": "button", "type": "primary", "text": {"tag": "plain_text", "content": "Claim task"}, + "behaviors": [{"type": "callback", "value": {"schema_version": ROOM_CLAIM_ACTION_SCHEMA, + "request_id": request_id, "intent_digest": _digest(dict(request)), "command": "claim_todo"}}]}]}} + + +def run_room_claim_offer(*, registry_path: Path, authority_root: Path, broker_root: Path, + binding_path: Path, target_path: Path, goal_id: str, actor_id: str, command: str, + execute: bool, todo_id: str | None = None, expected_revision: str | None = None, + idempotency_key: str | None = None, principals: list[str] | None = None, + expires_at: str | None = None, request_id: str | None = None, + runner: CommandRunner = default_subprocess_runner) -> dict[str, Any]: + result: dict[str, Any] = {"schema_version": ROOM_CLAIM_OFFER_RESULT_SCHEMA, "ok": False, + "goal_id": goal_id, "actor_id": actor_id, "operation": "work_" + command, "execute": execute, + "external_write_performed": False, "readback_verified": False, "canonical_claim_accepted": False, + "execution_authority_granted": False} + try: + validate_public_safe_value({"goal_id": goal_id, "actor_id": actor_id}) + with collaboration_goal_scope(registry_path, goal_id=goal_id, agents=(actor_id,), require_active=True) as scope: + if scope.exact: + raise RoomClaimCallbackError("exact_instance_claim_unqualified") + if actor_id not in registered_agent_ids_from_registry(registry_path, goal_id): + raise RoomClaimCallbackError("actor_scope_revoked") + if command == "revoke": + path = _path(broker_root, str(request_id or "")) + p = _read(path) + if p["request"]["goal_id"] != goal_id or p["request"]["actor_id"] != actor_id: + raise RoomClaimCallbackError("offer_scope_mismatch") + if execute: + with exclusive_file_lock(path, operation="room_claim_offer"): + p = _read(path) + p["scope_state"] = "revoked" + write_private_json_atomic(path, p) + return {**result, "ok": True, "status": "revoked" if execute else "planned", "request_id": p["request_id"]} + if command != "offer": + raise RoomClaimCallbackError("unsupported_offer_command") + if not principals or any(not re.fullmatch(r"lark:ou_[A-Za-z0-9_-]{1,160}", principal) for principal in principals): + raise RoomClaimCallbackError("invalid_lark_principal") + request = effect_runtime_result("goal_channel.work.claim_request", {"schema_version": "loopx_room_claim_request_v0", + "command": "claim_todo", "goal_id": goal_id, "actor_id": actor_id, "todo_id": todo_id, + "expected_revision": expected_revision, "idempotency_key": idempotency_key, + "authorized_principals": principals or [], "expires_at": expires_at}) + validate_public_safe_value({k:request[k] for k in ["goal_id", "actor_id", "todo_id", "expected_revision"]}) + admission = effect_runtime_result("goal_channel.work.claim_admission", {"request": request, + "scope_state": "active", "principal": request["authorized_principals"][0], "observed_at": _now()}) + if not admission["allowed"]: + raise RoomClaimCallbackError(admission["reason_code"]) + rid = "rc_" + _digest(request)[:32] + path = _path(broker_root, rid) + binding = _resolve_binding(binding_path, target_path, goal_id, actor_id) + p = {"schema_version": ROOM_CLAIM_OFFER_RECORD_SCHEMA, "request_id": rid, "request": request, + "intent_digest": _digest(request), "scope_state": "active", "source_registry": str(registry_path.resolve()), + "authority_root": str(authority_root.resolve()), "binding_path": str(binding_path.resolve()), + "target_path": str(target_path.resolve()), "binding_digest": goal_channel_binding_digest(binding), + "created_at": _now(), "delivery": None, "last_result_card": None, "result_delivery_verified": False} + # Validate current canonical eligibility, without creating a claim/receipt. + preview = claim_canonical_todo_if_promoted(registry_path=registry_path, runtime_root=authority_root, + goal_id=goal_id, todo_id=request["todo_id"], role="agent", claimed_by=actor_id, + actor_agent_id=actor_id, dry_run=True, operation_id="offer-preview:" + rid, + expected_provider_revision=request["expected_revision"]) + if preview is None: + raise RoomClaimCallbackError("canonical_authority_required") + card = build_room_claim_card(request, rid) + result.update(request_id=rid, status="planned", authorized_principal_count=len(request["authorized_principals"]), card=card) + if not execute: + return {**result, "ok": True} + _scope(p, broker_root) + with exclusive_file_lock(path, operation="room_claim_offer"): + if path.exists(): + p = _read(path) + if p["scope_state"] != "active": + raise RoomClaimCallbackError("offer_revoked") + else: + write_private_json_atomic(path, p) + binding = _scope(p, broker_root) + session = GoalChannelMessageDeliverySession(goal_id=goal_id, binding=binding, + binding_lock_path=binding_path, target_lock_path=target_path, history_start_at=p["created_at"], + resolve_current_binding=lambda: _scope(p, broker_root), runner=runner) + route = goal_channel_delivery_route(goal_id, session.resolve) + if not session.verify(route): + raise RoomClaimCallbackError("provider_identity_unverified") + sent = session.send(card, "room-claim-offer:" + rid, route) + result["external_write_performed"] = sent.get("external_write_performed") is True + verified = session.readback(str(sent["message_id"])).get("verified") is True + p["delivery"] = {"message_id": sent["message_id"], "chat_id": route["chat_id"], "app_id": route["bot_app_id"], + "profile": route["sender_profile"], "card": card, "readback_verified": verified} + write_private_json_atomic(path, p) + result.update(ok=verified, status="offered" if verified else "delivery_pending", readback_verified=verified) + return result + except GoalChannelDeliveryStageError as exc: + result.update(blocker=exc.blocker, external_write_performed=exc.external_write_performed is not False) + except RoomClaimCallbackError as exc: + result["blocker"] = exc.code + except Exception: + result["blocker"] = "offer_unavailable" + result["status"] = "failed" + return result + + +def is_room_claim_callback(event: Mapping[str, Any]) -> bool: + raw = event.get("action_value") + try: + value = json.loads(raw) if isinstance(raw, str) else raw + except (ValueError, TypeError): + return False + return isinstance(value, Mapping) and value.get("schema_version") == ROOM_CLAIM_ACTION_SCHEMA + + +def handle_room_claim_callback(event: Mapping[str, Any], *, runtime_root: Path, + profile_app_id: str, cli_bin: str, profile: str, + runner: CommandRunner = default_subprocess_runner) -> dict[str, Any]: + try: + raw = event.get("action_value") + action = json.loads(raw) if isinstance(raw, str) else raw + if (event.get("type") != "card.action.trigger" or event.get("action_tag") != "button" or + event.get("host") != "im_message" or not isinstance(action, Mapping) or + set(action) != {"schema_version", "request_id", "intent_digest", "command"} or + action["schema_version"] != ROOM_CLAIM_ACTION_SCHEMA or action["command"] != "claim_todo"): + raise RoomClaimCallbackError("invalid_callback") + token = str(event.get("token") or "") + if not token or len(token) > 2048 or any(ord(c) < 32 for c in token): + raise RoomClaimCallbackError("invalid_callback_token") + path = _path(runtime_root, str(action["request_id"])) + p = _read(path) + with exclusive_file_lock(path, operation="room_claim_callback"): + p = _read(path) + request = p["request"] + delivery = p.get("delivery") + principal = "lark:" + str(event.get("operator_id") or "") + admission = effect_runtime_result("goal_channel.work.claim_admission", {"request": request, + "scope_state": p["scope_state"], "principal": principal, "observed_at": _now()}) + if not admission["allowed"]: + raise RoomClaimCallbackError(admission["reason_code"]) + if (action["intent_digest"] != p["intent_digest"] or not isinstance(delivery, Mapping) or + delivery.get("readback_verified") is not True or event.get("message_id") != delivery["message_id"] or + event.get("chat_id") != delivery["chat_id"] or profile_app_id != delivery["app_id"] or profile != delivery["profile"]): + raise RoomClaimCallbackError("callback_delivery_scope_mismatch") + with room_work_sources(Path(p["source_registry"]), runtime_root / "registry.global.json") as sources: + binding = _scope(p, runtime_root) + route = goal_channel_delivery_route(request["goal_id"], lambda _: binding) + if route["bot_app_id"] != profile_app_id or route["sender_profile"] != profile: + raise RoomClaimCallbackError("callback_provider_scope_mismatch") + if not operator_membership_verified(runner=runner, cli_bin=cli_bin, profile=profile, + chat_id=delivery["chat_id"], operator_id=str(event["operator_id"])): + raise RoomClaimCallbackError("operator_identity_unverified") + # Always hydrate the originating Bot message. Visible-only equality + # cannot prove this action's hidden request identity. + observed = read_callback_card_content(runner=runner, cli_bin=cli_bin, profile=profile, + message_id=delivery["message_id"], chat_id=delivery["chat_id"], app_id=profile_app_id) + if isinstance(observed, str): + observed = json.loads(observed) + if observed != delivery["card"] and observed != p.get("last_result_card"): + raise RoomClaimCallbackError("callback_card_drifted") + claimed = run_goal_channel_work(registry_path=Path(p["source_registry"]), + runtime_root=Path(p["authority_root"]), binding_path=Path(p["binding_path"]), target_path=Path(p["target_path"]), + goal_id=request["goal_id"], actor_id=request["actor_id"], command="claim", execute=True, + todo_id=request["todo_id"], expected_revision=request["expected_revision"], + idempotency_key=request["idempotency_key"], runner=runner, publish_receipt=False, + # Provider reads can outlive the offer's original connection. + # Keep its witness through the facade's locked claim check. + expected_binding_digest=p["binding_digest"], registry_sources=sources) + card = build_work_card(claimed) if claimed.get("projection") else { + "config": {"wide_screen_mode": True}, "header": {"title": {"tag": "plain_text", "content": "Claim readback"}}, + "elements": [{"tag": "div", "text": {"tag": "plain_text", "content": str(claimed.get("public_summary"))}}]} + p["last_result_card"] = card + p["result_delivery_verified"] = False + write_private_json_atomic(path, p) + _scope(p, runtime_root) + if verified_app_id(runner=runner, cli_bin=cli_bin, profile=profile) != profile_app_id: + raise RoomClaimCallbackError("callback_provider_scope_mismatch") + updated = update_callback_card(runner=runner, cli_bin=cli_bin, profile=profile, token=token, + card=card, message_id=delivery["message_id"], chat_id=delivery["chat_id"], app_id=profile_app_id) + p["result_delivery_verified"] = updated.get("readback_verified") is True + write_private_json_atomic(path, p) + return {"schema_version": ROOM_CLAIM_CALLBACK_SCHEMA, "ok": p["result_delivery_verified"], + "request_id": p["request_id"], "claim_status": claimed["status"], + "canonical_claim_accepted": claimed["canonical_claim_accepted"], + "receipt": claimed.get("receipt"), "card_update_verified": p["result_delivery_verified"], + "external_write_performed": updated.get("external_write_performed"), + "callback_ack_is_execution_receipt": False, "execution_authority_granted": False} + except RoomClaimCallbackError: + raise + except Exception as exc: + raise RoomClaimCallbackError("callback_unavailable") from exc + + +def recover_room_claim_results(*, runtime_root: Path, profile_app_id: str, allowed_chat_ids: set[str], + cli_bin: str, profile: str, runner: CommandRunner = default_subprocess_runner, limit: int = 20) -> dict[str, int]: + """Retry only recorded public result-card transport, never a work transition.""" + stats = {"attempted": 0, "delivered": 0, "failed": 0} + for path in sorted((runtime_root / "room-claim-offers").glob("rc_*.json")): + if stats["attempted"] >= min(max(limit, 1), 100): + break + try: + p = _read(path) + d = p.get("delivery") + if (not p.get("last_result_card") or p.get("result_delivery_verified") is True or + not isinstance(d, Mapping) or d["app_id"] != profile_app_id or + d["chat_id"] not in allowed_chat_ids or d["profile"] != profile): + continue + stats["attempted"] += 1 + with exclusive_file_lock(path, operation="room_claim_result_recovery"): + p = _read(path) + d = p["delivery"] + _scope(p, runtime_root) + if p.get("scope_state") != "active" or p.get("result_delivery_verified") is True: + continue + request = p["request"] + admission = effect_runtime_result("goal_channel.work.claim_admission", {"request": request, + "scope_state": p["scope_state"], "principal": request["authorized_principals"][0], "observed_at": _now()}) + if not admission["allowed"] or verified_app_id(runner=runner, cli_bin=cli_bin, profile=profile) != profile_app_id: + continue + result = patch_result_card(runner=runner, cli_bin=cli_bin, profile=profile, + card=p["last_result_card"], message_id=d["message_id"], chat_id=d["chat_id"], app_id=profile_app_id) + p["result_delivery_verified"] = result.get("readback_verified") is True + write_private_json_atomic(path, p) + stats["delivered" if p["result_delivery_verified"] else "failed"] += 1 + except Exception: + stats["failed"] += 1 + return stats diff --git a/loopx/extensions/lark/room_resume.py b/loopx/extensions/lark/room_resume.py new file mode 100644 index 0000000000..8c0a8c07f3 --- /dev/null +++ b/loopx/extensions/lark/room_resume.py @@ -0,0 +1,113 @@ +"""Private reconnect context through existing recall, quota and channel owners.""" +from __future__ import annotations + +import hashlib +import json +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +from ...agent_registry import registered_agent_ids_from_registry +from ...capabilities.agent_turn_recall.runtime import run_configured_agent_turn_recall_fail_open +from ...capabilities.context_providers.base import ContextProvider +from ...capabilities.reward_memory.experiment import resolve_reward_memory_experiment, resolve_reward_memory_surface_config +from ...control_plane.collaboration.goal_instance_scope import collaboration_goal_scope +from ...control_plane.effect_runtime import effect_runtime_result +from ...control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route +from ..runtime import default_extension_state_file, resolve_extension_activation +from . import LARK_EXTENSION_ID, LARK_GOAL_CHANNEL_PERMISSION +from .goal_channel_delivery_contract import goal_channel_binding_digest, goal_channel_delivery_route +from .goal_channel_message_delivery import GoalChannelMessageDeliverySession +from .goal_channel_work import _resolve_binding, run_goal_channel_work +from .cli_transport import CommandRunner, default_subprocess_runner + +ROOM_RESUME_RESULT_SCHEMA = "loopx_goal_channel_resume_v0" + + +def run_room_resume(*, registry_path: Path, authority_root: Path, broker_root: Path, + binding_path: Path, target_path: Path, goal_id: str, actor_id: str, + quota_decision: Mapping[str, Any], turn_instance_id: str, execute: bool, + read_current_quota: Callable[[], dict[str, Any]], artifact_refs: list[str] | None = None, + runner: CommandRunner = default_subprocess_runner, provider: ContextProvider | None = None) -> dict[str, Any]: + packet: dict[str, Any] = {"schema_version": ROOM_RESUME_RESULT_SCHEMA, "ok": False, + "goal_id": goal_id, "actor_id": actor_id, "turn_instance_id": turn_instance_id, + "status": "rejected", "visibility": "private", "private_context": None, + "artifact_references": [], "provider_call_count": 0, "room_delivery_performed": False, + "external_writes_performed": False, "quota_spend_performed": False, + "execution_authority_granted": False} + refs = artifact_refs or [] + + def scope() -> tuple[dict[str, Any], str, list[str]]: + route = resolve_goal_source_runtime_route(registry_path=broker_root / "registry.global.json", goal_id=goal_id) + if (Path(route["source_registry"]).resolve() != registry_path.resolve() or + Path(route["source_runtime_root"]).resolve() != authority_root.resolve()): + raise ValueError("source route changed") + with collaboration_goal_scope(registry_path, goal_id=goal_id, agents=(actor_id,), require_active=True) as current: + if current.exact: + raise ValueError("exact instance resume unqualified") + if actor_id not in registered_agent_ids_from_registry(registry_path, goal_id): + raise ValueError("actor scope revoked") + binding = _resolve_binding(binding_path, target_path, goal_id, actor_id) + status, config = resolve_reward_memory_experiment(registry_path=registry_path, goal_id=goal_id, agent_id=actor_id) + digest = hashlib.sha256(json.dumps([goal_channel_binding_digest(binding), status, config], + sort_keys=True, separators=(",", ":")).encode()).hexdigest() + roots = [str(item["provider_binding"]["scope_ref"]) for item in + resolve_reward_memory_surface_config(config, "agent_workflow.turn_admission")["recall_corpora"]] if config else [] + if execute: + resolve_extension_activation(LARK_EXTENSION_ID, state_file=default_extension_state_file(authority_root), + required_permissions=(LARK_GOAL_CHANNEL_PERMISSION,)) + session = GoalChannelMessageDeliverySession(goal_id=goal_id, binding=binding, + binding_lock_path=binding_path, target_lock_path=target_path, + history_start_at="1970-01-01T00:00:00Z", + resolve_current_binding=lambda: _resolve_binding(binding_path, target_path, goal_id, actor_id), runner=runner) + if not session.verify(goal_channel_delivery_route(goal_id, session.resolve)): + raise ValueError("provider identity unavailable") + return binding, digest, roots + + def projection() -> dict[str, Any]: + observed = run_goal_channel_work(registry_path=registry_path, runtime_root=authority_root, + binding_path=binding_path, target_path=target_path, goal_id=goal_id, + actor_id=actor_id, command="project", execute=False, runner=runner) + if observed.get("ok") is not True: + raise ValueError("canonical projection unavailable") + return dict(observed["projection"]) + + try: + _, before_scope, artifact_scopes = scope() + before_projection = projection() + before_quota = read_current_quota() + packet.update(current_quota=before_quota, work_projection=before_projection) + checked = effect_runtime_result("goal_channel.work.resume_input", {"goal_id": goal_id, "actor_id": actor_id, + "turn_instance_id": turn_instance_id, "quota": dict(quota_decision), "current_quota": before_quota, + "artifact_refs": refs}) + if not checked["matches"]: + return {**packet, "blocker": checked["reason_code"]} + if not execute: + return {**packet, "ok": True, "status": "planned", "requested_reference_count": len(refs)} + recalled = run_configured_agent_turn_recall_fail_open(registry_path=registry_path, + goal_id=goal_id, agent_id=actor_id, quota_decision=before_quota, turn_instance_id=turn_instance_id, + execute=True, force_refresh=True, reconcile_pending_ingests=False, provider=provider) + packet["provider_call_count"] = recalled.get("provider_call_count", 0) + packet["context_status"] = recalled.get("status") + _, after_scope, _ = scope() + after_projection = projection() + after_quota = read_current_quota() + packet.update(current_quota=after_quota, work_projection=after_projection) + readback = effect_runtime_result("goal_channel.work.resume_readback", {"goal_id": goal_id, "actor_id": actor_id, + "before_scope": before_scope, "after_scope": after_scope, "before_projection": before_projection, + "after_projection": after_projection, "before_quota": before_quota, "after_quota": after_quota, + "recall": recalled, "artifact_refs": refs, "artifact_scope_refs": artifact_scopes}) + packet.update(readback=readback, artifact_references=readback["artifact_references"]) + if not readback["authority_observation_stable"]: + return {**packet, "status": "authority_changed", "blocker": "authority_observation_changed"} + packet.update(ok=True, status="restored" if readback["context_usable"] else "context_unavailable", + private_context=recalled.get("context") if readback["context_usable"] else None) + return packet + except (OSError, RuntimeError, TypeError, ValueError): + # Scope/readback failure invalidates every earlier observation, including + # the private quota packet. Do not label a pre-retrieval read "current". + packet.pop("current_quota", None) + packet.pop("work_projection", None) + packet.pop("readback", None) + return {**packet, "ok": False, "status": "rejected", "private_context": None, + "artifact_references": [], "blocker": "resume_scope_unavailable"} diff --git a/loopx/presentation/room_work_api.py b/loopx/presentation/room_work_api.py new file mode 100644 index 0000000000..c92e278d12 --- /dev/null +++ b/loopx/presentation/room_work_api.py @@ -0,0 +1,79 @@ +"""Loopback room-work transport; the existing Todo and delivery owners decide effects.""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +from ..control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route +from ..extensions.lark.goal_channel_contracts import binding_for_goal, read_goal_channel_binding +from ..extensions.lark.goal_channel_delivery_contract import goal_channel_binding_digest +from ..extensions.lark.goal_channel_work import _resolve_binding, run_goal_channel_work +from ..extensions.lark import LARK_EXTENSION_ID, LARK_GOAL_CHANNEL_PERMISSION, goal_channel_work +from ..extensions.runtime import default_extension_state_file + +ROOM_WORK_PATH = "/api/chat/lark/room-work" + + +class RoomWorkChatRequestMixin: + def _room_work(self, *, claim: bool = False) -> None: + try: + if claim: + body = self._read_json() + allowed = {"goal_id", "connection_id", "scope_revision", "todo_id", "expected_revision", "idempotency_key"} + if not isinstance(body, dict) or set(body) != allowed: + raise ValueError("invalid room claim fields") + else: + query = parse_qs(urlparse(self.path).query) + if not {"goal_id", "connection_id"} <= set(query) or set(query) - {"goal_id", "connection_id", "todo_id"} or any(len(v) != 1 for v in query.values()): + raise ValueError("invalid room work target") + body = {k: v[0] for k, v in query.items()} + if any(not isinstance(v, str) or not v.strip() or len(v) > 512 for v in body.values()): + raise ValueError("invalid room work input") + goal_id, connection_id = body["goal_id"], body["connection_id"] + _, binding_path = self._goal_channel_context(goal_id) + route = resolve_goal_source_runtime_route(registry_path=self.server.registry_path, goal_id=goal_id) + selected = binding_for_goal(read_goal_channel_binding(binding_path), goal_id, connection_id=connection_id) + actor = (selected or {}).get("agent_id") + routing = (selected or {}).get("routing") or {} + if not isinstance(routing, dict) or routing.get("conversation_kind") not in (None, "goal"): + raise ValueError("a Goal connection is required") + if not isinstance(actor, str) or not actor or selected.get("enabled") is not True: + raise ValueError("an enabled Agent connection is required") + target_path = self._goal_channel_target_path() + binding = _resolve_binding(binding_path, target_path, goal_id, actor) + if binding.get("connection_id") != connection_id: + raise ValueError("connection does not match the Agent route") + digest = goal_channel_binding_digest(binding) + # An opaque freshness witness, never a claim grant or persisted state. + scope = "sha256:" + hashlib.sha256(json.dumps([ + digest, str(Path(route["source_registry"]).resolve()), + str(Path(route["source_runtime_root"]).resolve()), + ], separators=(",", ":")).encode()).hexdigest() + if claim and body["scope_revision"] != scope: + return self._send_error("The connection changed. Refresh room work before claiming.", + status=409, error_code="room_work_scope_changed") + packet = run_goal_channel_work( + registry_path=Path(route["source_registry"]), runtime_root=Path(route["source_runtime_root"]), + binding_path=binding_path, target_path=target_path, goal_id=goal_id, actor_id=actor, + command="claim" if claim else "project", execute=claim, runner=self._lark_runner(), + todo_id=body.get("todo_id"), expected_revision=body.get("expected_revision"), + idempotency_key=body.get("idempotency_key"), expected_connection_id=connection_id, + expected_binding_digest=digest, invoked_registry_path=self.server.registry_path, + ) + activation = {} + if not claim: + try: + activation = goal_channel_work.resolve_extension_activation(LARK_EXTENSION_ID, + state_file=default_extension_state_file(Path(route["source_runtime_root"])), + required_permissions=(LARK_GOAL_CHANNEL_PERMISSION,)) + except ValueError: + # Inactive/incompatible extensions leave an explicitly read-only view. + pass + self._send_json({**packet, "connection_id": connection_id, "scope_revision": scope, + "claim_available": packet.get("ok") is True and (activation.get("enabled") is True or + (claim and packet.get("canonical_claim_accepted") is True and packet.get("readback_verified") is True))}) + except (ValueError, KeyError): + self._send_error("Room work is unavailable for this connection. Check its Agent and authority, then refresh.", + status=400, error_code="room_work_unavailable") diff --git a/tests/capabilities/test_agent_turn_recall.py b/tests/capabilities/test_agent_turn_recall.py index ca8d2ccc4e..92124c4f8b 100644 --- a/tests/capabilities/test_agent_turn_recall.py +++ b/tests/capabilities/test_agent_turn_recall.py @@ -589,7 +589,7 @@ def test_explicit_cli_checkpoint_transport_failure_returns_safe_packet( turn = "turn-cli-failure" decision = quota_decision() | {"goal_id": "goal", "agent_identity": {"agent_id": "pilot"}, "heartbeat_receipt": {"turn_instance_id": turn, "status": "committed"}} - monkeypatch.setattr(recall_cli, "_quota_decision", lambda *_args: decision) + monkeypatch.setattr(recall_cli, "load_turn_quota_decision", lambda *_args: decision) def unavailable(operation: str, _params: Any) -> Any: assert operation == "reward_memory.read_authority.surface_checkpoints" diff --git a/tests/capabilities/test_outbound_guidance.py b/tests/capabilities/test_outbound_guidance.py index 03908df022..c39f2dcbf1 100644 --- a/tests/capabilities/test_outbound_guidance.py +++ b/tests/capabilities/test_outbound_guidance.py @@ -154,7 +154,7 @@ def test_cli_opaque_turn_uses_real_timestamp(tmp_path, monkeypatch): monkeypatch.setattr( cli, "resolve_reward_memory_experiment", lambda **kw: ({}, config) ) - monkeypatch.setattr(cli, "_quota_decision", lambda path: quota) + monkeypatch.setattr(cli, "load_turn_quota_decision", lambda path: quota) observed = [] def run(config, situation, **kw): diff --git a/tests/control_plane/test_room_claim_callback.py b/tests/control_plane/test_room_claim_callback.py new file mode 100644 index 0000000000..1236068dd7 --- /dev/null +++ b/tests/control_plane/test_room_claim_callback.py @@ -0,0 +1,346 @@ +"""Synthetic authenticated Lark ingress, actual canonical File/SQLite work.""" +import json +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from test_room_work import setup as room_setup, snapshot, GOAL, TODO, ROOT # noqa: F401 +from loopx.extensions.lark import room_claim as ingress +from loopx.extensions.lark.goal_channel_contracts import write_goal_channel_binding +from loopx.control_plane.projects.registry_codec import project_registry_transaction + + +@pytest.fixture +def offered(room_setup, monkeypatch): # noqa: F811 - pytest fixture dependency + args, room, binding = room_setup + source = json.loads(args["registry_path"].read_text()) + source["registry_role"] = "global-local" + source["goals"][0]["source_registry"] = str(args["registry_path"]) + (args["runtime_root"] / "registry.global.json").write_text(json.dumps(source)) + monkeypatch.setattr(ingress, "resolve_extension_activation", lambda *a, **k: {"enabled": True}) + transport_state = {"fail_update": False, "readable": True, "member": True} + + def runner(argv, cwd, timeout): + payload = None + if "auth" in argv: + return room(argv, cwd, timeout) + if "chats" in argv and "get" in argv: + payload = {"data": {"tenant_key": "room-fixture-tenant", "chat_id": "oc_room_fixture"}} + elif "+chat-members-list" in argv and "--member-types" in argv and argv[argv.index("--member-types") + 1] == "user": + payload = {"data": {"items": [{"open_id": "ou_room_owner", "tenant_key": "room-fixture-tenant"}] + if transport_state["member"] else []}} + elif "api" in argv and "GET" in argv: + payload = {"data": {"items": room.messages if transport_state["readable"] else []}} + elif "api" in argv and "POST" in argv: + if transport_state["fail_update"]: + return {"returncode": 1, "stdout": "", "stderr": "", "timed_out": True} + data = json.loads(argv[argv.index("--data") + 1]) + mid = data["token"].removeprefix("callback:") + target = next(m for m in room.messages if m["message_id"] == mid) + target["body"]["content"] = json.dumps(data["card"]) + payload = {} + elif "messages" in argv and "patch" in argv: + mid = argv[argv.index("--message-id") + 1] + data = json.loads(argv[argv.index("--data") + 1]) + next(m for m in room.messages if m["message_id"] == mid)["body"]["content"] = data["content"] + payload = {} + if payload is None: + result = room(argv, cwd, timeout) + payload = json.loads(result["stdout"]) + payload["ok"] = True + return {"returncode": 0, "stdout": json.dumps(payload), "stderr": ""} + + offer_args = dict(registry_path=args["registry_path"], authority_root=args["runtime_root"], + broker_root=args["runtime_root"], binding_path=args["binding_path"], target_path=args["target_path"], + goal_id=GOAL, runner=runner) + revision = snapshot(args)["provider_revision"] + + def offer(actor="agent-a", key="room-offer-fixture", execute=True): + return ingress.run_room_claim_offer(**offer_args, actor_id=actor, command="offer", execute=execute, + todo_id=TODO, expected_revision=revision, idempotency_key=key, + principals=["lark:ou_room_owner"], expires_at="2030-01-01T00:00:00Z") + + def event(result): + mid = next(m["message_id"] for m in room.messages + if json.loads(m["body"]["content"])["body"]["elements"][1]["behaviors"][0]["value"]["request_id"] == result["request_id"]) + card = json.loads(next(m for m in room.messages if m["message_id"] == mid)["body"]["content"]) + return {"type": "card.action.trigger", "action_tag": "button", "host": "im_message", + "operator_id": "ou_room_owner", "message_id": mid, "chat_id": "oc_room_fixture", + "token": "callback:" + mid, "event_id": "room-fixture-event", + "action_value": card["body"]["elements"][1]["behaviors"][0]["value"]} + + callback_args = dict(runtime_root=args["runtime_root"], profile_app_id="cli_room_fixture", + cli_bin="fixture-cli", profile="room-fixture", runner=runner) + return args, room, binding, offer_args, offer, event, callback_args, transport_state + + +def test_offer_preview_and_cli_do_not_claim_or_publish(offered): + args, room, _, _, offer, _, _, _ = offered + before = snapshot(args)["provider_revision"] + preview = offer(execute=False) + assert preview["ok"] and not preview["canonical_claim_accepted"], preview + assert not room.messages and not (args["runtime_root"] / "room-claim-offers").exists() + assert snapshot(args)["provider_revision"] == before + proc = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", "goal-channel", "work", "offer", "--goal-id", GOAL, "--agent-id", "agent-a", + "--todo-id", TODO, "--expected-revision", before, "--idempotency-key", "cli-offer-fixture", + "--principal", "lark:ou_room_owner", "--expires-at", "2030-01-01T00:00:00Z"], + cwd=ROOT, text=True, capture_output=True) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert json.loads(proc.stdout)["canonical_claim_accepted"] is False + assert "PRIVATE_" not in proc.stdout and snapshot(args)["provider_revision"] == before + + +def test_authenticated_callback_and_duplicate_return_one_canonical_claim(offered): + args, room, _, _, offer, event, callback_args, _ = offered + before = snapshot(args)["provider_revision"] + prepared = offer() + assert prepared["ok"] and prepared["readback_verified"], json.dumps(prepared) + assert snapshot(args)["provider_revision"] == before + callback = event(prepared) + accepted = ingress.handle_room_claim_callback(callback, **callback_args) + assert accepted["ok"] and accepted["canonical_claim_accepted"], accepted + assert accepted["execution_authority_granted"] is False + assert snapshot(args)["todos"][0]["claimed_by"] == "agent-a" + committed = snapshot(args)["provider_revision"] + replay = ingress.handle_room_claim_callback(callback, **callback_args) + assert replay["ok"] and replay["claim_status"] == "already_applied", replay + assert snapshot(args)["provider_revision"] == committed + assert "PRIVATE_" not in json.dumps(room.messages) and len(room.messages) == 1 + + +def test_two_actor_offers_compete_at_one_revision(offered): + args, _, _, _, offer, event, callback_args, _ = offered + cards = [offer(actor, "compete-" + actor) for actor in ["agent-a", "agent-b"]] + assert all(card["ok"] for card in cards), cards + callbacks = [event(card) for card in cards] + with ThreadPoolExecutor(2) as pool: + receipts = list(pool.map(lambda e: ingress.handle_room_claim_callback(e, **callback_args), callbacks)) + assert sum(r["canonical_claim_accepted"] for r in receipts) == 1, receipts + assert sorted(r["claim_status"] for r in receipts) == ["applied", "conflict"] + assert snapshot(args)["todos"][0]["claimed_by"] in {"agent-a", "agent-b"} + + +@pytest.mark.parametrize("rejection", ["principal", "grant", "actor", "binding", "message", "app", "card", "expiry", "membership", "route"]) +def test_scope_and_stale_callback_rejections_preserve_canonical_state(offered, monkeypatch, rejection): + args, room, binding, offer_args, offer, event, callback_args, transport = offered + prepared = offer() + assert prepared["ok"], prepared + callback = event(prepared) + before = snapshot(args)["provider_revision"] + if rejection == "principal": + callback["operator_id"] = "ou_unapproved_room_member" + elif rejection == "grant": + revoked = ingress.run_room_claim_offer(**offer_args, actor_id="agent-a", command="revoke", execute=True, + request_id=prepared["request_id"]) + assert revoked["ok"] + elif rejection == "actor": + r = json.loads(args["registry_path"].read_text()) + r["goals"][0]["coordination"]["registered_agents"] = ["agent-b"] + args["registry_path"].write_text(json.dumps(r)) + elif rejection == "binding": + binding["bindings"][GOAL]["connections"]["agent-a"]["enabled"] = False + write_goal_channel_binding(args["binding_path"], binding) + elif rejection == "message": + callback["message_id"] = "om_another_card" + elif rejection == "app": + callback_args["profile_app_id"] = "cli_another_app" + elif rejection == "card": + card = json.loads(room.messages[0]["body"]["content"]) + card["body"]["elements"][1]["behaviors"][0]["value"]["request_id"] = "rc_" + "0" * 32 + room.messages[0]["body"]["content"] = json.dumps(card) + elif rejection == "expiry": + monkeypatch.setattr(ingress, "_now", lambda: "2030-01-01T00:00:00Z") + elif rejection == "membership": + transport["member"] = False + elif rejection == "route": + r = json.loads((args["runtime_root"] / "registry.global.json").read_text()) + r["goals"][0]["source_registry"] = str(args["registry_path"].parent / "rebound.json") + (args["runtime_root"] / "registry.global.json").write_text(json.dumps(r)) + with pytest.raises(ingress.RoomClaimCallbackError): + ingress.handle_room_claim_callback(callback, **callback_args) + assert snapshot(args)["provider_revision"] == before + assert not snapshot(args)["todos"][0].get("claimed_by") + assert "PRIVATE_" not in json.dumps(room.messages) + + +@pytest.mark.parametrize("read_stage", ["operator_membership_verified", "read_callback_card_content"]) +def test_callback_retargeted_during_provider_read_cannot_claim(offered, monkeypatch, read_stage): + args, room, binding, _, offer, event, callback_args, _ = offered + operation = "retarget-during-provider-read" + prepared = offer(key=operation) + assert prepared["ok"], prepared + callback = event(prepared) + before = snapshot(args) + messages = json.dumps(room.messages) + def receipt(): + return ingress.effect_runtime_result("coordination.local_authority.operation_receipt", { + "schema_version": "loopx_local_coordination_operation_receipt_request_v0", + "runtime_root": str(args["runtime_root"]), "goal_id": GOAL, "operation_id": operation, + }) + + original_receipt = receipt() + assert original_receipt["status"] == "missing", original_receipt + original_read = getattr(ingress, read_stage) + + def retarget_after_read(**kwargs): + observed = original_read(**kwargs) + binding["bindings"][GOAL]["connections"]["agent-a"]["channel"]["chat_id"] = "oc_retargeted_fixture" + write_goal_channel_binding(args["binding_path"], binding) + return observed + + monkeypatch.setattr(ingress, read_stage, retarget_after_read) + with pytest.raises(ingress.RoomClaimCallbackError, match="Room claim callback") as rejected: + ingress.handle_room_claim_callback(callback, **callback_args) + assert rejected.value.code == "binding_changed" + assert snapshot(args) == before + assert not snapshot(args)["todos"][0].get("claimed_by") + assert receipt() == original_receipt + assert json.dumps(room.messages) == messages + + +@pytest.mark.parametrize("read_stage", ["operator_membership_verified", "read_callback_card_content", "claim_effect"]) +def test_callback_source_route_change_cannot_claim_old_source(offered, monkeypatch, read_stage): + args, room, _, _, offer, event, callback_args, _ = offered + operation = "source-route-during-callback" + prepared = offer(key=operation) + assert prepared["ok"], prepared + callback = event(prepared) + before = snapshot(args) + messages = json.dumps(room.messages) + broker = args["runtime_root"] / "registry.global.json" + replacement = args["registry_path"].with_name("another-source.json") + replacement.write_bytes(args["registry_path"].read_bytes()) + + def receipt(): + return ingress.effect_runtime_result("coordination.local_authority.operation_receipt", { + "schema_version": "loopx_local_coordination_operation_receipt_request_v0", + "runtime_root": str(args["runtime_root"]), "goal_id": GOAL, "operation_id": operation, + }) + + original_receipt = receipt() + assert original_receipt["status"] == "missing", original_receipt + + def retarget(): + with project_registry_transaction(broker, operation="fixture_source_retarget") as transaction: + current = transaction.payload_copy() + current["goals"][0]["source_registry"] = str(replacement) + transaction.commit(current) + + if read_stage == "claim_effect": + from loopx.extensions.lark import goal_channel_work + original = goal_channel_work.claim_canonical_todo_if_promoted + + def change_before_native_claim(**kwargs): + retarget() + return original(**kwargs) + + monkeypatch.setattr(goal_channel_work, "claim_canonical_todo_if_promoted", change_before_native_claim) + else: + original = getattr(ingress, read_stage) + + def change_after_read(**kwargs): + result = original(**kwargs) + retarget() + return result + + monkeypatch.setattr(ingress, read_stage, change_after_read) + with pytest.raises(ingress.RoomClaimCallbackError) as rejected: + ingress.handle_room_claim_callback(callback, **callback_args) + assert rejected.value.code == "source_route_changed" + assert snapshot(args) == before + assert not snapshot(args)["todos"][0].get("claimed_by") + assert receipt() == original_receipt + assert json.dumps(room.messages) == messages + + +def test_restart_recovers_only_result_transport_without_a_new_transition(offered): + args, _, _, _, offer, event, callback_args, transport = offered + prepared = offer() + assert prepared["ok"], prepared + transport["fail_update"] = True + pending = ingress.handle_room_claim_callback(event(prepared), **callback_args) + assert pending["canonical_claim_accepted"] and not pending["ok"], pending + committed = snapshot(args)["provider_revision"] + transport["fail_update"] = False + recovery = ingress.recover_room_claim_results(**callback_args, allowed_chat_ids={"oc_room_fixture"}) + assert recovery == {"attempted": 1, "delivered": 1, "failed": 0} + assert snapshot(args)["provider_revision"] == committed + assert ingress.recover_room_claim_results(**callback_args, allowed_chat_ids={"oc_room_fixture"})["attempted"] == 0 + + +def test_collector_default_off_and_native_callback_dispatch(offered, tmp_path): + import shutil + from loopx.extensions.lark.event_collector_runtime import run_lark_event_collector + + args, room, binding, _, offer, event, callback_args, _ = offered + project = args["registry_path"].parent.parent + subprocess.run(["git", "init", "-q", str(project)], check=True) + (project / ".gitignore").write_text(".loopx/\nruntime/\n") + cli = tmp_path / "room-event-cli.mjs" + for connection in binding["bindings"][GOAL]["connections"].values(): + connection["identity"]["cli_bin"] = str(cli) + write_goal_channel_binding(args["binding_path"], binding) + prepared = offer() + assert prepared["ok"], prepared + callback = event(prepared) + cli.write_text("const key=process.argv[process.argv.indexOf('consume')+1];" + "if(key==='card.action.trigger'){console.log('[event] ready event_key=card.action.trigger');" + "console.log(" + json.dumps(json.dumps(callback)) + ");}else{setTimeout(()=>{},500);}") + cfg = project / ".loopx/config/lark" + cfg.mkdir(parents=True) + (cfg / "inbox.json").write_text(json.dumps({"schema_version": "lark_event_inbox_config_v0", "enabled": True, + "inbox_dir": ".loopx/inbox/room", "capture_scope": "configured_chat_all", "reply": {"enabled": True, + "sender_profile": "room-fixture", "sender_identity": "bot", "bot_display_name": "Fixture Bot", + "chat_id": "oc_room_fixture", "placement_policy": "source_context", "editorial_style": "bullet_points_preferred"}})) + config = {"schema_version": "lark_event_collector_config_v1", "enabled": True, + "service_name": "loopx-room-fixture", "event_key": "im.message.receive_v1", "identity": "bot", + "supervisor": "systemd", "consume_timeout": "30m", "lark_cli_bin": "lark-cli", + "operation_callbacks": {"enabled": False}, "routes": [{"route_key": "room", + "chat_id": "oc_room_fixture", "event_inbox_config": ".loopx/config/lark/inbox.json"}]} + path = cfg / "collector.json" + path.write_text(json.dumps(config)) + + def process_runner(argv, **kwargs): + if "whoami" in argv: + return subprocess.CompletedProcess(argv, 0, json.dumps({"appId": "cli_room_fixture"}), "") + reply = callback_args["runner"](argv, None, kwargs.get("timeout")) + return subprocess.CompletedProcess(argv, reply["returncode"], reply["stdout"], reply.get("stderr", "")) + + before = snapshot(args)["provider_revision"] + inactive = run_lark_event_collector(project=project, config_path=path, lark_cli_executable=str(cli), + node_executable=shutil.which("node"), runtime_root=args["runtime_root"], runner=process_runner) + assert inactive["ok"] is True + assert inactive.get("operation_callback_listener_started", False) is False + assert snapshot(args)["provider_revision"] == before + config["operation_callbacks"]["enabled"] = True + path.write_text(json.dumps(config)) + active = run_lark_event_collector(project=project, config_path=path, lark_cli_executable=str(cli), + node_executable=shutil.which("node"), runtime_root=args["runtime_root"], runner=process_runner) + assert active["operation_callback_listener_started"] is True + assert active["operation_callback_verified_count"] == 1, active + assert snapshot(args)["todos"][0]["claimed_by"] == "agent-a" + assert len(room.messages) == 1 + + +def test_legacy_offer_cannot_inherit_an_exact_goal_instance(offered): + from loopx.control_plane.projects.registry_codec import source_session_registry_transaction + args, _, _, _, offer, event, callback_args, _ = offered + prepared = offer() + assert prepared["ok"], prepared + callback = event(prepared) + before = snapshot(args)["provider_revision"] + payload = json.loads(args["registry_path"].read_text()) + payload.update(profile_id="source_session_v1", session_bindings=[], session_receipts=[], lifetime_receipts=[]) + payload["goals"][0].update(goal_instance_id="ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", status="active") + args["registry_path"].unlink() + with source_session_registry_transaction(args["registry_path"], operation="create_fixture_instance", + create=lambda: payload) as transaction: + transaction.commit(transaction.payload_copy()) + with pytest.raises(ingress.RoomClaimCallbackError): + ingress.handle_room_claim_callback(callback, **callback_args) + assert snapshot(args)["provider_revision"] == before + denied = offer(key="exact-profile-offer") + assert not denied["ok"] and denied["blocker"] == "exact_instance_claim_unqualified", denied diff --git a/tests/control_plane/test_room_resume.py b/tests/control_plane/test_room_resume.py new file mode 100644 index 0000000000..bc07dc65a2 --- /dev/null +++ b/tests/control_plane/test_room_resume.py @@ -0,0 +1,281 @@ +"""Private context, synthetic OV/Lark, real local authority and source CLI.""" +import copy +import argparse +import json +import subprocess +import sys + +import pytest + +from test_room_work import setup as room_setup, snapshot, claim, GOAL, TODO, ROOT # noqa: F401 +from loopx.cli_commands.goal_channel import _quota_packet, handle_goal_channel_command +from loopx.extensions.lark import room_resume as resume +from loopx.extensions.lark.goal_channel_contracts import write_goal_channel_binding +from loopx.capabilities.agent_turn_recall import runtime as recall_runtime +from loopx.capabilities.reward_memory import outcome_lifecycle +from loopx.capabilities.reward_memory.experiment import load_reward_memory_experiment_config +from loopx.capabilities.context_providers.base import ContextProviderItem, ContextProviderRetrieval +from tests.capabilities.test_agent_turn_recall import raw_config, active_record + +TURN = "room-resume-fixture-turn" + + +@pytest.fixture +def reconnect(room_setup, monkeypatch): # noqa: F811 + args, room, binding = room_setup + assert claim(args, "agent-a", snapshot(args)["provider_revision"], "resume-initial-claim")["ok"] + room.messages.clear() + source = json.loads(args["registry_path"].read_text()) + source["goals"][0].update(domain="room-resume-fixture", status="active-read-only", + adapter={"kind": "read_only_project_map_v0", "status": "connected-read-only"}) + args["registry_path"].write_text(json.dumps(source)) + source["registry_role"] = "global-local" + source["goals"][0]["source_registry"] = str(args["registry_path"]) + (args["runtime_root"] / "registry.global.json").write_text(json.dumps(source)) + monkeypatch.setattr(resume, "resolve_extension_activation", lambda *a, **k: {"enabled": True}) + + def fresh(): + return _quota_packet(registry_path=args["registry_path"], runtime_root_arg=str(args["runtime_root"]), + goal_id=GOAL, agent_id="agent-a") + + quota = fresh() + admitted = copy.deepcopy(quota) + admitted.update(mode="should-run", heartbeat_receipt={"turn_instance_id": TURN, "status": "committed"}) + kwargs = dict(registry_path=args["registry_path"], authority_root=args["runtime_root"], + broker_root=args["runtime_root"], binding_path=args["binding_path"], target_path=args["target_path"], + goal_id=GOAL, actor_id="agent-a", quota_decision=admitted, turn_instance_id=TURN, + read_current_quota=fresh, runner=room) + return args, room, binding, kwargs + + +def test_source_cli_preview_is_private_and_performs_no_recall_or_claim(reconnect, tmp_path): + args, room, _, kwargs = reconnect + before = snapshot(args)["provider_revision"] + result = resume.run_room_resume(**kwargs, execute=False) + assert result["ok"] and result["status"] == "planned", json.dumps(result) + saved = tmp_path / "admitted.json" + guard = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", "quota", "should-run", "--goal-id", GOAL, "--agent-id", "agent-a", + "--codex-app", "--turn-instance-id", TURN, "--available-capability", "network"], + cwd=ROOT, text=True, capture_output=True) + assert guard.returncode == 0, guard.stdout + guard.stderr + admitted = json.loads(guard.stdout) + assert admitted["ok"] and admitted["heartbeat_receipt"]["status"] == "committed", admitted + saved.write_text(guard.stdout) + proc = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", "goal-channel", "work", "resume", "--goal-id", GOAL, "--agent-id", "agent-a", + "--turn-instance-id", TURN, "--quota-decision-json", str(saved)], cwd=ROOT, text=True, capture_output=True) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert json.loads(proc.stdout)["status"] == "planned" + assert not room.messages and snapshot(args)["provider_revision"] == before + assert result["provider_call_count"] == 0 and result["private_context"] is None + + +def test_default_off_preserves_canonical_work_without_room_or_memory_writes(reconnect): + args, room, _, kwargs = reconnect + before = snapshot(args)["provider_revision"] + result = resume.run_room_resume(**kwargs, execute=True) + assert result["ok"] and result["status"] == "context_unavailable", result + assert result["private_context"] is None and result["artifact_references"] == [] + assert result["provider_call_count"] == 0 and not room.messages + assert snapshot(args)["provider_revision"] == before + assert not result["external_writes_performed"] and not result["execution_authority_granted"] + + +@pytest.fixture +def recalled(reconnect, tmp_path, monkeypatch): + args, room, binding, kwargs = reconnect + raw = raw_config(goal_id=GOAL, agent_id="agent-a", peer_ref="agent:agent-a") + raw["automation"]["automatic_ingest"] = True + path = tmp_path / "recall-config.json" + path.write_text(json.dumps(raw)) + config = load_reward_memory_experiment_config(project=tmp_path, config_path=path.name) + status = {"ok": True, "status": "available", "automatic_recall": True, "automatic_ingest": True} + def resolver(**_): + return status, config + monkeypatch.setattr(resume, "resolve_reward_memory_experiment", resolver) + monkeypatch.setattr(recall_runtime, "resolve_reward_memory_experiment", resolver) + + def forbid_ingest(**_): + raise AssertionError("Read-only room restore must not reconcile memory writes") + monkeypatch.setattr(outcome_lifecycle, "reconcile_pending_turn_outcome_ingests_fail_open", forbid_ingest) + + class Provider: + provider_id = "openviking" + calls = 0 + mutate = None + unavailable = False + content = active_record(config, expires_at="2030-01-01T00:00:00Z") + ref = raw["project_provider_binding"]["corpus_scopes"][0]["scope_ref"] + "/artifact.json" + + def retrieve(self, **parameters): + self.calls += 1 + if self.mutate: + self.mutate() + if self.unavailable: + raise RuntimeError("synthetic unavailable provider") + assert self.ref.startswith(parameters["scope_ref"].rstrip("/") + "/") + return ContextProviderRetrieval(provider="openviking", namespace=parameters["namespace"], + status="completed", query_summary=parameters["query_summary"], observed_at=parameters["observed_at"], + search_performed=True, read_performed=True, + items=(ContextProviderItem(resource_ref=self.ref, summary="Scoped artifact", content=self.content),)) + + provider = Provider() + return args, room, binding, kwargs, provider, config + + +def test_restore_carries_only_current_explicit_scoped_references_without_writes(recalled): + args, room, _, kwargs, provider, _ = recalled + before = snapshot(args)["provider_revision"] + requested = [provider.ref, "viking://user/other/private.json"] + first = resume.run_room_resume(**kwargs, execute=True, artifact_refs=requested, provider=provider) + assert first["ok"] and first["status"] == "restored", json.dumps(first) + assert first["private_context"]["guidance"] + assert first["artifact_references"] == [{"ref": provider.ref, "source": "current_scoped_recall", "target_access_granted": False}] + assert first["readback"]["omitted_reference_count"] == 1 + assert first["current_quota"]["selected_todo"]["claimed_by"] == "agent-a" + # Reconnect retrieves anew even when a previous private receipt exists. + second = resume.run_room_resume(**kwargs, execute=True, provider=provider) + assert second["ok"] and provider.calls == 2 + assert snapshot(args)["provider_revision"] == before and not room.messages + assert not first["execution_authority_granted"] and not first["external_writes_performed"] + + +@pytest.mark.parametrize("change", ["actor", "binding", "route", "memory_scope", "canonical_revision", "user_gate"]) +def test_changes_during_retrieval_discard_context_and_references(recalled, change): + args, room, binding, kwargs, provider, config = recalled + before = snapshot(args)["provider_revision"] + + def mutate(): + if change == "binding": + binding["bindings"][GOAL]["connections"]["agent-a"]["enabled"] = False + write_goal_channel_binding(args["binding_path"], binding) + elif change == "memory_scope": + config["automation"]["automatic_recall"] = False + elif change in {"actor", "route"}: + path = args["registry_path"] if change == "actor" else args["runtime_root"] / "registry.global.json" + source = json.loads(path.read_text()) + if change == "actor": + source["goals"][0]["coordination"]["registered_agents"] = ["agent-b"] + else: + source["goals"][0]["source_registry"] = str(path.parent / "replacement.json") + path.write_text(json.dumps(source)) + else: + operation = ["todo", "update", "--todo-id", TODO, "--text", "Current fixture task changed.", + "--update-operation-id", "resume-concurrent-fixture"] if change == "canonical_revision" else [ + "todo", "add", "--role", "user", "--task-class", "user_gate", "--blocks-agent", "agent-a", + "--bound-agent", "agent-a", "--text", "Approve the fixture action."] + proc = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", *operation, "--goal-id", GOAL, "--agent-id", "agent-a"], + cwd=ROOT, text=True, capture_output=True) + assert proc.returncode == 0, proc.stdout + proc.stderr + + provider.mutate = mutate + result = resume.run_room_resume(**kwargs, execute=True, artifact_refs=[provider.ref], provider=provider) + assert not result["ok"], result + assert result["private_context"] is None and result["artifact_references"] == [] + assert provider.calls == 1 and not room.messages + if change in {"actor", "binding", "route"}: + assert "current_quota" not in result and "work_projection" not in result + if change == "user_gate": + assert result["status"] == "authority_changed" + assert result["current_quota"]["should_run"] is False + assert result["work_projection"]["counts"]["user_gates"] == 1 + if change not in {"canonical_revision", "user_gate"}: + assert snapshot(args)["provider_revision"] == before + + +def test_provider_failure_preserves_current_quota_with_reduced_context(recalled): + args, room, _, kwargs, provider, _ = recalled + before = snapshot(args)["provider_revision"] + provider.unavailable = True + result = resume.run_room_resume(**kwargs, execute=True, artifact_refs=[provider.ref], provider=provider) + assert result["ok"] and result["status"] == "context_unavailable", result + assert result["private_context"] is None and result["artifact_references"] == [] + assert result["current_quota"]["selected_todo"]["todo_id"] == TODO + assert not room.messages and snapshot(args)["provider_revision"] == before + + +@pytest.mark.parametrize("invalid", ["actor", "turn", "revision_basis", "provider_identity"]) +def test_invalid_admission_never_contacts_context_provider(recalled, invalid): + args, room, _, kwargs, provider, _ = recalled + kwargs = copy.deepcopy(kwargs) if invalid != "provider_identity" else dict(kwargs) + if invalid == "actor": + kwargs["quota_decision"]["agent_identity"]["agent_id"] = "agent-b" + elif invalid == "turn": + kwargs["turn_instance_id"] = "old-turn" + elif invalid == "revision_basis": + kwargs["quota_decision"]["selected_todo"]["todo_id"] = "todo_other" + else: + room.authenticated = False + before = snapshot(args)["provider_revision"] + result = resume.run_room_resume(**kwargs, execute=True, provider=provider) + assert not result["ok"] and provider.calls == 0 + assert result["private_context"] is None and not room.messages + assert snapshot(args)["provider_revision"] == before + + +@pytest.mark.parametrize("invalid", ["expired", "foreign_peer"]) +def test_unqualified_records_cannot_restore_context_or_references(recalled, invalid): + _, room, _, kwargs, provider, _ = recalled + content = json.loads(provider.content) + if invalid == "expired": + content["lifecycle"]["expires_at"] = "2000-01-01T00:00:00Z" + else: + content["scope"]["peer_ref"] = "agent:other" + provider.content = json.dumps(content) + result = resume.run_room_resume(**kwargs, execute=True, artifact_refs=[provider.ref], provider=provider) + assert result["ok"] and result["status"] == "context_unavailable", result + assert result["private_context"] is None and result["artifact_references"] == [] and not room.messages + + +def test_existing_automatic_recall_keeps_its_reconciliation_default(recalled, monkeypatch): + _, _, _, kwargs, provider, _ = recalled + reconciled = [] + monkeypatch.setattr(outcome_lifecycle, "reconcile_pending_turn_outcome_ingests_fail_open", + lambda **options: reconciled.append(options) or {"ok": True, "status": "not_required"}) + recall_runtime.run_configured_agent_turn_recall(registry_path=kwargs["registry_path"], goal_id=GOAL, + agent_id="agent-a", quota_decision=kwargs["quota_decision"], turn_instance_id=TURN, + execute=True, force_refresh=True, provider=provider) + assert len(reconciled) == 1 + assert resume.run_room_resume(**kwargs, execute=True, provider=provider)["ok"] + assert len(reconciled) == 1 + + +def test_executing_cli_dispatch_returns_private_context_without_room_delivery(recalled, tmp_path, monkeypatch): + args, room, _, kwargs, provider, _ = recalled + saved = tmp_path / "quota.json" + saved.write_text(json.dumps(kwargs["quota_decision"])) + actual = resume.run_room_resume + monkeypatch.setattr(resume, "run_room_resume", lambda **options: actual(**options, runner=room, provider=provider)) + namespace = argparse.Namespace(command="goal-channel", goal_channel_command="work", goal_channel_work_command="resume", + goal_id=GOAL, agent_id="agent-a", execute=True, binding_path=None, target_path=None, + turn_instance_id=TURN, quota_decision_json=str(saved), artifact_ref=[provider.ref]) + captured = {} + code = handle_goal_channel_command(namespace, registry_path=args["registry_path"], + runtime_root_arg=str(args["runtime_root"]), print_payload=lambda p, *_: captured.update(p), output_format=lambda _: "json") + assert code == 0 and captured["status"] == "restored", captured + assert captured["private_context"]["guidance"] and captured["artifact_references"] + assert not room.messages and not captured["execution_authority_granted"] + + +def test_exact_instance_profile_fails_closed_before_retrieval(recalled): + from loopx.control_plane.projects.registry_codec import source_session_registry_transaction + from loopx.control_plane.coordination.local_authority import claim_canonical_todo_if_promoted + args, room, _, kwargs, provider, _ = recalled + before = snapshot(args)["provider_revision"] + source = json.loads(args["registry_path"].read_text()) + source.update(profile_id="source_session_v1", session_bindings=[], session_receipts=[], lifetime_receipts=[]) + source["goals"][0].update(goal_instance_id="ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", status="active") + args["registry_path"].unlink() + with source_session_registry_transaction(args["registry_path"], operation="create_resume_fixture", create=lambda: source) as tx: + tx.commit(tx.payload_copy()) + result = resume.run_room_resume(**kwargs, execute=True, provider=provider) + assert not result["ok"] and provider.calls == 0 and result["private_context"] is None and not room.messages + # The underlying current Todo facade is also profile-gated. A room adapter + # must not bypass this owner or infer a Goal instance from its provider key. + with pytest.raises(ValueError, match="source_session_v1"): + claim_canonical_todo_if_promoted(registry_path=args["registry_path"], runtime_root=args["runtime_root"], + goal_id=GOAL, todo_id=TODO, role="agent", claimed_by="agent-a", actor_agent_id="agent-a", + dry_run=False, operation_id="exact-scope-denied", expected_provider_revision=before) + assert snapshot(args)["provider_revision"] == before diff --git a/tests/control_plane/test_room_work.py b/tests/control_plane/test_room_work.py new file mode 100644 index 0000000000..8962a32c56 --- /dev/null +++ b/tests/control_plane/test_room_work.py @@ -0,0 +1,331 @@ +"""Synthetic room transport, real canonical File/SQLite authority and source CLI.""" +import json +import io +import multiprocessing +import os +import subprocess +import sys +import tempfile +from contextlib import redirect_stdout +from functools import partial +from pathlib import Path +from unittest.mock import patch + +import pytest + +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.extensions.lark import goal_channel_work as work +from loopx.extensions.lark.goal_channel_contracts import write_goal_channel_binding + +ROOT = Path(__file__).resolve().parents[2] +GOAL = "room-fixture" +TODO = "todo_room_fixture" + + +class Room: + """A synthetic Lark protocol responder, never an external room.""" + def __init__(self): + self.messages = [] + self.fail_send = False + self.authenticated = True + + def __call__(self, args, cwd, timeout): + payload = {} + if "auth" in args: + payload = {"appId": "cli_room_fixture", "identities": {"bot": { + "available": self.authenticated, "verified": self.authenticated, "appName": "Fixture Bot"}}} + elif "+chat-members-list" in args: + payload = {"data": {"bots": [{"app_id": "cli_room_fixture"}]}} + elif "+messages-send" in args: + card = json.loads(args[args.index("--content") + 1]) + message = {"message_id": f"om_room_{len(self.messages)}", "chat_id": "oc_room_fixture", + "sender": {"sender_type": "app", "id": "cli_room_fixture"}, + "body": {"content": json.dumps(card)}} + self.messages.append(message) + if self.fail_send: + self.fail_send = False + return {"returncode": 1, "stdout": "", "stderr": "", "timed_out": True} + payload = {"data": {"message_id": message["message_id"]}} + elif "+messages-mget" in args: + payload = {"data": {"items": self.messages}} + elif "+chat-messages-list" in args: + payload = {"data": {"items": self.messages, "has_more": False}} + return {"returncode": 0, "stdout": json.dumps(payload), "stderr": ""} + + +@pytest.fixture(params=["file", "sqlite"]) +def setup(tmp_path, monkeypatch, request): + isolate_sqlite_runtime(tmp_path, monkeypatch) + runtime, registry, state = tmp_path / "runtime", tmp_path / ".loopx/registry.json", tmp_path / "state.md" + registry.parent.mkdir() + state.write_text("## Agent Todo\n") + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ + "id": GOAL, "repo": str(tmp_path), "state_file": state.name, + "coordination": {"registered_agents": ["agent-a", "agent-b"]}}]})) + todos = [{"schema_version": "todo_item_v0", "todo_id": TODO, "role": "agent", "status": "open", + "done": False, "text": "PRIVATE_ORGANIZATIONAL_CONTEXT", "archive_state": "active", + "source_section": "Agent Todo", "index": 1, "task_class": "advancement_task"}] + provider = request.param.split(":")[0] + if request.param.endswith(":bound"): + todos[0]["bound_agent"] = "agent-b" + projection = build_todo_runtime_shadow_projection(goal_id=GOAL, todos=todos, handoff_mode="soft_claim") + initialize_canonical_authority(runtime, GOAL, projection, state_path=state, provider=provider) + binding_path = registry.parent / "goal-channel.json" + binding = {"schema_version": "loopx_goal_channel_lark_binding_v0", "bindings": {GOAL: { + "schema_version": "loopx_goal_channel_connection_set_v0", "connections": { + actor: {"goal_id": GOAL, "agent_id": actor, "provider": "lark", "enabled": True, + "channel": {"chat_id": "oc_room_fixture"}, "identity": {"mode": "project_bot", + "sender_identity": "bot", "sender_profile": "room-fixture", + "bot_app_id": "cli_room_fixture", "bot_display_name": "Fixture Bot", "cli_bin": "fixture-cli"}} + for actor in ["agent-a", "agent-b"]}}}} + write_goal_channel_binding(binding_path, binding) + room = Room() + monkeypatch.setattr(work, "resolve_extension_activation", lambda *a, **k: {"enabled": True}) + args = dict(registry_path=registry, runtime_root=runtime, binding_path=binding_path, + target_path=runtime / "targets.json", goal_id=GOAL, runner=room) + yield args, room, binding + # Each fixture owns its disposable runtime; shut it down before its root vanishes. + from loopx.control_plane.effect_runtime import effect_runtime_result + effect_runtime_result("runtime.shutdown", {}, retry_safe=False) + + +def snapshot(args): + return read_canonical_todos_if_promoted(runtime_root=args["runtime_root"], goal_id=GOAL) + + +def claim(args, actor, revision, key, **extra): + return work.run_goal_channel_work(**args, actor_id=actor, command="claim", execute=True, + todo_id=TODO, expected_revision=revision, idempotency_key=key, **extra) + + +def _source_cli_claim(args, actor, revision, key, worker_root, results, barrier=None, messages=()): + """Real CLI client/TS runtime; fixture extension config and synthetic Lark IO.""" + worker_root.mkdir() + for variable in ("TMPDIR", "TEMP", "TMP"): + os.environ[variable] = str(worker_root) + tempfile.tempdir = str(worker_root) + from loopx.cli import main + from loopx.control_plane.effect_runtime import effect_runtime_result + + room = Room() + room.messages = list(messages) + argv = ["--registry", str(args["registry_path"]), "--runtime-root", str(args["runtime_root"]), + "--format", "json", "goal-channel", "work", "claim", "--goal-id", GOAL, + "--agent-id", actor, "--todo-id", TODO, "--expected-revision", revision, + "--idempotency-key", key, "--execute"] + try: + if barrier is not None: + barrier.wait(timeout=30) + output = io.StringIO() + # The parent fixture supplies an enabled extension. Spawned clients + # independently reproduce that fixture fact and inject only room IO. + with patch.object(work, "resolve_extension_activation", return_value={"enabled": True}), \ + patch.object(work, "run_goal_channel_work", partial(work.run_goal_channel_work, runner=room)), \ + redirect_stdout(output): + exit_code = main(argv) + runtime_info = list(worker_root.glob("loopx-effect-runtime-*/runtime-*.json")) + assert len(runtime_info) == 1 + runtime_pid = json.loads(runtime_info[0].read_text())["pid"] + results.put({"pid": os.getpid(), "runtime_pid": runtime_pid, "exit_code": exit_code, + "packet": json.loads(output.getvalue()), "messages": room.messages}) + finally: + effect_runtime_result("runtime.shutdown", {}, retry_safe=False) + + +def _independent_claims(args, revision, actors, root, messages=()): + context = multiprocessing.get_context("spawn") + results = context.Queue() + barrier = context.Barrier(len(actors)) if len(actors) > 1 else None + clients = [context.Process(target=_source_cli_claim, + args=(args, actor, revision, "room-claim-" + actor, root / actor, results, barrier, messages)) + for actor in actors] + try: + for client in clients: + client.start() + observed = [results.get(timeout=90) for _ in clients] + for client in clients: + client.join(timeout=30) + assert client.exitcode == 0 + return observed + finally: + for client in clients: + if client.is_alive(): + client.terminate() + client.join(timeout=10) + results.close() + results.join_thread() + + +def test_independent_source_cli_claims_retry_and_direct_readback(setup, tmp_path): + args, room, _ = setup + before = snapshot(args) + project = work.run_goal_channel_work(**args, actor_id="agent-a", command="project", execute=False) + assert project["ok"], project + assert project["projection"]["counts"] == {"unclaimed": 1, "user_gates": 0} + assert not room.messages and "PRIVATE_" not in json.dumps(project) + revision = before["provider_revision"] + process_args = {name: value for name, value in args.items() if name != "runner"} + clients = _independent_claims(process_args, revision, ["agent-a", "agent-b"], tmp_path) + assert len({client["pid"] for client in clients} | {os.getpid()}) == 3 + assert len({client["runtime_pid"] for client in clients}) == 2 + results = [client["packet"] for client in clients] + assert sum(r["canonical_claim_accepted"] for r in results) == 1, results + assert sorted(r["status"] for r in results) == ["applied", "conflict"], results + winner = next(r["actor_id"] for r in results if r["canonical_claim_accepted"]) + committed = snapshot(args) + winner_client = next(client for client in clients if client["packet"]["actor_id"] == winner) + retry_root = tmp_path / "retry" + retry_root.mkdir() + retry = _independent_claims(process_args, revision, [winner], retry_root, winner_client["messages"])[0] + assert sorted(client["exit_code"] for client in clients) == [0, 1] + assert retry["exit_code"] == 0 + replay = retry["packet"] + assert replay["status"] == "already_applied" and replay["readback_verified"], replay + assert snapshot(args)["provider_revision"] == committed["provider_revision"] + assert snapshot(args)["todos"][0]["claimed_by"] == winner + assert len(retry["messages"]) == 1 + assert "PRIVATE_" not in json.dumps(clients + [retry]) + readback = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", "todo", "list", "--goal-id", GOAL], cwd=ROOT, text=True, capture_output=True) + assert readback.returncode == 0, readback.stdout + readback.stderr + assert json.loads(readback.stdout)["todos"][0]["claimed_by"] == winner + + +def test_reconnect_after_unknown_delivery_reuses_canonical_receipt(setup): + args, room, _ = setup + revision = snapshot(args)["provider_revision"] + room.fail_send = True + first = claim(args, "agent-a", revision, "room-lost-response") + assert first["canonical_claim_accepted"] and not first["ok"], first + assert first["external_write_performed"] and first["blocker"] == "delivery_outcome_unknown" + committed = snapshot(args)["provider_revision"] + recovered = claim(args, "agent-a", revision, "room-lost-response") + assert recovered["ok"] and recovered["status"] == "already_applied", recovered + assert len(room.messages) == 1 + assert snapshot(args)["provider_revision"] == committed + + +def test_revocation_and_auth_failure_cannot_create_or_replay_claim(setup): + args, room, binding = setup + before = snapshot(args)["provider_revision"] + room.authenticated = False + denied = claim(args, "agent-a", before, "room-auth-denied") + assert not denied["canonical_claim_accepted"] and not room.messages + assert snapshot(args)["provider_revision"] == before + room.authenticated = True + assert claim(args, "agent-a", before, "room-revocation")["canonical_claim_accepted"] + committed = snapshot(args)["provider_revision"] + binding["bindings"][GOAL]["connections"]["agent-a"]["enabled"] = False + write_goal_channel_binding(args["binding_path"], binding) + replay = claim(args, "agent-a", before, "room-revocation") + assert not replay["canonical_claim_accepted"] and replay["failure_stage"] == "scope", replay + assert snapshot(args)["provider_revision"] == committed + + +def test_real_room_cli_preview_and_stale_revision(setup): + args, room, _ = setup + proc = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", "goal-channel", "work", "project", "--goal-id", GOAL, + "--agent-id", "agent-a"], cwd=ROOT, text=True, capture_output=True) + assert proc.returncode == 0, proc.stdout + proc.stderr + packet = json.loads(proc.stdout) + assert packet["projection"]["selected_todo"]["todo_id"] == TODO + assert packet["external_write_performed"] is False and "PRIVATE_" not in proc.stdout + revision = snapshot(args)["provider_revision"] + rejected = claim(args, "agent-a", "stale-revision", "room-stale") + assert rejected["status"] == "conflict", rejected + assert snapshot(args)["provider_revision"] == revision and not room.messages + + +def test_provider_loss_does_not_fall_back_or_disclose(setup, monkeypatch): + args, room, _ = setup + def unavailable(**kwargs): + raise RuntimeError("PRIVATE_CREDENTIAL_AND_BACKEND_PATH") + monkeypatch.setattr(work, "read_canonical_todos_if_promoted", unavailable) + packet = work.run_goal_channel_work(**args, actor_id="agent-a", command="project", execute=True) + assert not packet["ok"] and packet["failure_stage"] == "projection" + assert "PRIVATE_" not in json.dumps(packet) and not room.messages + + +@pytest.mark.parametrize("setup", ["file:bound", "sqlite:bound"], indirect=True) +def test_bound_agent_guard_applies_to_room_and_existing_direct_claim(setup): + args, room, _ = setup + before = snapshot(args)["provider_revision"] + project = work.run_goal_channel_work(**args, actor_id="agent-a", command="project", execute=False) + assert project["projection"]["counts"]["unclaimed"] == 0 + denied = claim(args, "agent-a", before, "room-bound-denied") + assert denied["status"] == "rejected" and not denied["canonical_claim_accepted"], denied + direct = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", "todo", "claim", "--goal-id", GOAL, "--todo-id", TODO, + "--agent-id", "agent-a", "--claimed-by", "agent-a"], cwd=ROOT, text=True, capture_output=True) + assert direct.returncode == 1 + assert json.loads(direct.stdout)["error_code"] == "bound_agent_mismatch" + assert snapshot(args)["provider_revision"] == before and not room.messages + + +def test_changed_key_intent_and_revoked_identity_cannot_reapply(setup): + args, room, _ = setup + before = snapshot(args)["provider_revision"] + assert claim(args, "agent-a", before, "room-exact-intent")["canonical_claim_accepted"] + committed = snapshot(args)["provider_revision"] + changed = claim(args, "agent-a", committed, "room-exact-intent") + assert not changed["canonical_claim_accepted"] + registry = json.loads(args["registry_path"].read_text()) + registry["goals"][0]["coordination"]["registered_agents"] = ["agent-b"] + args["registry_path"].write_text(json.dumps(registry)) + revoked = claim(args, "agent-a", before, "room-exact-intent") + assert not revoked["canonical_claim_accepted"] and revoked["failure_stage"] == "scope" + assert snapshot(args)["provider_revision"] == committed and len(room.messages) == 1 + + +def test_scope_revoked_during_provider_preflight_is_rechecked_before_claim(setup): + args, room, _ = setup + before = snapshot(args)["provider_revision"] + revoked = False + def revoke_then_respond(argv, cwd, timeout): + nonlocal revoked + if "auth" in argv and not revoked: + registry = json.loads(args["registry_path"].read_text()) + registry["goals"][0]["coordination"]["registered_agents"] = ["agent-b"] + args["registry_path"].write_text(json.dumps(registry)) + revoked = True + return room(argv, cwd, timeout) + changed_args = {**args, "runner": revoke_then_respond} + denied = claim(changed_args, "agent-a", before, "room-preflight-revoked") + assert revoked and not denied["canonical_claim_accepted"], denied + assert snapshot(args)["provider_revision"] == before and not room.messages + + +@pytest.mark.parametrize("has_receipt", [False, True]) +@pytest.mark.parametrize("goal_ref", [None, "invalid", {"goal_id": GOAL, "goal_instance_id": "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]) +@pytest.mark.parametrize("wire_version", [0, 1, 2]) +def test_unqualified_claim_wires_cannot_accept_goal_ref(setup, has_receipt, goal_ref, wire_version): + import hashlib + from loopx.control_plane.effect_runtime import effect_runtime_result + args, room, _ = setup + original_revision = snapshot(args)["provider_revision"] + key = "room-unqualified-goal-ref" + if has_receipt: + assert claim(args, "agent-a", original_revision, key)["canonical_claim_accepted"] + before = snapshot(args)["provider_revision"] + packet = {"schema_version": "loopx_local_coordination_todo_claim_request_v1", + "runtime_root": str(args["runtime_root"]), "goal_id": GOAL, "todo_id": TODO, + "role": "agent", "claimed_by": "agent-a", "actor_agent_id": "agent-a", + "registered_agents": ["agent-a", "agent-b"], + "registry_source": {"path": str(args["registry_path"]), + "sha256": hashlib.sha256(args["registry_path"].read_bytes()).hexdigest()}, + "operation_id": key, "expected_provider_revision": original_revision, + "lease_request": None, "observed_at": "2026-09-29T00:00:00Z", "dry_run": False, + "goal_ref": goal_ref} + if wire_version == 0: + packet["schema_version"] = "loopx_local_coordination_todo_claim_request_v0" + packet.pop("registry_source") + elif wire_version == 2: + packet["schema_version"] = "loopx_local_coordination_todo_claim_request_v2" + packet["registry_sources"] = [packet["registry_source"]] + result = effect_runtime_result("coordination.local_authority.todo_claim", packet) + assert result["status"] == "failed" and result["reason_code"] == "goal_ref_claim_contract_unqualified", result + assert result["decision_read_from_provider"] is False + assert snapshot(args)["provider_revision"] == before diff --git a/tests/control_plane/test_room_work_api.py b/tests/control_plane/test_room_work_api.py new file mode 100644 index 0000000000..3682283e7d --- /dev/null +++ b/tests/control_plane/test_room_work_api.py @@ -0,0 +1,149 @@ +"""Real loopback HTTP and canonical stores; only provider transport is synthetic.""" +import http.client +import json +import threading + +import pytest + +from test_room_work import setup as setup, snapshot, claim +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.extensions.lark.goal_channel_contracts import write_goal_channel_binding + + +@pytest.fixture +def api(setup): + args, room, binding = setup + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path = args["registry_path"] + server.runtime_root = args["runtime_root"] + server.runtime_root_override = str(args["runtime_root"]) + server.lark_runner = room + server.verbose = False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def request(body=None, *, query="goal_id=room-fixture&connection_id=agent-a", origin=None): + conn = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=30) + headers = {"Content-Type": "application/json"} + if origin is not None: + headers["Origin"] = origin + try: + conn.request("POST" if body is not None else "GET", "/api/chat/lark/room-work" + ("" if body is not None else "?" + query), + body=None if body is None else json.dumps(body), headers=headers) + response = conn.getresponse() + return response.status, json.loads(response.read()) + finally: + conn.close() + try: + yield request, args, room, binding + finally: + server.shutdown() + thread.join() + server.server_close() + + +def intent(readback): + return {"goal_id": "room-fixture", "connection_id": "agent-a", "scope_revision": readback["scope_revision"], + "todo_id": readback["projection"]["selected_todo"]["todo_id"], + "expected_revision": readback["projection"]["source_revision"], "idempotency_key": "http-room-claim"} + + +def test_http_claim_retry_and_independent_current_owner(api): + request, args, room, _ = api + status, preview = request() + assert status == 200 and preview["ok"] and not room.messages + assert "PRIVATE_" not in json.dumps(preview) + body = intent(preview) + status, accepted = request(body) + assert status == 200 and accepted["canonical_claim_accepted"] and accepted["readback_verified"] + assert accepted["execution_authority_granted"] is False + assert accepted["claim_available"] is True + assert "inspected_todo" not in accepted["projection"], "the established claim projection remains unchanged" + revision = snapshot(args)["provider_revision"] + _, retry = request(body) + assert retry["status"] == "already_applied" and len(room.messages) == 1 + assert snapshot(args)["provider_revision"] == revision + _, current = request(query="goal_id=room-fixture&connection_id=agent-a&todo_id=" + body["todo_id"]) + assert current["projection"]["inspected_todo"]["current_owner"] == "agent-a" + assert current["projection"]["counts"]["unclaimed"] == 0 + + +def test_http_unknown_delivery_keeps_exact_request_recoverable(api): + request, args, room, _ = api + _, preview = request() + body = intent(preview) + room.fail_send = True + _, first = request(body) + assert first["canonical_claim_accepted"] and not first["ok"] + revision = snapshot(args)["provider_revision"] + _, replay = request(body) + assert replay["ok"] and replay["status"] == "already_applied" and len(room.messages) == 1 + assert snapshot(args)["provider_revision"] == revision + + +def test_http_stale_revision_and_changed_scope_cannot_claim(api): + request, args, room, binding = api + _, preview = request() + body = intent(preview) + assert claim(args, "agent-b", body["expected_revision"], "peer-claim")["ok"] + revision = snapshot(args)["provider_revision"] + _, stale = request(body) + assert stale["status"] == "conflict" and not stale["canonical_claim_accepted"] + assert snapshot(args)["provider_revision"] == revision + binding["bindings"]["room-fixture"]["connections"]["agent-a"]["channel"]["chat_id"] = "oc_another_fixture" + write_goal_channel_binding(args["binding_path"], binding) + assert request(body)[0] == 409 + assert snapshot(args)["provider_revision"] == revision and len(room.messages) == 1 + + +def test_http_client_cannot_supply_actor_or_foreign_connection(api): + request, args, room, _ = api + _, preview = request() + body = intent(preview) + before = snapshot(args)["provider_revision"] + assert request({**body, "actor_id": "agent-b"})[0] == 400 + assert request(query="goal_id=room-fixture&connection_id=missing")[0] == 400 + assert request(body, origin="https://foreign.example")[0] == 403 + room.authenticated = False + _, denied = request(body) + assert not denied["canonical_claim_accepted"] and not room.messages + assert snapshot(args)["provider_revision"] == before + + +def test_inactive_extension_preserves_read_only_view(api, monkeypatch): + from loopx.extensions.lark import goal_channel_work + request, args, room, _ = api + def disabled(*args, **kwargs): + raise ValueError("fixture extension is disabled") + monkeypatch.setattr(goal_channel_work, "resolve_extension_activation", disabled) + before = snapshot(args)["provider_revision"] + status, preview = request() + assert status == 200 and preview["ok"] and preview["claim_available"] is False + _, attempted = request(intent(preview)) + assert not attempted["canonical_claim_accepted"] and not room.messages + assert snapshot(args)["provider_revision"] == before + + +def test_binding_change_after_http_scope_check_is_rechecked_by_work_owner(api, monkeypatch): + from loopx.extensions.lark import goal_channel_work + request, args, room, binding = api + _, preview = request() + body = intent(preview) + before = snapshot(args)["provider_revision"] + def retarget(*a, **k): + binding["bindings"]["room-fixture"]["connections"]["agent-a"]["channel"]["chat_id"] = "oc_retargeted_fixture" + write_goal_channel_binding(args["binding_path"], binding) + return {"enabled": True} + monkeypatch.setattr(goal_channel_work, "resolve_extension_activation", retarget) + _, result = request(body) + assert not result["canonical_claim_accepted"] and not room.messages + assert snapshot(args)["provider_revision"] == before + + +def test_manager_connection_cannot_be_used_as_a_worker_route(api): + request, args, room, binding = api + before = snapshot(args)["provider_revision"] + binding["bindings"]["room-fixture"]["connections"]["agent-a"]["routing"] = {"conversation_kind": "manager"} + write_goal_channel_binding(args["binding_path"], binding) + assert request()[0] == 400 + assert snapshot(args)["provider_revision"] == before and not room.messages diff --git a/tests/control_plane_ts/authority_source.test.ts b/tests/control_plane_ts/authority_source.test.ts index 1a19f6f908..97af26d41c 100644 --- a/tests/control_plane_ts/authority_source.test.ts +++ b/tests/control_plane_ts/authority_source.test.ts @@ -1,14 +1,25 @@ import assert from "node:assert/strict"; import {createHash} from "node:crypto"; -import {mkdtemp, rm, writeFile} from "node:fs/promises"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {mkdtemp, readFile, rm, writeFile} from "node:fs/promises"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test from "node:test"; import {registryAuthoritySourceCheck} from "../../loopx/control_plane/coordination/authority_source.ts"; +import {withRegistryAuthoritySources} from "../../loopx/control_plane/coordination/shadow_registry_source.ts"; +import {mutationLockOwner, withFileMutationLock} from "../../loopx/control_plane/effect_runtime_io.ts"; +import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {coordinationTodoReadModel, TODO_CANONICAL_READ_RECORD_SCHEMA} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import {TODO_ITEM_SCHEMA} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; +import type {AuthorityStore, AuthorityStoreCommit} from "../../loopx/control_plane/coordination/authority_store.ts"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; import { claimLocalCoordinationTodo, createLocalCoordinationTodo, updateLocalCoordinationTodo, terminalLifecycleLocalCoordinationTodo, pollLocalCoordinationMonitor, LOCAL_COORDINATION_TODO_TERMINAL_LIFECYCLE_REQUEST_SCHEMA, + LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA, } from "../../loopx/control_plane/coordination/local_authority_runtime.ts"; test("registry witness captures primitive values across awaited request mutation", async t => { @@ -28,6 +39,132 @@ test("registry witness captures primitive values across awaited request mutation assert.equal(await check(), false); }); +test("claim source guards reject missing, duplicate, omitted primary and legacy wire intent before provider IO", async t => { + const root = await mkdtemp(join(tmpdir(), "loopx-claim-wire-")); + t.after(() => rm(root, {recursive: true, force: true})); + const path = join(root, "registry.json"), body = "{}"; + await writeFile(path, body); + const primary = {path, sha256: createHash("sha256").update(body).digest("hex")}; + let opens = 0; + const dependencies = {createStore: (): AuthorityStore => {opens++; throw new Error("unexpected provider IO");}}; + for (const registry_sources of [undefined, null, [], [null], [primary, primary], + [{...primary, path: join(root, "broker.json")}], [{...primary, path: "relative"}], + [{...primary, sha256: "invalid"}], [primary, primary, primary]]) { + const result = await claimLocalCoordinationTodo({ + schema_version: LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA, + registry_source: primary, registry_sources, + }, dependencies); + assert.equal(result.status, "failed"); + assert.equal(result.decision_read_from_provider, false); + assert.equal(result.reason_code, "invalid_local_coordination_todo_claim_request"); + } + for (const version of [0, 1]) for (const registry_sources of [null, [primary]]) { + const result = await claimLocalCoordinationTodo({ + schema_version: `loopx_local_coordination_todo_claim_request_v${version}`, registry_sources, + }, dependencies); + assert.equal(result.failure_kind, "protocol_failure"); + assert.equal(result.decision_read_from_provider, false); + } + assert.equal(opens, 0); +}); + +test("source guards copy original witnesses, fail closed on drift or contention and release partial guards", async t => { + const root = await mkdtemp(join(tmpdir(), "loopx-source-guards-")); + t.after(() => rm(root, {recursive: true, force: true})); + const a = {path: join(root, "a.json"), sha256: createHash("sha256").update("{}").digest("hex")}; + const b = {...a, path: join(root, "b.json")}; + await writeFile(a.path, "{}"); await writeFile(b.path, "{}"); + await withFileMutationLock(b.path, async () => { + await assert.rejects(withRegistryAuthoritySources([a, b], a, async () => assert.fail("busy source reached effect")), + {reason_code: "source_registry_busy_retry"}); + assert.equal(await mutationLockOwner(a.path), null, "earlier acquired guard is released on contention"); + }); + const sources = [a, b]; + const result = withRegistryAuthoritySources(sources, a, async () => { + assert.equal((await mutationLockOwner(b.path))?.pid, process.pid); + return "held original"; + }); + sources.length = 0; a.path = join(root, "redirect.json"); b.sha256 = "0".repeat(64); + assert.equal(await result, "held original"); + const original = {...b, sha256: createHash("sha256").update("{}").digest("hex")}; + await writeFile(original.path, "changed"); + await assert.rejects(withRegistryAuthoritySources([original], original, async () => assert.fail("stale source reached effect")), + {reason_code: "authority_source_changed"}); + await rm(original.path); + await assert.rejects(withRegistryAuthoritySources([original], original, async () => assert.fail("missing source reached effect")), + {reason_code: "authority_source_changed"}); +}); + +for (const provider of ["file", "sqlite"] as const) test(`${provider}: native claim retains broker/source guards through real commit and Python writer`, {timeout: 20_000}, async t => { + const root = await mkdtemp(join(tmpdir(), "loopx-claim-serialization-")); + t.after(() => rm(root, {recursive: true, force: true})); + const sources = await Promise.all(["broker.json", "source.json"].map(async name => { + const path = join(root, name), body = JSON.stringify({goals: [], revision: 1}); + await writeFile(path, body); + return {path, sha256: createHash("sha256").update(body).digest("hex")}; + })); + const store: AuthorityStore = provider === "file" ? new FileAuthorityStore(join(root, "authority", "file-v0"), "goal-a") + : new SqliteAuthorityStore(join(root, "authority", "sqlite-v0"), "goal-a"); + const todos = [{schema_version: TODO_ITEM_SCHEMA, todo_id: "todo_a", role: "agent", status: "open", + done: false, text: "Claim the registered work", archive_state: "active", source_section: "Agent Todo", index: 1}]; + assert.equal((await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + events: [], receipts: [], next_projection: {goal_id: "goal-a", handoff_mode: "soft_claim", todos, leases: [], + todo_read_model: coordinationTodoReadModel(todos, TODO_CANONICAL_READ_RECORD_SCHEMA)}})).status, "applied"); + const request = {schema_version: LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA, + registry_source: sources[1], registry_sources: sources, runtime_root: root, goal_id: "goal-a", todo_id: "todo_a", + role: "agent", claimed_by: "agent-a", actor_agent_id: "agent-a", registered_agents: ["agent-a"], + operation_id: "claim-a", observed_at: "2026-09-05T04:30:00Z", dry_run: false}; + let entered!: () => void, release!: () => void; + const atCommit = new Promise(resolve => {entered = resolve;}); + const resume = new Promise(resolve => {release = resolve;}); + t.after(release); + const guarded = new Proxy(store, {get(target, key) { + if (key === "commitAuthority") return async (commit: AuthorityStoreCommit) => { + entered(); await resume; return target.commitAuthority(commit); + }; + const value = Reflect.get(target, key); + return typeof value === "function" ? value.bind(target) : value; + }}); + const claim = claimLocalCoordinationTodo(request, {createStore: () => guarded}); + await Promise.race([atCommit, claim.then(result => assert.fail(JSON.stringify(result)))]); + for (const source of sources) assert.equal((await mutationLockOwner(source.path))?.pid, process.pid); + // Use the real administration writer, not an uncoordinated writeFile probe. + const writer = spawn(resolveTestPython(), ["-u", "-c", ` +import sys +from pathlib import Path +from loopx.control_plane.projects.registry_codec import project_registry_transaction +print("attempting", flush=True) +for path in sys.argv[1:]: + with project_registry_transaction(Path(path), operation="source-serialization-test") as tx: + payload = tx.payload_copy() + payload["revision"] = 2 + tx.commit(payload) +print("committed", flush=True) +`, ...sources.map(source => source.path)], {stdio: ["ignore", "pipe", "pipe"]}); + t.after(() => {writer.kill();}); + let output = "", error = ""; + writer.stdout.on("data", chunk => {output += String(chunk);}); + writer.stderr.on("data", chunk => {error += String(chunk);}); + const exit = once(writer, "exit"); + await once(writer.stdout, "data"); + assert.match(output, /attempting/u); + assert.equal(await Promise.race([exit.then(() => true), new Promise(resolve => setTimeout(() => resolve(false), 150))]), false); + for (const source of sources) assert.equal(JSON.parse(await readFile(source.path, "utf8")).revision, 1); + assert.deepEqual(await store.readReceipt("claim-a"), {status: "missing"}); + release(); + assert.equal((await claim).status, "applied"); + assert.equal((await store.readReceipt("claim-a")).status, "found"); + assert.equal((await exit)[0], 0, error); + assert.match(output, /committed/u); + for (const source of sources) { + assert.equal(JSON.parse(await readFile(source.path, "utf8")).revision, 2); + assert.equal(await mutationLockOwner(source.path), null); + } + const before = await store.loadAuthority(); + assert.equal((await claimLocalCoordinationTodo(request, {createStore: () => store})).reason_code, "authority_source_changed"); + assert.deepEqual(await store.loadAuthority(), before, "a stale replay cannot cross the retained source contract"); +}); + const wires = [ {invoke: createLocalCoordinationTodo, prefix: "loopx_local_coordination_todo_create_request", legacy: [0], current: 1}, {invoke: claimLocalCoordinationTodo, prefix: "loopx_local_coordination_todo_claim_request", legacy: [0], current: 1}, diff --git a/tests/control_plane_ts/goal_ref_claim_compatibility.test.ts b/tests/control_plane_ts/goal_ref_claim_compatibility.test.ts new file mode 100644 index 0000000000..baf12f733c --- /dev/null +++ b/tests/control_plane_ts/goal_ref_claim_compatibility.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type {AuthorityStore} from "../../loopx/control_plane/coordination/authority_store.ts"; +import {executeCoordinationTodoClaim} from "../../loopx/control_plane/coordination/todo_claim.ts"; + +test("GoalRef-bearing legacy claims reject before provider or historical receipt access", async () => { + const store = new Proxy({} as AuthorityStore, {get() {throw new Error("provider must not be accessed");}}); + const base = {goal_id: "goal", todo_id: "todo", claimed_by: "agent", actor_agent_id: "agent", + registered_agents: ["agent"], expected_role: "agent", operation_id: "old-operation", dry_run: false, + now: new Date("2026-09-29T00:00:00Z")}; + for (const goalRef of [null, "invalid", {goal_id: "goal", goal_instance_id: "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]) { + const request = {...base, goal_ref: goalRef}; + const result = await executeCoordinationTodoClaim(store, request); + assert.equal(result.status, "failed"); + assert.equal(result.reason_code, "goal_ref_claim_contract_unqualified"); + assert.equal(result.failure_kind, "protocol_failure"); + } +}); diff --git a/tests/control_plane_ts/room_claim_request.test.ts b/tests/control_plane_ts/room_claim_request.test.ts new file mode 100644 index 0000000000..acdf953437 --- /dev/null +++ b/tests/control_plane_ts/room_claim_request.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {decodeRoomClaimRequest, admitRoomClaimCallback} from "../../loopx/control_plane/goals/room_claim_request.ts"; + +const request = () => ({schema_version: "loopx_room_claim_request_v0", command: "claim_todo", + goal_id: "goal-fixture", actor_id: "agent-a", todo_id: "todo_fixture", expected_revision: "fixture:1", + idempotency_key: "claim-fixture", authorized_principals: ["lark:ou_fixture"], expires_at: "2030-01-01T00:00:00Z"}); +const admission = (patch = {}) => ({request: request(), scope_state: "active", principal: "lark:ou_fixture", + observed_at: "2026-01-01T00:00:00Z", ...patch}); + +test("claim scope never comes from room membership or recalled fields", () => { + assert.equal(admitRoomClaimCallback(admission()).allowed, true); + assert.equal(admitRoomClaimCallback(admission()).execution_authority_granted, false); + assert.equal(admitRoomClaimCallback(admission({request: {...request(), authorized_principals: ["im:operator-a"]}, principal: "im:operator-a"})).allowed, true); + assert.equal(admitRoomClaimCallback(admission({principal: "lark:ou_other"})).reason_code, "principal_not_authorized"); + assert.equal(admitRoomClaimCallback(admission({scope_state: "revoked"})).reason_code, "offer_revoked"); + assert.equal(admitRoomClaimCallback(admission({observed_at: "2030-01-01T00:00:00Z"})).reason_code, "offer_expired"); +}); + +test("input boundary rejects broad commands, excess principals and ambiguous expiry", () => { + for (const patch of [{command: "execute"}, {authorized_principals: []}, {authorized_principals: ["*"]}, + {authorized_principals: ["lark:ou_fixture", "lark:ou_fixture"]}, + {expires_at: "2030-01-01"}, {expected_revision: ""}, {recalled_approval: true}]) { + assert.throws(() => decodeRoomClaimRequest({...request(), ...patch})); + } + assert.throws(() => admitRoomClaimCallback(admission({scope_state: "implicitly_approved"}))); +}); diff --git a/tests/control_plane_ts/room_resume.test.ts b/tests/control_plane_ts/room_resume.test.ts new file mode 100644 index 0000000000..35f043f5ba --- /dev/null +++ b/tests/control_plane_ts/room_resume.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {createHash} from "node:crypto"; +import {validateRoomResumeInput, projectRoomResumeReadback} from "../../loopx/control_plane/goals/room_resume.ts"; + +const quota = {ok: true, status_health_ok: true, goal_id: "goal", mode: "should-run", + agent_identity: {agent_id: "actor"}, heartbeat_receipt: {status: "committed", turn_instance_id: "turn"}, + selected_todo: {todo_id: "todo", claimed_by: "actor", status: "open"}, should_run: true, + interaction_contract: {mode: "run", agent_channel: {must_attempt: true, delivery_allowed: true}}}; +const projection = {goal_id: "goal", actor_id: "actor", authority_state: "available", source_revision: "revision"}; +const ref = "viking://user/example/peers/actor/memories/artifact.json"; +const readback = {goal_id: "goal", actor_id: "actor", before_quota: quota, after_quota: quota, + before_projection: projection, after_projection: projection, before_scope: "scope", after_scope: "scope", + artifact_scope_refs: ["viking://user/example/peers/actor/memories"], + recall: {ok: true, status: "applied", context: {guidance: ["remembered approval"]}, application: {receipt: { + outcome: "applied", current_artifact_verified: true, result_readback_verified: true, + memory_ref_digests: [createHash("sha256").update(ref).digest("hex").slice(0, 16)]}}}, + artifact_refs: [ref, "viking://user/other/private.json"]}; + +test("exact Turn/actor identity and current quota are required before retrieval", () => { + const input = {goal_id: "goal", actor_id: "actor", turn_instance_id: "turn", quota, current_quota: quota, artifact_refs: []}; + assert.equal(validateRoomResumeInput(input).matches, true); + assert.throws(() => validateRoomResumeInput({...input, actor_id: "other"})); + assert.throws(() => validateRoomResumeInput({...input, turn_instance_id: "old"})); + assert.throws(() => validateRoomResumeInput({...input, artifact_refs: ["viking://user/example/../other"]})); + assert.equal(validateRoomResumeInput({...input, current_quota: {...quota, should_run: false}}).matches, false); +}); + +test("only explicitly requested references from current accepted recall may be carried", () => { + const result = projectRoomResumeReadback(readback); + assert.deepEqual(result.artifact_references, [{ref, source: "current_scoped_recall", target_access_granted: false}]); + assert.equal(result.omitted_reference_count, 1); + assert.equal(result.execution_authority_granted, false); + assert.deepEqual(projectRoomResumeReadback({...readback, artifact_scope_refs: ["viking://user/other"]}).artifact_references, []); +}); + +test("reconnect discards context when canonical revision, gate, ownership or scope changes", () => { + for (const delta of [{after_projection: {...projection, source_revision: "new"}}, {after_scope: "revoked"}, + {after_quota: {...quota, should_run: false}}]) { + const result = projectRoomResumeReadback({...readback, ...delta}); + assert.equal(result.context_usable, false); + assert.deepEqual(result.artifact_references, []); + } + assert.throws(() => projectRoomResumeReadback({...readback, after_quota: {...quota, + selected_todo: {todo_id: "todo", claimed_by: "other"}}})); + assert.equal(projectRoomResumeReadback({...readback, recall: {ok: true, status: "provider_unavailable", + context: {guidance: []}}}).context_usable, false); +}); + +test("healthy no-work/gated readback is fresh state, while missing scope proof fails closed", () => { + const empty = projectRoomResumeReadback({...readback, after_quota: {...quota, selected_todo: null, should_run: false}}); + assert.equal(empty.authority_observation_stable, false); + assert.equal(empty.context_usable, false); + assert.deepEqual(empty.artifact_references, []); + for (const delta of [{before_scope: null}, {after_scope: null}, + {after_projection: {...projection, source_revision: null}}]) { + assert.throws(() => projectRoomResumeReadback({...readback, ...delta})); + } + const unverified = projectRoomResumeReadback({...readback, recall: {...readback.recall, + application: {receipt: {...readback.recall.application.receipt, result_readback_verified: false}}}}); + assert.equal(unverified.context_usable, false); + assert.deepEqual(unverified.artifact_references, []); +}); diff --git a/tests/control_plane_ts/room_work_projection.test.ts b/tests/control_plane_ts/room_work_projection.test.ts new file mode 100644 index 0000000000..78148b3e8e --- /dev/null +++ b/tests/control_plane_ts/room_work_projection.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {projectRoomWork} from "../../loopx/control_plane/goals/room_work_projection.ts"; + +const todo = (todo_id: string, patch = {}) => ({todo_id, role: "agent", status: "open", + archive_state: "active", task_class: "advancement_task", text: "PRIVATE_TASK_TEXT", ...patch}); +const packet = (todos: unknown[], patch = {}) => ({goal_id: "room-goal", actor_id: "agent-a", + registered_agents: ["agent-a", "agent-b"], observed_at: "2026-01-01T00:00:00Z", + snapshot: {status: "loaded", provider_revision: "fixture:1", decision_read_from_provider: true, + legacy_fallback_used: false, todos, ...patch}}); + +test("room orientation reuses claim admission and exposes no task content", () => { + const result = projectRoomWork(packet([ + todo("todo_done", {status: "done"}), todo("todo_bound", {bound_agent: "agent-b"}), + todo("todo_excluded", {excluded_agents: ["agent-a"]}), todo("todo_claimed", {claimed_by: "agent-b"}), + todo("todo_monitor", {task_class: "continuous_monitor"}), todo("todo_guarded"), todo("todo_available"), + todo("todo_gate", {role: "user", task_class: "user_gate", blocks_agent: "agent-a"}), + todo("todo_foreign_gate", {role: "user", task_class: "user_gate", blocks_agent: "agent-b"}), + ], {goal_acceptance_work_guards: {todo_guarded: {allowed: false}}})); + assert.deepEqual(result.counts, {unclaimed: 1, user_gates: 1}); + assert.deepEqual(result.selected_todo, {todo_id: "todo_available", claimed_by: null, actionability: "unclaimed"}); + assert.equal(JSON.stringify(result).includes("PRIVATE_"), false); + assert.deepEqual(result.truth_contract, {projection_grants_authority: false, + memory_grants_authority: false, private_task_content_included: false}); +}); + +test("room orientation refuses revoked identity, partial snapshot and invalid freshness", () => { + for (const bad of [{...packet([]), registered_agents: []}, packet([], {status: "failed"}), + packet([], {legacy_fallback_used: true}), {...packet([]), observed_at: "not-a-date"}]) { + assert.throws(() => projectRoomWork(bad)); + } +}); + +test("optional ownership readback is fresh, minimal and actor scoped", () => { + const input = packet([todo("todo_owned", {claimed_by: "agent-b"}), todo("todo_hidden", {bound_agent: "agent-b"})]); + const plain = projectRoomWork(input); + assert.equal(Object.hasOwn(plain, "inspected_todo"), false, "existing callers retain their result shape"); + const observed = projectRoomWork({...input, inspected_todo_id: "todo_owned"}); + assert.deepEqual(observed.inspected_todo, {todo_id: "todo_owned", current_owner: "agent-b", current_claim_matches_actor: false}); + assert.equal(JSON.stringify(observed).includes("PRIVATE_"), false); + assert.equal(projectRoomWork({...input, inspected_todo_id: "todo_hidden"}).inspected_todo, null); + const legacy = projectRoomWork({...packet([todo("todo_empty", {bound_agent: "", claimed_by: ""})]), inspected_todo_id: "todo_empty"}); + assert.deepEqual(legacy.inspected_todo, {todo_id: "todo_empty", current_owner: null, current_claim_matches_actor: false}); +}); diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index a009df79e7..6247c8090d 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -13,6 +13,11 @@ "types": ["node"] }, "include": [ + "tests/control_plane_ts/authority_source.test.ts", + "tests/control_plane_ts/goal_ref_claim_compatibility.test.ts", + "tests/control_plane_ts/room_resume.test.ts", + "tests/control_plane_ts/room_claim_request.test.ts", + "tests/control_plane_ts/room_work_projection.test.ts", "loopx/control_plane/**/*.ts", "loopx/zcode_goal_mode/**/*.ts", "tests/control_plane_ts/explore_research.test.ts",