From bdf603e6f1e8dbd81ca08644186b1fe3ddbc6bec Mon Sep 17 00:00:00 2001 From: song Date: Tue, 13 Oct 2026 12:23:51 +0800 Subject: [PATCH 01/11] feat(collaboration): reconcile request revisions with governed Goal effects Signed-off-by: song --- .../dashboard/src/data/chat-model.ts | 5 +- apps/presentation/dashboard/src/data/chat.ts | 1 + .../dashboard/src/data/request-control.ts | 47 +++ .../personal-workspace/collaboration-card.css | 13 + .../personal-workspace/collaboration-card.tsx | 37 +- .../personal-workspace-model.ts | 1 + .../personal-workspace/request-control.tsx | 155 +++++++ .../return-delivery-status.tsx | 3 +- .../manager_context/presentation.py | 47 ++- .../capabilities/manager_context/roundtrip.py | 27 +- .../capabilities/manager_context/tracking.py | 22 + loopx/cli_commands/manager_inbox.py | 46 ++- loopx/collaboration_mcp.py | 26 +- loopx/control_plane/collaboration/inbox.py | 97 ++++- .../collaboration/inbox_receipts.ts | 59 +-- .../collaboration/peer_context_observation.py | 7 +- loopx/control_plane/collaboration/peers.py | 11 +- .../collaboration/request_amendment.py | 182 ++++++++ .../collaboration/request_control.ts | 388 ++++++++++++++++++ .../collaboration/request_control_contract.ts | 9 + .../collaboration/request_control_source.py | 142 +++++++ .../collaboration/result_publication.ts | 55 ++- .../collaboration/semantic_request.ts | 25 ++ .../collaboration/source_chat_observation.py | 16 + .../control_plane/effect_runtime_handlers.ts | 7 +- .../goals/goal_amendment_authority.ts | 216 ++++++++-- .../goals/goal_amendment_runtime.py | 43 +- loopx/presentation/configuration_api.py | 4 + loopx/presentation/goal_amendment_api.py | 14 +- loopx/presentation/goal_source_routing.py | 16 + loopx/presentation/request_control_api.py | 104 +++++ .../project_registry_io_manifest_v1.json | 42 +- tsconfig.control-plane.json | 1 + 33 files changed, 1705 insertions(+), 163 deletions(-) create mode 100644 apps/presentation/dashboard/src/data/request-control.ts create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/request-control.tsx create mode 100644 loopx/control_plane/collaboration/request_amendment.py create mode 100644 loopx/control_plane/collaboration/request_control.ts create mode 100644 loopx/control_plane/collaboration/request_control_contract.ts create mode 100644 loopx/control_plane/collaboration/request_control_source.py create mode 100644 loopx/presentation/goal_source_routing.py create mode 100644 loopx/presentation/request_control_api.py diff --git a/apps/presentation/dashboard/src/data/chat-model.ts b/apps/presentation/dashboard/src/data/chat-model.ts index 66bb18b38e..470185c6fe 100644 --- a/apps/presentation/dashboard/src/data/chat-model.ts +++ b/apps/presentation/dashboard/src/data/chat-model.ts @@ -88,6 +88,9 @@ export type CollaborationReadback = { agent_id: string; goal_id?: string; decision_reason?: string; + request_revision?: number; + control_route?: import("./request-control").RequestControlRoute; + request_control?: import("./request-control").RequestControlView | null; brief: { purpose: string; context: string; @@ -98,7 +101,7 @@ export type CollaborationReadback = { }; read_status: string; decision: string; - returns: { phase: string; status: string }[]; + returns: { phase: string; status: string; request_revision?: number | null }[]; }; export type ChatRouteCandidate = { diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index a2e8722828..1cced23c35 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -801,6 +801,7 @@ export type ChatVisibleMessage = { return_delivery?: { schema_version: "manager_return_delivery_status_v0"; phase: "decision" | "conclusion"; + request_revision?: number | null; status: string; created_at?: string | null; delivered_at?: string | null; diff --git a/apps/presentation/dashboard/src/data/request-control.ts b/apps/presentation/dashboard/src/data/request-control.ts new file mode 100644 index 0000000000..4d57e8a422 --- /dev/null +++ b/apps/presentation/dashboard/src/data/request-control.ts @@ -0,0 +1,47 @@ +import {z} from "zod"; +import {requestJson} from "./chat"; +import {REQUEST_CONTROL_SCHEMA, REQUEST_AMENDMENT_OUTCOMES, REQUEST_CONTROL_STATES, + REQUEST_OBSERVATION_STATES} from "../../../../../loopx/control_plane/collaboration/request_control_contract.js"; + +export const requestControlRouteSchema = z.object({available: z.boolean().optional(), goal_id: z.string(), goal_instance_id: z.string(), + agent_id: z.string(), request_id: z.string(), session_id: z.string(), turn_id: z.string()}); +export type RequestControlRoute = z.infer; +export function requestControlRouteKey(route: RequestControlRoute) { + return JSON.stringify([route.goal_id, route.goal_instance_id, route.agent_id, route.request_id, route.session_id, route.turn_id]); +} +export const requestBriefSchema = z.object({schema_version: z.literal("collaboration_brief_v0"), purpose: z.string(), context: z.string(), + constraints: z.array(z.string()), inputs: z.array(z.object({ref: z.string(), description: z.string(), sha256: z.string().optional(), + delegation: z.object({operation_id: z.string(), ref: z.string(), relation: z.enum(["responds_to", "revises", "uses"])}).optional()})), + acceptance: z.array(z.string()), return_requirement: z.string()}); +export type RequestBrief = z.infer; +const terminalSchema = z.object({operation_id: z.string(), reservation_digest: z.string(), outcome: z.enum(REQUEST_AMENDMENT_OUTCOMES), + goal_changed: z.boolean(), goal_revision: z.number().nullable(), todo_id: z.string().nullable(), receipt_sha256: z.string(), + goal_ref: z.object({goal_id: z.string(), goal_instance_id: z.string()})}); +export const requestControlViewSchema = z.object({schema_version: z.literal(REQUEST_CONTROL_SCHEMA), + request_id: z.string(), goal_ref: z.object({goal_id: z.string(), goal_instance_id: z.string()}), receiver_agent_id: z.string(), + revision: z.number().int().positive(), control_version: z.number().int().nonnegative(), revision_digest: z.string(), brief: requestBriefSchema, + reason: z.string(), read_at: z.string().nullable(), read_readback: z.enum(REQUEST_OBSERVATION_STATES), + assessment: z.object({decision: z.enum(["adopt", "defer", "reject", "no_change"]), reason: z.string(), assessed_at: z.string()}).nullable(), + assessment_readback: z.enum(REQUEST_OBSERVATION_STATES), + control_state: z.enum(REQUEST_CONTROL_STATES), + cancellation: z.object({operation_id: z.string(), reason: z.string(), requested_at: z.string()}).nullable(), + active_reservation: z.object({digest: z.string(), binding: z.object({request_revision: z.number(), operation_id: z.string()})}).nullable(), + terminal_effects: z.array(z.object({digest: z.string(), binding: z.object({request_revision: z.number(), operation_id: z.string()}), terminal: terminalSchema})), + execution_authority_granted: z.literal(false)}); +export type RequestControlView = z.infer; +export const pendingRequestControlSchema = z.object({route: requestControlRouteSchema, operation: z.enum(["revise", "cancel", "recover"]), + parameters: z.record(z.string(), z.unknown()), execute: z.literal(true)}); +export type PendingRequestControl = z.infer; +const responseSchema = z.object({ok: z.boolean(), view: requestControlViewSchema.nullable().optional(), + request_control: requestControlViewSchema.nullable().optional(), effect: terminalSchema.nullable().optional(), + readback_errors: z.array(z.string()).optional(), effect_recovery: z.object({readback_errors: z.array(z.string()).optional()}).optional()}); +function routing(route: RequestControlRoute) {const {available: _available, ...fields} = route; return fields;} +export async function fetchRequestControl(route: RequestControlRoute) { + const response = responseSchema.parse(await requestJson(`/api/chat/request-control?${new URLSearchParams(routing(route))}`)); + if (!response.ok || !response.view) throw new Error("Current request readback is unavailable."); + return response.view; +} +export async function applyRequestControl(request: PendingRequestControl, execute = true) { + return responseSchema.parse(await requestJson("/api/chat/request-control", {method: "POST", + body: JSON.stringify({...routing(request.route), operation: request.operation, parameters: request.parameters, execute})})); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.css b/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.css index 63d5a2cf29..a249553572 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.css @@ -8,3 +8,16 @@ .personal-collaboration ul { padding-left: 20px; } .personal-collaboration small { display: block; font-family: var(--font-mono); font-size: var(--text-caption); line-height: var(--leading-caption); } .personal-collaboration summary:focus-visible { outline: 2px solid var(--color-link, #0070f3); outline-offset: 2px; } +.personal-request-control { border-top: 1px solid var(--color-border); margin-top: 16px; padding-top: 12px; } +.personal-request-control form, .personal-request-control label { display: grid; gap: 6px; } +.personal-request-control form { gap: 12px; margin-top: 12px; } +.personal-request-control input, .personal-request-control textarea { width: 100%; min-width: 0; box-sizing: border-box; } +.personal-request-control textarea { min-height: 72px; resize: vertical; } +.personal-request-control fieldset { min-width: 0; margin: 0; border: 0; padding: 0; } +.personal-request-input { display: grid; gap: 8px; margin: 10px 0; padding-bottom: 10px; border-bottom: 1px solid var(--color-border); } +.personal-request-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.personal-request-help { color: var(--color-text-secondary); } +.personal-request-control button { min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: var(--pw-card); color: inherit; cursor: pointer; } +.personal-request-control button:disabled { opacity: .5; cursor: default; } +.personal-request-control :is(button, input, textarea):focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; } +.personal-request-control :is(input, textarea) { padding: 8px 10px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); font: inherit; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.tsx b/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.tsx index a73cf7b34b..64d097b870 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.tsx @@ -1,4 +1,7 @@ import type { CollaborationReadback } from "../../data/chat-model"; +import {useEffect, useState} from "react"; +import type {RequestControlView} from "../../data/request-control"; +import {RequestControl} from "./request-control"; import { useWorkspaceI18n } from "./i18n"; import "./collaboration-card.css"; @@ -23,11 +26,26 @@ const copy = { export function CollaborationCard({ request }: { request?: CollaborationReadback }) { const { locale } = useWorkspaceI18n(); + const [observed, setObserved] = useState(null); + const [unavailable, setUnavailable] = useState(false); + const [editing, setEditing] = useState(false); + const [detailsOpen, setDetailsOpen] = useState(false); + useEffect(() => {setObserved(null); setUnavailable(false);}, [request?.request_id]); + useEffect(() => { + if (request?.request_control && observed && request.request_control.control_version >= observed.control_version) { + setObserved(null); setUnavailable(false); + } + }, [request?.request_control]); if (!request) return null; const c = copy[locale]; - const brief = request.brief; + const brief = observed?.brief ?? request.brief; + const revision = observed?.revision ?? request.request_revision; + const readStatus = unavailable ? "unavailable" : observed ? observed.read_readback === "recorded" ? "supplied" + : observed.read_readback === "unavailable" ? "unavailable" : "pending" : request.read_status; + const recordedDecision = unavailable ? "unavailable" : observed ? observed.assessment?.decision + ?? (observed.assessment_readback === "unavailable" ? "unavailable" : "pending") : request.decision; const decision = (() => { - switch (request.decision) { + switch (recordedDecision) { case "adopt": return c.adopt; case "defer": return c.defer; case "reject": return c.reject; @@ -36,23 +54,30 @@ export function CollaborationCard({ request }: { request?: CollaborationReadback default: return c.unknown; } })(); - const conclusion = request.returns.find((reply) => reply.phase === "conclusion"); + const conclusion = request.returns.find((reply) => reply.phase === "conclusion" && + (revision === undefined || (reply.request_revision === undefined ? 1 : reply.request_revision) === revision)); + const decisionReason = observed ? observed.assessment?.reason : request.decision_reason; return
{brief.purpose}{c.owner}: {request.goal_id ? `${request.goal_id} / ` : ""}{request.agent_id}

- {request.read_status === "supplied" ? c.supplied : request.read_status === "unavailable" ? c.unavailable : c.pending} + {revision !== undefined ? {locale === "zh-CN" ? "请求版本" : "Request revision"} {revision} : null} + {readStatus === "supplied" ? c.supplied : readStatus === "unavailable" ? c.unavailable : c.pending} {c.decision}: {decision} {conclusion ? {conclusion.status === "delivered" ? c.delivered : conclusion.status === "queued" ? c.result : c.unverified} : null}

- {request.decision_reason ?

{c.reason}: {request.decision_reason}

: null} -
+ {decisionReason && !unavailable ?

{c.reason}: {decisionReason}

: null} +
setDetailsOpen(event.currentTarget.open)}> {c.details} + {request.control_route ? {setObserved(value); setUnavailable(value === null);}} onEditing={setEditing} /> : null} + {!editing && <>

{c.context}

{brief.context}

{brief.constraints.length ? <>

{c.constraints}

    {brief.constraints.map((item, i) =>
  • {item}
  • )}
: null} {brief.inputs.length ? <>

{c.inputs}

    {brief.inputs.map((input, i) =>
  • {input.ref} — {input.description}{input.sha256 ? sha256:{input.sha256} : null}
  • )}
: null}

{c.acceptance}

    {brief.acceptance.map((item, i) =>
  • {item}
  • )}

{c.return}

{brief.return_requirement}

+ }
; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 2b433edd93..0a0d1259e1 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -385,6 +385,7 @@ export type WorkspaceReturnDelivery = { error?: string | null; status: string; verification?: "reconciled_after_restart"; + request_revision?: number | null; }; export type WorkspaceImageAttachment = { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/request-control.tsx b/apps/presentation/dashboard/src/features/personal-workspace/request-control.tsx new file mode 100644 index 0000000000..35d50ccc14 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/request-control.tsx @@ -0,0 +1,155 @@ +import {useCallback, useEffect, useRef, useState} from "react"; +import {ChatApiError} from "../../data/chat"; +import {applyRequestControl, fetchRequestControl, pendingRequestControlSchema, requestControlRouteKey, + type PendingRequestControl, type RequestBrief, type RequestControlRoute, type RequestControlView} from "../../data/request-control"; +import {useWorkspaceI18n} from "./i18n"; + +const words = { + "zh-CN": {open: "修正或取消请求", refresh: "重新读取", edit: "修正内容", save: "保存修正", cancel: "取消请求", recover: "核对原操作", + revision: "请求版本", pending: "目标变更仍待核对", cancelling: "取消已记录,原操作仍待核对", cancelled: "请求已取消", current: "当前请求", + committed: "已写入目标", aborted: "已中止,未写入目标", rejected: "目标未接受这项变更", goalRevision: "目标版本", + limitation: "取消会停止这项请求的新变更;已经写入目标的任务会保留。", retained: "已记录的变更不代表任务执行或验收完成。", + purpose: "请求内容", context: "背景与补充", constraints: "约束(每行一项)", acceptance: "验收要求(每行一项)", expected: "需要回传", + inputs: "输入材料", ref: "工作区内的相对路径", description: "材料说明", digest: "版本摘要(可选)", add: "添加材料", remove: "移除材料", + reason: "修正原因(可选)", defaultReason: "在原对话中修正请求", cancelReason: "原请求发起者取消请求", close: "收起编辑", + unknown: "结果尚未确认。原操作已保留,可重新核对。", retry: "重试原操作", stale: "请求已有新修订。已重新读取,请基于当前内容修改。", + historical: "这是历史 Goal 实例的请求,当前界面仅保留回读。", loading: "正在读取请求…", error: "暂时无法读取当前请求。", storage: "无法保存恢复信息,本次操作尚未发送。", + }, + en: {open: "Revise or cancel request", refresh: "Refresh", edit: "Revise content", save: "Save revision", cancel: "Cancel request", recover: "Recover original operation", + revision: "Request revision", pending: "Goal effect needs reconciliation", cancelling: "Cancellation recorded; original operation still needs reconciliation", cancelled: "Request cancelled", current: "Current request", + committed: "Committed to Goal", aborted: "Aborted without a Goal change", rejected: "Goal change rejected", goalRevision: "Goal revision", + limitation: "Cancellation stops new changes for this request. Work already committed to the Goal is retained.", retained: "A recorded change does not prove task execution or acceptance.", + purpose: "Request", context: "Context & corrections", constraints: "Constraints (one per line)", acceptance: "Acceptance (one per line)", expected: "Expected return", + inputs: "Input materials", ref: "Relative workspace path", description: "Description", digest: "Version digest (optional)", add: "Add material", remove: "Remove material", + reason: "Revision reason (optional)", defaultReason: "Request revised in the original conversation", cancelReason: "Original requester cancelled the request", close: "Close editor", + unknown: "The outcome is not confirmed. The original operation is retained for recovery.", retry: "Retry original operation", stale: "The request changed. Its current revision has been loaded for editing.", + historical: "This request belongs to a historical Goal instance and remains read-only here.", loading: "Reading the request…", error: "Current request readback is unavailable.", storage: "Recovery data could not be saved. The operation was not sent.", + }, +}; +const lines = (value: string) => value.split("\n").map(row => row.trim()).filter(Boolean); + +export function RequestControl({route, active, onReadback, onEditing}: {route: RequestControlRoute; active: boolean; + onReadback: (view: RequestControlView | null) => void; onEditing: (editing: boolean) => void}) { + const {locale} = useWorkspaceI18n(); const c = words[locale]; + const [opened, setOpened] = useState(false), [view, setView] = useState(null); + const [draft, setDraft] = useState(null), [reason, setReason] = useState(""); + const [pending, setPending] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState(""); + const generation = useRef(0), onRead = useRef(onReadback); onRead.current = onReadback; + const editNotice = useRef(onEditing); editNotice.current = onEditing; + useEffect(() => {editNotice.current(draft !== null);}, [draft !== null]); + const storageKey = `loopx.request-control.${route.goal_instance_id}.${route.session_id}.${route.request_id}`; + const routeKey = requestControlRouteKey(route); + const show = useCallback((value: RequestControlView | null) => {setView(value); onRead.current(value);}, []); + const remember = (value: PendingRequestControl | null) => { + if (value) sessionStorage.setItem(storageKey, JSON.stringify(value)); else sessionStorage.removeItem(storageKey); + setPending(value); + }; + const load = useCallback(async () => { + const observed = ++generation.current; + try { + const value = await fetchRequestControl(route); + if (observed === generation.current) {show(value); return value;} + } catch (failure) { + if (observed === generation.current) {show(null); setError(failure instanceof Error ? failure.message : c.error);} + } + return null; + }, [routeKey, show, c.error]); // Route identity, not caller object allocation, owns this read. + useEffect(() => { + generation.current++; setOpened(false); setDraft(null); setReason(""); setPending(null); setError(""); setView(null); setBusy(false); + try { + const raw = sessionStorage.getItem(storageKey); + if (raw) { + const saved = pendingRequestControlSchema.parse(JSON.parse(raw)); + if (requestControlRouteKey(saved.route) === routeKey) {setPending(saved); setOpened(true); void load();} + } + } catch {setError(c.unknown);} + return () => {generation.current++;}; + }, [storageKey, routeKey]); + useEffect(() => { + if (active && !opened && route.available !== false) {setOpened(true); void load();} + }, [active, opened, route.available, load]); + + async function submit(request: PendingRequestControl) { + const observed = ++generation.current; + setBusy(true); setError(""); + if (pending === null && request.operation !== "recover") { + try { + await applyRequestControl(request, false); + if (observed !== generation.current) return; + } catch (failure) { + if (observed !== generation.current) return; + setBusy(false); + if (failure instanceof ChatApiError && failure.payload.error_code === "request_basis_changed") { + setDraft(null); await load(); setError(c.stale); + } else setError(failure instanceof Error ? failure.message : c.error); + return; + } + } + try {remember(request);} catch {setBusy(false); setError(c.storage); return;} + try { + const result = await applyRequestControl(request); + if (observed !== generation.current) return; + const current = result.view ?? result.request_control ?? null; + show(current); + if (!result.ok || !current) { + setError(result.readback_errors?.join(" ") || result.effect_recovery?.readback_errors?.join(" ") || c.unknown); + return; + } + remember(null); setDraft(null); setReason(""); + } catch (failure) { + if (observed !== generation.current) return; + show(null); + if (failure instanceof ChatApiError && failure.payload.error_code === "request_basis_changed") { + remember(null); setDraft(null); setBusy(false); + await load(); setError(c.stale); + } else setError(failure instanceof Error ? `${c.unknown} ${failure.message}` : c.unknown); + } finally {if (observed === generation.current) setBusy(false);} + } + function operation(kind: "revise" | "cancel" | "recover") { + if (kind !== "recover" && !view) return; + const parameters = kind === "recover" ? {} : {operation_id: `request-${kind}-${crypto.randomUUID()}`, + expected_revision: view!.revision, expected_digest: view!.revision_digest, + reason: kind === "cancel" ? c.cancelReason : reason.trim() || c.defaultReason, + ...(kind === "revise" ? {brief: draft} : {})}; + void submit({route, operation: kind, parameters, execute: true}); + } + if (route.available === false) return

{c.historical}

; + if (!opened) return null; + const terminal = view?.terminal_effects.at(-1); + return
+ {!view &&

{busy ? c.loading : c.error}

} + {view && view.control_state !== "open" &&

{view.control_state === "cancelled" ? c.cancelled : view.control_state === "cancellation_pending" ? c.cancelling + : view.control_state === "effect_pending" ? c.pending : c.current}

} + {terminal &&

{c.revision} {terminal.binding.request_revision}: {c[terminal.terminal.outcome]} + {terminal.terminal.goal_revision !== null ? ` · ${c.goalRevision} ${terminal.terminal.goal_revision}` : ""}

} + {view?.terminal_effects.length ?

{c.retained}

: null} +

{c.limitation}

+ {error &&

{error}

} +
+ + {pending ? : <> + {view && !view.cancellation && } + {view && !view.cancellation && } + {view?.active_reservation && } + } +
+ {draft &&
{event.preventDefault(); operation("revise");}}> + +