diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 1b61e8a922..e6bd8d48fc 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -5,6 +5,7 @@ "license": "Apache-2.0", "type": "module", "scripts": { + "smoke:todo-ownership": "vite build --ssr smoke/todo-ownership-smoke.mjs --outDir node_modules/.cache/todo-ownership --emptyOutDir && node node_modules/.cache/todo-ownership/todo-ownership-smoke.js", "smoke:workspace-progressive-loader": "node smoke/workspace-progressive-status-smoke.mjs", "smoke:workspace-progressive": "node ../../../examples/workspace-progressive-loading-browser-smoke.mjs", "build": "tsc --noEmit && vite build && npm run build:chat", diff --git a/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs b/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs new file mode 100644 index 0000000000..270b0e2a30 --- /dev/null +++ b/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { GoalTasksView } from "../src/features/personal-workspace/goal-tasks-view.tsx"; +import { CompletedTaskLane } from "../src/features/personal-workspace/completed-task-lane.tsx"; +import { todoItemSchema } from "../src/data/status.ts"; +import { workspaceAgentTodoFromItem } from "../src/features/personal-workspace/personal-workspace-model.ts"; +import { ContextDrawer } from "../src/features/personal-workspace/context-drawer.tsx"; +import { WorkspaceI18nProvider } from "../src/features/personal-workspace/i18n.tsx"; + +// A Goal's default executor is not evidence that a Todo has been claimed. +// Exercise the real consumers, including retained history and stale labels. +const originalWindow = globalThis.window; +try { + for (const [locale, unassigned, owner] of [["en", "Unassigned", "Owner"], ["zh-CN", "未分配", "Owner"]]) { + globalThis.window = { localStorage: { getItem: () => locale } }; + const render = (component, props) => renderToStaticMarkup(createElement( + WorkspaceI18nProvider, null, createElement(component, props), + )); + const notesLabel = locale === "en" ? "Notes" : "备注"; + const evidenceLabel = locale === "en" ? "Evidence" : "证据"; + const longNote = "Preserve separate notes. ".repeat(800) + "NOTE_END_SENTINEL"; + for (const fields of [{}, {note: null, evidence: null}, {note: "", evidence: ""}, + {note: longNote}, {evidence: "RECORDED_EVIDENCE"}, {note: longNote, evidence: "RECORDED_EVIDENCE"}]) { + for (const done of [false, true]) { + const source = todoItemSchema.parse({todo_id: "metadata-todo", text: "Read source metadata", done, + status: done ? "done" : "open", ...fields}); + const todo = workspaceAgentTodoFromItem(source, "fallback-id"); + assert.equal(todo.note, fields.note ?? null, "Notes remain an independent source field"); + assert.equal(todo.evidence, fields.evidence ?? null, "An absent evidence field cannot be inferred from notes"); + const drawer = render(ContextDrawer, {agents: [], callbacks: {}, onClose() {}, selection: { + kind: "todo", item: {...todo, goalId: "test-goal", goalTitle: "Test Goal"}, + }}); + assert.equal(drawer.includes(`${notesLabel}`), Boolean(fields.note)); + assert.equal(drawer.includes(`aria-label="${evidenceLabel}"`), Boolean(fields.evidence)); + assert.equal(drawer.includes("NOTE_END_SENTINEL"), Boolean(fields.note), "Both active and completed inspectors preserve the end of long notes"); + assert.equal(drawer.includes("RECORDED_EVIDENCE"), Boolean(fields.evidence)); + assert.equal(source.note, fields.note, "Reading never mutates canonical notes"); + assert.equal(source.evidence, fields.evidence, "Reading never mutates canonical evidence"); + } + } + for (const claim of [undefined, null, "actual-worker"]) { + const todo = {todoId: "test-todo", text: "Read claim identity", done: false, status: "open", claimedBy: claim}; + const goal = {goalId: "test-goal", title: "Test Goal", agentId: "goal-default", agentLabel: "Goal default worker", agentTodos: [todo], state: "已安排", agentSentence: "", nextSentence: ""}; + const expected = claim ?? unassigned; + const active = render(GoalTasksView, {goal, items: [], userTodos: [], onSelect() {}}); + assert.ok(active.includes(expected), "Active cards must identify only the recorded claim"); + assert.ok(!active.includes("Goal default worker"), "Goal defaults cannot substitute for a task claim"); + const completed = render(CompletedTaskLane, {goal, agentId: "all", seed: [{...todo, done: true, status: "done"}], enabled: false, onSelect() {}}); + assert.ok(completed.includes(expected), "Completed history preserves the same claim semantics"); + assert.ok(!completed.includes("Goal default worker")); + const drawer = render(ContextDrawer, {agents: [], callbacks: {}, onClose() {}, selection: { + kind: "todo", item: {...todo, goalId: goal.goalId, goalTitle: goal.title, + ownerLabel: claim ? claim : "Goal default worker"}, + }}); + assert.ok(drawer.includes(`
${owner}
${expected}
`), "A stale display label cannot invent an absent claim"); + assert.equal(todo.claimedBy, claim, "Rendering never mutates the source claim"); + } + } +} finally { + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; +} +console.log("todo-ownership-smoke: ok"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx b/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx index 5cc434414a..ff92756e99 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx @@ -100,8 +100,8 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio {indices.map((index) => { const todo = rows[index]!; return
-
; })} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index 42c79930cd..8c5f8b77ab 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -29,6 +29,7 @@ import { import type { PersonalWorkspaceCallbacks, + WorkspaceActionPreviewRequest, WorkspaceAgentOption, WorkspaceAttention, WorkspaceDrawerSelection, @@ -155,19 +156,23 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention const [todoAgentId, setTodoAgentId] = useState(agents.find((agent) => agent.available)?.agentId ?? "codex"); const [todoPriority, setTodoPriority] = useState(""); const [todoResumeWhen, setTodoResumeWhen] = useState(""); + const [previewFailure, setPreviewFailure] = useState<{identity: string; message: string} | null>(null); + const previewAttemptRef = useRef(0); const closeRef = useRef(null); const drawerRef = useRef(null); const returnFocusRef = useRef(null); const titleRef = useRef(null); const selectionIdentity = selection.kind === "run" ? `run:${selection.item.runId}` : selection.kind === "proposal" ? `proposal:${selection.item.previewId}` - : selection.kind === "todo" ? `todo:${selection.item.todoId}` - : selection.kind === "attention" ? `attention:${selection.item.todoId}` + : selection.kind === "todo" ? `todo:${selection.item.goalId}:${selection.item.todoId}` + : selection.kind === "attention" ? `attention:${selection.item.goalId}:${selection.item.todoId}` : selection.kind === "output" ? `output:${selection.item.outputId}` : selection.kind === "schedule" ? `schedule:${selection.item.scheduleId}` : `goal:${selection.item.goalId}`; useEffect(() => { + previewAttemptRef.current += 1; + setPreviewFailure(null); setRepositoryCopyState("idle"); setDiagnosticsOpen(false); setRunDrawerTab("record"); @@ -370,9 +375,22 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention if (await performRunAction(run, "correct", () => onCorrectRun(run, message))) setCorrection(""); } + async function previewAction(request: WorkspaceActionPreviewRequest) { + const attempt = ++previewAttemptRef.current; + setPreviewFailure(null); + try { + await callbacks.onPreviewAction?.(request); + } catch (error) { + if (attempt !== previewAttemptRef.current) return; + setPreviewFailure({identity: selectionIdentity, message: t("feedback.previewFailed", { + error: error instanceof Error ? error.message : String(error), + })}); + } + } + async function previewTodoTransition(todo: WorkspaceTodo, operation: TodoOperation, label: string, resumeWhen?: string) { if (operation === "successor_create") { - await callbacks.onPreviewAction?.({ + await previewAction({ actionKind: "todo.create", context: { goal_id: todo.goalId, kind: "todo", todo_id: todo.todoId }, idempotencyKey: `workspace-todo-successor-${todo.todoId}-${Date.now().toString(36)}`, @@ -381,7 +399,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention }); return; } - await callbacks.onPreviewAction?.({ + await previewAction({ actionKind: "todo.update", context: { goal_id: todo.goalId, kind: "todo", todo_id: todo.todoId }, idempotencyKey: `workspace-todo-${todo.todoId}-${operation}-${Date.now().toString(36)}`, @@ -399,7 +417,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention async function previewDecision(attention: WorkspaceAttention, decision: DecisionOutcome) { if (readOnly || !canDecideAttention(attention)) return; - await callbacks.onPreviewAction?.({ + await previewAction({ actionKind: "gate.resolve", context: { goal_id: attention.goalId, kind: "todo", todo_id: attention.todoId }, idempotencyKey: `workspace-decision-${attention.todoId}-${decision}-${Date.now().toString(36)}`, @@ -603,6 +621,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
+ {previewFailure?.identity === selectionIdentity ?

{previewFailure.message}

: null} {selection.kind === "attention" ? ( <> @@ -641,7 +660,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention

{t("drawer.taskInfo")}

Goal
{selection.item.goalTitle}
-
{t("common.owner")}
{selection.item.ownerLabel ?? selection.item.claimedBy ?? t("drawer.notAssigned")}
+
{t("common.owner")}
{selection.item.claimedBy ? selection.item.ownerLabel ?? selection.item.claimedBy : t("drawer.notAssigned")}
{t("common.status")}
{selection.item.done ? t("drawer.taskStatusCompleted") : selection.item.status === "deferred" ? t("drawer.taskStatusDeferred") : selection.item.status === "blocked" ? t("drawer.taskStatusBlocked") : t("drawer.taskStatusOpen")}
{t("drawer.priority")}
{selection.item.priority ?? t("drawer.notSet")}
{selection.item.completedAt ?
{t("drawer.completedAt")}
: null} @@ -666,7 +685,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention -
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 3f7eaec907..d5a924c4a6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -219,6 +219,7 @@ const en = { "drawer.detailsAndActions": "Details & actions", "drawer.duration": "Duration 24h / 7d", "drawer.evidence": "Evidence", + "drawer.notes": "Notes", "drawer.executionHistory": "Execution history", "drawer.executionRecord": "Run record", "drawer.executionRecordAndResult": "Execution & result", @@ -1508,6 +1509,7 @@ const zhCN: Record = { "drawer.detailsAndActions": "详情与操作", "drawer.duration": "运行时长 24h / 7d", "drawer.evidence": "证据", + "drawer.notes": "备注", "drawer.executionHistory": "执行历史", "drawer.executionRecord": "运行记录", "drawer.executionRecordAndResult": "执行过程与结果", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index a2d89d2dd4..a95da6c407 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -192,8 +192,8 @@ assert.match(tasks, /disabled=\{quickCompletingTodoIds\?\.has\(todo\.todoId\)\}/ assert.match(page, /callbacks\.onGoalActivationStateChange\?\.\(lifecycleChange\.goalId, lifecycleChange\.next\)/, "Goal lifecycle apply projects the requested state before the server responds"); assert.match(page, /model\.goals\.find\(\(goal\) => goal\.goalId === proposal\.goalId\)\?\.activationState/, "Goal lifecycle rollback captures the rendered state instead of assuming the operation inverse"); assert.match(page, /callbacks\.onGoalActivationStateChange\?\.\(lifecycleChange\.goalId, lifecycleChange\.previous\)/, "Rejected Goal lifecycle apply rolls back the optimistic projection"); -assert.match(page, /if \(applied\.actionKind === "goal\.lifecycle" \|\| applied\.actionKind === "gate\.resolve"\) \{\s*void reconcileStatus\(applied\.goalId \? \[applied\.goalId\] : undefined\)/, "Successful Goal lifecycle or decision apply reconciles the affected Goal without blocking the sidebar"); -assert.match(dashboard, /onReconcileStatus=\{\(options\) => loadFromUrl\([\s\S]*\{ background: true, invalidateGoalIds: options\?\.invalidateGoalIds, readScope: "missing" \}/, "Lifecycle reconciliation uses the non-fatal background status path"); +assert.match(page, /if \(applied\.actionKind === "goal\.lifecycle" \|\| applied\.actionKind === "gate\.resolve" \|\| applied\.actionKind === "todo\.update"\) \{\s*void reconcileStatus\(applied\.goalId \? \[applied\.goalId\] : undefined\)/, "Successful lifecycle, decision or Todo update reconciles the affected Goal without blocking the sidebar"); +assert.match(dashboard, /onReconcileStatus=\{\(options\) => loadFromUrl\([\s\S]*\{ rejectOnError: true, background: true, invalidateGoalIds: options\?\.invalidateGoalIds, readScope: "missing" \}/, "Action reconciliation preserves the background workspace and reports failed reads to its caller"); assert.match(dashboard, /statusRequestCanCommit\(statusRequestFenceRef\.current, request\)/, "A stale background response cannot overwrite a newer optimistic transition"); assert.match(sidebar, /Trash2/, "Stopped Goals expose a delete icon"); assert.match(sidebar, /onRequestGoalLifecycle\(goal, "delete"\)/, "Goal deletion stays behind the lifecycle request boundary"); @@ -459,7 +459,7 @@ for (const capabilityId of [ const matches = capabilityLocalization.match(new RegExp(`${capabilityId}:`, "g")) ?? []; assert.equal(matches.length, 2, `${capabilityId} has English and Simplified Chinese metadata`); } -for (const fieldKey of ["allowed_domains", "coordinator_agent_id", "eligible_endpoints", "enabled", "executor_endpoint", "executor_model", "executor_reasoning_effort", "max_children", "profile", "profile_preset", "review_priority", "route_ref", "safe_fix", "selection_policy", "strict_receipt", "timezone"]) { +for (const fieldKey of ["allowed_domains", "coordinator_agent_id", "eligible_endpoints", "enabled", "executor_endpoint", "executor_model", "executor_reasoning_effort", "max_children", "profile", "profile_preset", "review_order", "route_ref", "safe_fix", "selection_policy", "strict_receipt", "timezone"]) { const matches = capabilityLocalization.match(new RegExp(`^\\s+${fieldKey}:`, "gm")) ?? []; assert.equal(matches.length, 2, `${fieldKey} has English and Simplified Chinese field copy`); } 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 a440cb7ed6..1dff20f6eb 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 @@ -41,6 +41,7 @@ export type WorkspaceAgentTodo = { dependencies?: string[]; done: boolean; evidence?: string | null; + note?: string | null; nextTransition?: string | null; priority?: string | null; status?: string | null; @@ -69,7 +70,8 @@ export function workspaceAgentTodoFromItem(todo: Pick summary { min-height: 44px; align-content: center; cursor: pointer; font-size: 12px; font-weight: 600; } +.personal-detail-card > summary:focus-visible { outline: 2px solid var(--pw-blue-ink); outline-offset: 3px; } .personal-proposal-card { padding: 16px; border: 1px solid #c3d3f9; border-radius: 14px; background: #f5f8ff; } .personal-proposal-card > small { color: #5270bd; font-size: 11px; font-weight: 650; letter-spacing: 0.05em; } .personal-proposal-card h3 { margin: 8px 0; font-size: 15.5px; } diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 4d00cd8e9d..45a619e998 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -3301,6 +3301,7 @@ export function DashboardPage() { background?: boolean; readScope?: WorkspaceReadScope; invalidateGoalIds?: string[]; + rejectOnError?: boolean; resyncAttempt?: number; selectionRevision?: number; } = {}, @@ -3385,6 +3386,9 @@ export function DashboardPage() { } catch (error) { if (!statusRequestIsCurrent(statusRequestFenceRef.current, request)) return; if (!background) setLoadError(formatStatusError(error)); + // A confirmed action keeps its receipt while its caller reports that + // the independent status read needs retrying. Passive refreshes stay quiet. + if (options.rejectOnError) throw error; } finally { if (!background && statusRequestIsCurrent(statusRequestFenceRef.current, request)) { setIsLoading(false); @@ -3583,7 +3587,7 @@ export function DashboardPage() { onSelectWorkspace={selectWorkspace} onReconcileStatus={(options) => loadFromUrl( source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), - { background: true, invalidateGoalIds: options?.invalidateGoalIds, readScope: "missing" }, + { rejectOnError: true, background: true, invalidateGoalIds: options?.invalidateGoalIds, readScope: "missing" }, )} onRetryGoalArchive={retryGoalArchive} onRefresh={(readScope = "all") => loadFromUrl(source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), { readScope })} diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index f206f632df..74211f8e3e 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -889,6 +889,12 @@ export const typedActionsScenario = { await page.locator(".personal-goal-link", { hasText: "Progress Projection" }).click(); await page.getByRole("heading", { name: "Progress Projection" }).waitFor({ state: "visible" }); await page.locator(".personal-task-card").getByText("Current Todo", {exact: true}).waitFor(); + const unclaimedCard = page.locator(".personal-task-card", {hasText: "Current Todo"}); + await unclaimedCard.getByText("未分配", {exact: true}).waitFor(); + await unclaimedCard.getByText("Current Todo", {exact: true}).click(); + const unclaimedOwner = page.getByRole("dialog", {name: "Todo 详情"}).locator("dl > div", {has: page.getByText("Owner", {exact: true})}); + await unclaimedOwner.getByText("未分配", {exact: true}).waitFor(); + await page.getByRole("button", {name: /关闭详情/}).click(); if ((await page.locator(".personal-channel-title").innerText()).includes("Current Todo")) throw new Error("Header repeated the task already shown in the workspace"); const progressColumn = page.locator(".personal-object-list", { hasText: "待执行 / 进行中" }); if ((await progressColumn.locator(".personal-task-card").count()) !== 5) throw new Error("Id-less long Todo was duplicated across compact and full projections"); @@ -1766,12 +1772,35 @@ export const typedActionsScenario = { taskManagement = page.locator("details.personal-task-management"); await taskManagement.locator("summary").click(); await page.getByLabel("优先级", {exact: true}).selectOption("P4"); + const priorityButton = taskManagement.locator("label", {has: page.getByLabel("优先级", {exact: true})}).getByRole("button"); + const previewsBeforeFailure = api.actionPreviews.length; + api.failNextActionPreview = true; + await priorityButton.click(); + await taskInspector.getByRole("alert").getByText(/Action preview temporarily unavailable/).waitFor(); + if (api.actionPreviews.length !== previewsBeforeFailure) throw new Error("A refused preview persisted a proposal"); + if (await page.getByLabel("优先级", {exact: true}).inputValue() !== "P4") throw new Error("Preview failure lost the owner's draft"); + await page.getByRole("button", { name: /关闭详情/ }).click(); + await secondTaskRow.click(); + if (await taskInspector.getByRole("alert").count()) throw new Error("Preview error leaked into another Todo"); + await taskRow.click(); + taskManagement = page.locator("details.personal-task-management"); + await taskManagement.locator("summary").click(); + await page.getByLabel("优先级", {exact: true}).selectOption("P4"); await page.screenshot({path: resolve(outputDir, "todo-priority-edit.png"), fullPage: false, animations: "disabled"}); await taskManagement.locator("label", {has: page.getByLabel("优先级", {exact: true})}).getByRole("button").click(); await page.getByText("确认执行").waitFor({state: "visible"}); const priorityEdit = api.actionPreviews.findLast(preview => preview.action_kind === "todo.update" && preview.normalized_parameters.priority === "P4"); if (!priorityEdit || priorityEdit.normalized_parameters.text !== undefined) throw new Error("Priority edit must be structured, without a text rewrite"); - await page.getByRole("button", {name: "关闭", exact: true}).click(); + const readsBeforePriorityApply = api.statusRequestCount; + const writesBeforePriorityApply = api.durableWriteCount; + await page.getByRole("button", {name: "确认并应用", exact: true}).click(); + await page.getByText("操作已完成,结果状态已通过读回验证。", {exact: true}).waitFor(); + await page.waitForTimeout(2_000); + if (api.statusRequestCount <= readsBeforePriorityApply) throw new Error("Verified Todo update left the workspace status unread"); + if (api.durableWriteCount !== writesBeforePriorityApply + 1) throw new Error("Todo status reconciliation repeated the confirmed write"); + // Applied receipts retain their Goal navigation; close the drawer itself + // so the next operation still begins from the existing task card. + await page.getByRole("button", {name: /关闭详情/}).click(); await taskRow.click(); taskManagement = page.locator("details.personal-task-management"); await taskManagement.locator("summary").click(); diff --git a/loopx/chat_server.py b/loopx/chat_server.py index fbfb9cba73..0caa20e02f 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -55,6 +55,7 @@ from .chat_loopx_mode import handle_loopx_request from .capabilities.manager_context.roundtrip import project_chat_session_snapshot from .control_plane.goals.active_state_metadata import active_state_section_text +from .control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable from .control_plane.status.ssh_host_catalog import ( SSH_HOST_CATALOG_PATH, ssh_host_catalog_payload, @@ -1153,6 +1154,16 @@ def _action_preview(self) -> None: except ActionConflictError as exc: self._send_error(str(exc), status=409, error_code="action_conflict") return + except LocalCoordinationAuthorityUnavailable as exc: + # The canonical owner already decided this refusal. Adapt its + # diagnostic without publishing the source snapshot or granting + # an actor/lease on the caller's behalf. + self._send_error( + redact_local_paths(str(exc)) + ". Resolve the canonical authority requirement and retry.", + status=400, + error_code=exc.code, + ) + return except (KeyError, ValueError) as exc: self._send_error(str(exc), status=400, error_code="invalid_action_preview") return diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 91dec88735..ebdc73d6c8 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -455,7 +455,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._goal_channel_extension_ready::codec_read:load_registry#1", - "line": 998, + "line": 999, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -463,7 +463,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._registry_and_goal::codec_read:load_registry#1", - "line": 525, + "line": 526, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -471,7 +471,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat::codec_read:load_registry#1", - "line": 1557, + "line": 1568, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -479,7 +479,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat._wake_goal_context::codec_read:load_registry#1", - "line": 1662, + "line": 1673, "column": 20, "kind": "codec_read", "api": "load_registry", diff --git a/tests/test_chat_server_cors.py b/tests/test_chat_server_cors.py index 1663cb1842..ade7f260b0 100644 --- a/tests/test_chat_server_cors.py +++ b/tests/test_chat_server_cors.py @@ -214,6 +214,55 @@ def test_chat_action_context_cannot_persist_or_emit_overflowed_float( server.server_close() +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_chat_preview_preserves_canonical_actor_refusal(tmp_path, monkeypatch, provider): + from tests.control_plane.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 + + isolate_sqlite_runtime(tmp_path, monkeypatch) + runtime = tmp_path / "runtime" + state = tmp_path / "ACTIVE_GOAL_STATE.md" + state.write_text("# Derived display\n", encoding="utf-8") + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"schema_version": 1, "common_runtime_root": str(runtime), + "goals": [{"id": "goal-preview", "repo": str(tmp_path), "state_file": state.name, + "coordination": {"registered_agents": ["agent-a"]}}]}), encoding="utf-8") + projection = build_todo_runtime_shadow_projection(goal_id="goal-preview", handoff_mode="hard_lease", todos=[{ + "schema_version": "todo_item_v0", "source_section": "Agent Todo", "index": 1, + "todo_id": "todo_preview", "text": "Retain original work", "role": "agent", "status": "open", + "done": False, "archive_state": "active", "claimed_by": "agent-a", "priority": "P1", + "note": "完整来源 " * 3000, + }]) + initialize_canonical_authority(runtime, "goal-preview", projection, state_path=state, provider=provider) + before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-preview", include_leases=True) + server, thread = _start_server() + server.action_store = ChatActionStore(tmp_path / "actions") + server.action_service = ChatActionService(store=server.action_store, registry_path=registry) + try: + response = _request(server.server_address[1], method="POST", origin=None, + path="/api/actions/preview", body=json.dumps({ + "action_kind": "todo.update", "summary": "Set priority", + "normalized_parameters": {"goal_id": "goal-preview", "todo_id": "todo_preview", + "operation": "edit", "priority": "P2"}, + "context": {}, "idempotency_key": "priority-without-actor", + }).encode()) + payload = json.loads(response.read()) + assert response.status == 400 + assert payload["error_code"] == "actor_required" + assert "retry" in payload["error"].lower() + assert str(tmp_path) not in json.dumps(payload) + assert "完整来源" not in json.dumps(payload, ensure_ascii=False) + assert server.action_store.list() == [] + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="goal-preview", include_leases=True) == before + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + @pytest.mark.parametrize( "action", ["snapshot", "apply", "cancel", "regenerate", "reject", "defer"] )