From d643ebf777cfe146d4b784e3368be4d7fd5fbfd8 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:32:23 +0800 Subject: [PATCH 1/4] fix(workspace): collapse whole outcomes and refresh reads quietly Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- ...goal-acceptance-contract-browser-smoke.mjs | 73 +++++++++++++++++-- .../goal-acceptance-contract.tsx | 15 ++-- .../goal-outcome-requirements.tsx | 72 +++++++++--------- .../personal-workspace/goal-overview.css | 18 +++-- docs/development/design.md | 26 +++++-- .../goal-outcome-backend.py | 8 ++ .../goal-outcome-recovery.mjs | 65 +++++++++++++++-- 7 files changed, 204 insertions(+), 73 deletions(-) diff --git a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs index 686ba340fc..a9d1822eb2 100644 --- a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs @@ -21,11 +21,34 @@ if (writeAuthorityOnly && packaged) throw new Error("--write-authority-only requ const payload = writeAuthorityOnly ? null : JSON.parse(execFileSync(python, ["-c", ` import runpy, tempfile, json from pathlib import Path +from pytest import MonkeyPatch +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.effect_runtime import restart_effect_runtime +from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source +from loopx.control_plane.todos.todo_summary import canonical_todo_read_record, structured_todo_item from loopx.status import collect_status +from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime fixture = runpy.run_path('tests/test_delivery_review.py') +refresh = fixture['make_project'].__globals__['refresh_state_run'] +def initialize_and_refresh(**kwargs): + # The standalone caller has no pytest setup: initialize the same real + # canonical provider before the shared fixture's first operational read. + groups, _, _ = parse_todo_source(kwargs['state_file'].read_text()) + records = [canonical_todo_read_record(structured_todo_item(item, role=role, + source_section=item['source_section'])) for role, rows in groups.items() for item in rows] + initialize_canonical_authority(Path(kwargs['runtime_root_override']), kwargs['goal_id'], + build_todo_runtime_shadow_projection(goal_id=kwargs['goal_id'], todos=records, + handoff_mode='soft_claim'), state_path=kwargs['state_file'], provider='sqlite') + return refresh(**kwargs) +fixture['make_project'].__globals__['refresh_state_run'] = initialize_and_refresh with tempfile.TemporaryDirectory() as directory: - registry, runtime = fixture['make_project'](Path(directory)) - print(json.dumps(collect_status(registry_path=registry, runtime_root_override=str(runtime), limit=20, goal_id='release-demo'))) + with MonkeyPatch.context() as patch: + isolate_sqlite_runtime(Path(directory), patch) + try: + registry, runtime = fixture['make_project'](Path(directory)) + print(json.dumps(collect_status(registry_path=registry, runtime_root_override=str(runtime), limit=20, goal_id='release-demo'))) + finally: + restart_effect_runtime(wait_for_exit=True) `], { cwd: root, encoding: "utf8" })); if (payload) payload.run_history.goals[0].acceptance_observation = acceptance; const server = packaged @@ -34,7 +57,8 @@ const server = packaged async function checkLiveWriteAuthority(browser, origin) { const page = await browser.newPage({viewport: {width: 1024, height: 768}}); const writes = [], errors = []; - let readsFail = false; + let readsFail = false, nativeReads = 0; + await page.clock.install(); const native = {ok: true, goal_id: "release-demo", provider_revision: "fixture:current", goal_acceptance_contract: {...contract, lifecycle_state: "active", goal_ref: {goal_id: "release-demo", goal_instance_id: "write-authority-instance"}}}; @@ -43,10 +67,13 @@ async function checkLiveWriteAuthority(browser, origin) { await page.route("**/api/**", route => { const request = route.request(), path = new URL(request.url()).pathname; if (request.method() !== "GET") writes.push({path, body: request.postDataJSON()}); - if (path === "/api/chat/goal-acceptance") return route.fulfill( + if (path === "/api/chat/goal-acceptance") { + if (request.method() === "GET") nativeReads++; + return route.fulfill( request.method() === "GET" && readsFail ? {status: 503, json: {error: "Synthetic native read failure"}} : {json: native}); + } if (path === "/api/actions") return route.fulfill({json: { ok: true, schema_version: "loopx_chat_action_list_v1", proposals: [], }}); @@ -71,6 +98,28 @@ async function checkLiveWriteAuthority(browser, origin) { await phase("released"); await idle(); assert.deepEqual(writes, [], "Native read failure during cancellation must prevent the stale effect POST"); + const failedReads = nativeReads; + await page.clock.fastForward(15_001); + assert.equal(nativeReads, failedReads, "Failed reads back off instead of repeating the healthy poll cadence"); + const retry = page.waitForResponse(response => response.url().includes("/api/chat/goal-acceptance?")); + await page.clock.fastForward(45_000); + await retry; + assert.equal(nativeReads, failedReads + 1, "The bounded automatic retry only reads"); + assert.deepEqual(writes, []); + await page.evaluate(() => { + Object.defineProperty(document, "visibilityState", {configurable: true, value: "hidden"}); + window.dispatchEvent(new Event("visibilitychange")); + }); + await page.clock.fastForward(60_000); + assert.equal(nativeReads, failedReads + 1, "A hidden workspace does not poll its acceptance reader"); + readsFail = false; + await page.evaluate(() => { + Object.defineProperty(document, "visibilityState", {configurable: true, value: "visible"}); + window.dispatchEvent(new Event("visibilitychange")); + }); + await check.waitFor(); + assert.deepEqual(writes, [], "Returning to the workspace recovers the read without running checks"); + // A fresh retry remains usable; the negative case must not pass by blocking every write. readsFail = false; await page.getByRole("button", {name: "Refresh native read", exact: true}).click(); @@ -199,10 +248,18 @@ try { await page.reload({waitUntil: "networkidle"}); const outcomes = page.locator(".goal-outcomes"); await outcomes.waitFor(); - const visibleRows = outcomes.locator(".goal-outcome-criteria > li:not([hidden])"); - assert.equal(await visibleRows.count(), 3, "Colon-bearing outcome/requirement pairs keep only three preview rows"); - await outcomes.locator(":scope > .goal-outcomes-more").click(); - assert.equal(await visibleRows.count(), 4, "Expanding reveals the unrelated fourth requirement"); + assert.equal(await outcomes.locator(".goal-outcome-disclosure[open]").count(), 0, "Whole outcomes start collapsed"); + const first = outcomes.locator(".goal-outcome").filter({has: page.getByRole("heading", {name: "First colliding outcome", exact: true})}); + const second = outcomes.locator(".goal-outcome").filter({has: page.getByRole("heading", {name: "Second colliding outcome", exact: true})}); + await first.locator(".goal-outcome-disclosure > summary").focus(); + await page.keyboard.press("Enter"); + assert.equal(await first.locator(".goal-outcome-criteria > li").count(), 3); + assert.equal(await second.locator(".goal-outcome-disclosure").getAttribute("open"), null, + "Colon-bearing identities do not open another outcome's requirements"); + await second.locator(".goal-outcome-disclosure > summary").click(); + await second.getByText("Unrelated met requirement", {exact: true}).waitFor(); + assert.equal(await outcomes.locator(".goal-outcome-criteria > li:visible").count(), 4, + "Each selected outcome exposes every requirement without identity collisions"); response = structuredClone(snapshot); payload.run_history.goals[0].acceptance_observation = acceptance; await page.reload({waitUntil: "networkidle"}); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx index 15d3656a43..b6d5d35fbb 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-acceptance-contract.tsx @@ -28,15 +28,16 @@ export function GoalAcceptanceContractSection({goalId, contract: retained, copy, const client = useQueryClient(); const queryKey = ["goal-acceptance-readback", goalId]; const read = useQuery({queryKey, queryFn: ({signal}) => acceptanceRequest(goalId, undefined, signal), - enabled: localSource && canManage, retry: false, refetchOnWindowFocus: "always", - refetchInterval: 15_000, refetchIntervalInBackground: false}); + enabled: localSource, retry: false, staleTime: 15_000, refetchOnWindowFocus: true, + refetchInterval: query => query.state.error ? 60_000 : 15_000, refetchIntervalInBackground: false}); const actions = useTypedActionReadback(!localSource || !canManage, goalId); const plan = !localSource || actions.isError ? null : preparedAcceptancePlan(actions.data ?? [], goalId); // A failed or loading delivery read revokes writes, not a newer same-source // declaration. Remote snapshots never inherit the local reader's cache. const contract = localSource && read.data?.goal_id === goalId ? read.data.goal_acceptance_contract : retained; - const fresh = localSource && canManage && !read.isError && read.data?.goal_id === goalId + const fresh = localSource && !read.isError && read.data?.goal_id === goalId && (!contract?.lifecycle_state || contract.lifecycle_state === "active"); + const writeCurrent = canManage && fresh; const sourceKey = JSON.stringify([localSource, goalId, contract?.goal_ref ?? null, contract?.outcome_requirements?.current_lifetime ?? null]); const checksConfigured = hasConfiguredGoalChecks(contract); @@ -50,7 +51,7 @@ export function GoalAcceptanceContractSection({goalId, contract: retained, copy, const [notice, setNotice] = useState(""); const generation = useRef(0), flight = useRef(false); const source = useRef(sourceKey); source.current = sourceKey; - const writable = useRef(!!fresh); writable.current = !!fresh; + const writable = useRef(!!writeCurrent); writable.current = !!writeCurrent; useEffect(() => { generation.current++; flight.current = false; setDocument(null); setFilename(""); setError(""); setNotice(""); setBusy(false); @@ -102,7 +103,7 @@ export function GoalAcceptanceContractSection({goalId, contract: retained, copy,

{copy.guidance}

- {document ?
{zh ? "待保存配置" : "Configuration to save"} · {filename}
{JSON.stringify(document, null, 2)}

{zh ? "保存会更新所列任务的关联,不会运行检查。" : "Saving updates the listed task associations and does not run checks."}

: null} - - + +
{copy.source}
{goalId}
{checksConfigured ? <>
{copy.scope}
{contract.scope?.kind === "selected_work" ? copy.selectedWork : copy.allWork}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-outcome-requirements.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-outcome-requirements.tsx index 1435f42662..c01c13f911 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-outcome-requirements.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-outcome-requirements.tsx @@ -16,16 +16,10 @@ function stateLabels(zh: boolean) { stale: zh ? "需复查" : "Review needed", failed: zh ? "检查失败" : "Checks failed", partial: zh ? "部分检查" : "Partial checks", held: zh ? "关联待确认" : "Association held"}; } -function stateLabel(item: Requirement, fresh: boolean, zh: boolean) { - const label = item.status === "unverified" && item.agent_review?.basis_current +function stateLabel(item: Requirement, zh: boolean) { + return item.status === "unverified" && item.agent_review?.basis_current ? {met: zh ? "自查满足 · 未独立核验" : "Agent reports met · unverified", unmet: zh ? "自查有缺口" : "Agent found gaps", uncertain: zh ? "自查尚不确定" : "Agent uncertain"}[item.agent_review.conclusion] : stateLabels(zh)[displayState(item)]; - return fresh ? label : `${zh ? "历史" : "Recorded"} · ${label}`; -} -function requirementKey(outcome: OutcomeRequirements["outcomes"][number], requirement: Requirement): string { - // Native IDs are opaque and may contain colons. Keep both identities - // unambiguous instead of relying on a separator the vocabulary forbids. - return JSON.stringify([outcome.id, requirement.id]); } /** One native declaration/readback owner. The form only appends a requirement. */ @@ -42,40 +36,39 @@ export function GoalOutcomeRequirements({goalId, declaration, fresh, canManage, const attention = rows.filter(({requirement}) => !["accepted", "unverified"].includes(displayState(requirement)) || current && requirement.agent_review?.basis_current && requirement.agent_review.conclusion === "unmet") .sort((a, b) => stateOrder.indexOf(displayState(a.requirement)) - stateOrder.indexOf(displayState(b.requirement))); - const preview = attention.slice(0, 3); - const visible = new Set((all ? rows : preview).map(row => requirementKey(row.outcome, row.requirement))); + const preview = [...new Set([...attention.map(row => row.outcome.id), ...declaration.outcomes.map(outcome => outcome.id)])].slice(0, 3); const labels = stateLabels(zh); if (!declaration.outcomes.length) return null; return

{zh ? "支撑成果" : "Supporting outcomes"} {declaration.outcomes.length}

-

{zh ? "数量统计要求,不代表 Goal 已完成。Agent 自查与独立检查分别记录;自查依据变化时显示需复查。完整要求、证据和来源可展开阅读。" : "Counts describe requirements, not Goal completion. Agent reviews and independent checks remain distinct; changed review bases need another review. Expand a requirement for its complete text, evidence and sources."}

-

{zh ? "默认显示最多三项需要处理的要求,按失败、关联待确认、需复查和部分检查排列,其余要求仍在全部列表。未配置自动检查不影响保留成果要求。" : "The preview shows up to three requirements needing attention: failed, held, changed or partial checks. All other requirements remain in the full list. Automated checks are optional."}

+

{zh ? "数量统计要求,不代表 Goal 已完成。Agent 自查与独立检查分别记录;自查依据变化仍需复查,重新读取不会使它通过。展开一项成果可读完整要求、证据与来源。" : "Counts describe requirements, not Goal completion. Agent reviews and independent checks remain distinct; changed review bases still need review, even after a fresh read. Expand an outcome for its complete requirements, evidence and sources."}

+

{zh ? "默认显示最多三项成果,优先列出有失败、关联待确认、需复查或自查缺口的成果。完整集合仍可展开,未配置自动检查不影响保留要求。" : "The preview shows up to three outcomes, prioritizing failed, held or changed checks and Agent-reported gaps. The complete collection remains available. Automated checks are optional."}

- {!current ? {zh ? "记录待刷新" : "Refresh needed"} : null} + {!current ? {declaration.current_lifetime ? (zh ? "上次读回" : "Last readback") : (zh ? "上一轮记录" : "Previous lifetime")} : null} {counts.filter(item => item.count).map(item => {item.count} {labels[item.state]})} {!rows.length ? {zh ? "尚无要求" : "No requirements yet"} : null}
{rows.length ? : null}
- {declaration.outcomes.map(outcome => )}
- + {declaration.outcomes.length > preview.length ? : null}
; } -function Outcome({goalId, outcome, fresh, canManage, revision, onRefresh, zh, visible, all}: { +function Outcome({goalId, outcome, fresh, canManage, revision, onRefresh, zh, shown}: { goalId: string; outcome: OutcomeRequirements["outcomes"][number]; fresh: boolean; canManage: boolean; revision: string | null; onRefresh: () => Promise; zh: boolean; - visible: Set; all: boolean; + shown: boolean; }) { const [text, setText] = useState(""); - const [groupsOpen, setGroupsOpen] = useState>>({failed: true, held: true, stale: true, partial: true}); + const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false), [notice, setNotice] = useState(""); const [pending, setPending] = useState | null>(null); async function save() { @@ -97,29 +90,30 @@ function Outcome({goalId, outcome, fresh, canManage, revision, onRefresh, zh, vi await onRefresh(); } finally {setBusy(false);} } - const shown = all || outcome.requirements.some(item => visible.has(requirementKey(outcome, item))); - return