diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index e6bd8d48fc..55e79e9ef7 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -40,7 +40,7 @@ "smoke:delegation-preflight-browser": "node --experimental-strip-types smoke/delegation-preflight-browser-smoke.mjs", "smoke:personal-workspace": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", - "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs", "smoke:todo-resume-condition": "rm -rf /tmp/loopx-todo-resume-condition-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-todo-resume-condition-smoke smoke/todo-resume-condition-smoke.ts src/features/personal-workspace/todo-resume-condition.ts && node /tmp/loopx-todo-resume-condition-smoke/smoke/todo-resume-condition-smoke.js", "smoke:presentation-surface-schema": "rm -rf /tmp/loopx-presentation-surface-schema-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-presentation-surface-schema-smoke smoke/presentation-surface-schema-smoke.ts src/data/status.ts src/data/decision-research.ts src/data/goal-channel-frontstage.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-presentation-surface-schema-smoke/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.js", "smoke:projection-localization": "rm -rf /tmp/loopx-projection-localization-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --outDir /tmp/loopx-projection-localization-smoke smoke/projection-localization-smoke.ts src/features/personal-workspace/projection-localization.ts && node /tmp/loopx-projection-localization-smoke/smoke/projection-localization-smoke.js", diff --git a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs new file mode 100644 index 0000000000..038892d933 --- /dev/null +++ b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs @@ -0,0 +1,176 @@ +// A reader can follow version evidence and return without rediscovering the work. +// Uses the packaged UI and existing synthetic API fixture; no live Goal/model writes. +import assert from "node:assert/strict"; +import {mkdir} from "node:fs/promises"; +import {resolve} from "node:path"; +import {launchBrowser, loadPlaywright, waitForHttp} from "../../../../examples/dashboard-browser-smoke-support.mjs"; + +process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED = "1"; +const {outputDir, port, startServer} = await import("../../../../examples/personal-workspace-browser/fixture.mjs"); +const {openWorkspacePage} = await import("../../../../examples/personal-workspace-browser/scenario-context.mjs"); +let server, browser, workspace; +try { + server = await startServer(); + const url = `http://127.0.0.1:${port}/chat/?statusUrl=/status.json`; + await waitForHttp(url); + browser = await launchBrowser(loadPlaywright().chromium); + workspace = await openWorkspacePage({newPage: options => browser.newPage({locale: "zh-CN", ...options})}, url); + const {page, api} = workspace; + await page.locator(".personal-goal-link", {hasText: "Product Release"}).click(); + await page.getByRole("navigation", {name: "Goal 视图"}).getByRole("button", {name: "对话", exact: true}).click(); + await page.getByRole("button", {name: "开启 LoopX 模式", exact: true}).click(); + await page.getByLabel("已注册的协调身份").selectOption("lead"); + await page.getByLabel("协调员总 token 额度").fill("100000"); + await page.getByRole("button", {name: "保存设置", exact: true}).click(); + const configured = api.loopxModeRequests.findLast(row => row.operation === "configure"); + const mode = page.__loopxRuntime.loopxModes.get(configured.sessionId); + Object.assign(mode, {enabled: true, paused: false, active_turn_id: "fixture-loopx-turn", + native: {status: "active", tokenBudget: 100000}, fixtureCorrectionEpisode: true, fixtureAdoptionState: "current"}); + await page.getByText("LoopX · 正在推进", {exact: true}).waitFor(); + await page.getByRole("button", {name: "团队执行情况", exact: true}).click(); + const dialog = page.getByRole("dialog", {name: "团队执行情况"}); + const openEvidence = dialog.getByRole("button", {name: "查看证据与反馈", exact: true}); + await openEvidence.first().click(); + const evidence = dialog.getByRole("region", {name: "执行证据"}); + const original = () => evidence.getByLabel("证据内容: report.json"); + await original().waitFor(); + // Older readbacks remain readable without invented provenance. + await evidence.getByText("本次验收依据", {exact: true}).click(); + await evidence.getByText("此运行时未提供验收依据标识。", {exact: true}).waitFor(); + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + const verificationGap = evidence.getByText("当前读回未提供独立验收者与指定版本回执。", {exact: true}); + await verificationGap.waitFor({timeout: 3000}); + await evidence.getByText("后续结果 · 当前验收与采用记录有效", {exact: false}).waitFor(); + await mkdir(outputDir, {recursive: true}); + await verificationGap.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-verifier-gap-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + await page.emulateMedia({reducedMotion: "reduce"}); + assert.ok(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth)); + await verificationGap.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-verifier-gap-mobile.png"), animations: "disabled"}); + await page.setViewportSize({width: 1512, height: 980}); + // Losing an optional adoption cannot erase freshly verified correction evidence. + const unavailableDownstream = route => route.request().postDataJSON()?.operation === "read" + && route.request().postDataJSON()?.operation_id === "accepted-synthesis" + ? route.fulfill({status: 503, json: {error: "downstream observation unavailable"}}) : route.fallback(); + await page.route("**/api/chat/sessions/*/loopx", unavailableDownstream); + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + const adoptionGap = evidence.getByText("采用证据无法核验", {exact: false}); + await adoptionGap.waitFor({timeout: 3000}); + await verificationGap.waitFor(); + assert.equal(await evidence.getByRole("button", {name: "阅读原始产物", exact: true}).count(), 1); + assert.equal(await evidence.getByRole("button", {name: "阅读回应与证据", exact: true}).count(), 1); + assert.equal(await evidence.getByRole("button", {name: "阅读后续结果", exact: true}).count(), 0); + await page.screenshot({path: resolve(outputDir, "team-adoption-unavailable-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + assert.ok(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth)); + await adoptionGap.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-adoption-unavailable-mobile.png"), animations: "disabled"}); + await page.setViewportSize({width: 1512, height: 980}); + await page.unroute("**/api/chat/sessions/*/loopx", unavailableDownstream); + // Revoke the refreshed owner receipt after this reader's earlier current observation. + mode.fixtureAdoptionState = "unavailable"; + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + await adoptionGap.waitFor({timeout: 3000}); + assert.equal(await evidence.getByRole("button", {name: "阅读后续结果", exact: true}).count(), 0); + await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click(); + await original().waitFor(); + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + await adoptionGap.waitFor({timeout: 3000}); + mode.fixtureAdoptionState = "current"; + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + await evidence.getByRole("button", {name: "阅读后续结果", exact: true}).waitFor(); + const unavailableCore = route => route.request().postDataJSON()?.operation === "read" + && route.request().postDataJSON()?.operation_id === "review-objection" + ? route.fulfill({status: 409, json: {error: "review version revoked"}}) : route.fallback(); + await page.route("**/api/chat/sessions/*/loopx", unavailableCore); + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + await evidence.getByRole("alert").filter({hasText: "关联执行或版本已变化"}).waitFor(); + assert.equal(await verificationGap.count(), 0, "A lost core revision still clears the trace"); + assert.equal(await evidence.getByRole("button", {name: "阅读原始产物", exact: true}).count(), 0); + await page.unroute("**/api/chat/sessions/*/loopx", unavailableCore); + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + await evidence.getByRole("button", {name: "阅读回应与证据", exact: true}).click(); + await evidence.getByLabel("证据内容: objection.json").waitFor(); + await evidence.getByRole("button", {name: "original-analysis", exact: true}).click(); + await original().waitFor(); + assert.match(await original().textContent(), /cash_flow.*90/); + const back = dialog.getByRole("button", {name: "返回上一份证据", exact: true}); + await back.waitFor({timeout: 3000}); + await page.keyboard.press("Enter"); + await evidence.getByLabel("证据内容: objection.json").waitFor(); + assert.ok(await back.evaluate(el => el === document.activeElement), "Returning retains keyboard navigation in evidence"); + const readsBefore = api.loopxModeRequests.filter(row => row.operation === "read").length; + await back.click(); + await original().waitFor(); + assert.match(await original().textContent(), /cash_flow.*75/); + assert.equal(api.loopxModeRequests.filter(row => row.operation === "read").length, readsBefore + 1, + "Return rechecks the original operation instead of replaying cached acceptance"); + assert.equal(await back.count(), 0, "Root evidence returns to the execution list"); + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + await evidence.getByRole("button", {name: "阅读原始产物", exact: true}).click(); + await original().waitFor(); + await mkdir(outputDir, {recursive: true}); + await page.screenshot({path: resolve(outputDir, "team-evidence-return-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + await page.emulateMedia({reducedMotion: "reduce"}); + assert.ok(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth)); + await page.screenshot({path: resolve(outputDir, "team-evidence-return-mobile.png"), animations: "disabled"}); + const failedReturn = async route => { + const body = route.request().method() === "POST" ? route.request().postDataJSON() : {}; + if (body.operation === "read" && body.operation_id === "accepted-analysis") { + return route.fulfill({status: 409, json: {error: "original acceptance revoked"}}); + } + if (body.operation === "operations") { + return route.fulfill({json: {items: [{record_id: "a".repeat(64), operation_id: "accepted-analysis", + status: "unavailable", recovery_required: null}], has_more: false, next_cursor: null, page_readback_complete: false}}); + } + return route.fallback(); + }; + await page.route("**/api/chat/sessions/*/loopx", failedReturn); + await back.click(); + await evidence.getByRole("alert").filter({hasText: "已清除上次证据"}).waitFor(); + assert.equal(await original().count(), 0, "Returning after revocation cannot restore the old report"); + assert.equal(await verificationGap.count(), 0, "Unavailable evidence cannot retain a prior correction trace"); + await dialog.getByRole("button", {name: "返回执行列表", exact: true}).click(); + await dialog.locator('.goal-team-record[data-state="unavailable"]').waitFor({timeout: 3000}); + assert.equal(await dialog.locator('.goal-team-pulse [data-bucket="accepted"] strong').textContent(), "0", + "Returning to the list must withdraw accepted counts when current evidence is unavailable"); + assert.ok(await openEvidence.first().evaluate(el => el === document.activeElement)); + await page.unroute("**/api/chat/sessions/*/loopx", failedReturn); + const refresh = dialog.getByRole("button", {name: "重新核验", exact: true}); + await refresh.click(); + await dialog.locator('.goal-team-record[data-state="accepted"]').waitFor(); + await openEvidence.first().click(); + await original().waitFor(); + const failedList = async route => route.request().postDataJSON()?.operation === "operations" + ? route.fulfill({status: 503, json: {error: "delegation inventory unavailable"}}) : route.fallback(); + await page.route("**/api/chat/sessions/*/loopx", failedList); + await dialog.getByRole("button", {name: "返回执行列表", exact: true}).click(); + await dialog.getByRole("alert").filter({hasText: "delegation inventory unavailable"}).waitFor(); + assert.equal(await dialog.locator(".goal-team-pulse").count(), 0, "Failed readback cannot retain cached counts"); + assert.equal(await openEvidence.count(), 0, "Failed readback cannot retain cached execution records"); + assert.ok(await refresh.evaluate(el => el === document.activeElement), "Unavailable list returns focus to recovery"); + await page.unroute("**/api/chat/sessions/*/loopx", failedList); + await refresh.click(); + await dialog.locator('.goal-team-record[data-state="accepted"]').waitFor(); + await dialog.getByRole("button", {name: "下一页", exact: true}).click(); + await dialog.locator('.goal-team-record[data-state="recovery_required"]').waitFor(); + await openEvidence.first().click(); + await evidence.waitFor(); + const listReadsBefore = api.loopxModeRequests.filter(row => row.operation === "operations").length; + await dialog.getByRole("button", {name: "返回执行列表", exact: true}).click(); + await dialog.locator('.goal-team-record[data-state="recovery_required"]').waitFor(); + const listReads = api.loopxModeRequests.filter(row => row.operation === "operations"); + assert.equal(listReads.length, listReadsBefore + 1, "List return performs one read, without polling"); + assert.equal(listReads.at(-1).cursor, "b".repeat(64), "Return retains the original page instead of jumping to the first page"); + assert.ok(await openEvidence.first().evaluate(el => el === document.activeElement)); + assert.equal(api.turnRequests.length, 0); + assert.equal(api.loopxModeRequests.filter(row => row.operation === "message").length, 0); + console.log("team-evidence-return: passed (packaged navigation, downstream loss/revocation/restoration, core loss, keyboard return, fresh evidence/list, pagination, mobile and no execution)"); +} finally { + await workspace?.close(); + await browser?.close(); + server?.kill("SIGTERM"); +} diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index e48c2b1452..56fc25e55e 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -628,6 +628,16 @@ async function requestJson(url: string, init?: RequestInit): Promise { return parsedPayload as T; } +export async function readTodoRequest(goalId: string, todoId: string, signal: AbortSignal) { + const query = new URLSearchParams({goal_id: goalId, todo_id: todoId}); + const result = z.object({ + ok: z.literal(true), goal_id: z.string(), todo_id: z.string(), text: z.string(), + status: z.string(), archive_state: z.string(), updated_at: z.string().nullable(), + }).parse(await requestJson(`/api/chat/todo/detail?${query}`, {signal})); + if (result.goal_id !== goalId || result.todo_id !== todoId) throw new Error("Task source changed"); + return result; +} + export async function fetchChatStatus() { return chatStatusSchema.parse(await requestJson("/status.json")); } @@ -1133,6 +1143,8 @@ export type DelegationReadback = { operation_id: string; request_id: string; agent_id: string; todo_id: string; status: string; worker_active: boolean; recovery_required: boolean; artifacts?: Array<{ref: string; sha256: string; text: string}>; error?: string; + validation?: {source: "goal_acceptance" | "todo_validation"; basis_sha256: string; + check_count: number; pinned_file_count: number}; dependencies?: DelegationDependency[]; adoptions?: DelegationAdoption[]; }; export function readLoopXTeamWork(sessionId: string, operationId: string) { 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 8c5f8b77ab..e1bdac1cc4 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -43,6 +43,7 @@ import type { LarkGoalConnection } from "../../data/chat"; import { localizedGoalState, localizedSessionStatus, useWorkspaceI18n } from "./i18n"; import { formatCostUsd, formatDurationMs, formatTokenCount, formatUsageValue } from "./personal-workspace-model"; import { TeamPlanResult } from "./team-plan-result"; +import { TaskRequest } from "./task-request"; import { parseTodoResumeCondition } from "./todo-resume-condition"; import { MarkdownText } from "./markdown"; import { formatMonitorDate } from "./monitor-readback"; @@ -654,7 +655,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.item.priority ? {selection.item.priority} : null} {selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")} -

{selection.item.text}

+

{t("drawer.taskInfo")}

diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-tasks-view.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-tasks-view.tsx index 5d101b91c7..c47cf33622 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-tasks-view.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-tasks-view.tsx @@ -133,6 +133,12 @@ export function GoalTasksView({ const [listView, setListView] = useState(false); const [laneSelection, setLaneSelection] = useState({ goalId: "", laneId: "all" }); const selectedTodoRef = useRef(null); + function selectFromButton(button: HTMLButtonElement, next: WorkspaceDrawerSelection) { + // macOS pointer activation need not focus buttons. The drawer uses the + // active element to return to the originating task when it closes. + button.focus({ preventScroll: true }); + onSelect(next); + } useEffect(() => { if (!selectedTodoId) return; const frame = window.requestAnimationFrame(() => selectedTodoRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" })); @@ -226,7 +232,7 @@ export function GoalTasksView({ {attentionItems.map((attention) => { const age = localizedAttentionAge(attention.updatedAt, t); return ( -
- {execution ? : null} + {execution ? : null} {onQuickComplete ? ( ) : null} - +
); @@ -279,7 +285,7 @@ export function GoalTasksView({ {scheduleItems.map((item) => ( - ))} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-episode.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-episode.tsx index bc0ac81bf6..b86c8e0387 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-episode.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-episode.tsx @@ -1,8 +1,9 @@ import {useEffect, useRef, useState} from "react"; -import {readLoopXTeamWork, type DelegationDependency, type DelegationReadback} from "../../data/chat"; +import {readLoopXTeamWork, type DelegationAdoption, type DelegationDependency, type DelegationReadback} from "../../data/chat"; type VerifiedLink = {link: DelegationDependency; source: DelegationReadback}; -type Episode = {original: VerifiedLink; response: VerifiedLink; downstream: DelegationReadback | null}; +type Episode = {original: VerifiedLink; response: VerifiedLink; + adoption: DelegationAdoption | null; downstream: DelegationReadback | null}; /** Resolve only an explicitly requested correction path. Reads are on demand and never start work. */ export function GoalTeamEpisode({sessionId, result, zh, onInspect}: { @@ -10,7 +11,6 @@ export function GoalTeamEpisode({sessionId, result, zh, onInspect}: { }) { const original = result.dependencies?.find(link => link.relation === "revises"); const response = result.dependencies?.find(link => link.relation === "responds_to" && link.operation_id !== original?.operation_id); - const adoption = result.adoptions?.find(row => row.state === "current") ?? result.adoptions?.[0]; const [episode, setEpisode] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -24,42 +24,53 @@ export function GoalTeamEpisode({sessionId, result, zh, onInspect}: { const current = ++generation.current; setEpisode(null); setError(""); setBusy(true); try { - const [first, challenge, revised, downstream] = await Promise.all([ + const [first, challenge, revised] = await Promise.all([ readLoopXTeamWork(sessionId, original!.operation_id), readLoopXTeamWork(sessionId, response!.operation_id), readLoopXTeamWork(sessionId, result.operation_id), - adoption?.state === "current" ? readLoopXTeamWork(sessionId, adoption.consumer_operation_id) : Promise.resolve(null), ]); const matches = (link: DelegationDependency, source: DelegationReadback) => - link.state === "current" && source.status === "accepted" && !source.recovery_required && !source.error + link.state === "current" && source.operation_id === link.operation_id + && source.status === "accepted" && !source.recovery_required && !source.error && source.artifacts?.some(artifact => artifact.ref === link.ref && artifact.sha256 === link.sha256); const responseBindsOriginal = challenge.dependencies?.some(link => link.relation === "responds_to" && link.operation_id === original!.operation_id && matches(link, first)); - const revisionMatchesObservation = revised.status === "accepted" && revised.agent_id === result.agent_id + const revisionMatchesObservation = revised.operation_id === result.operation_id + && revised.request_id === result.request_id && revised.todo_id === result.todo_id + && revised.status === "accepted" && revised.agent_id === result.agent_id && !revised.recovery_required && !revised.error && revised.artifacts?.length === result.artifacts?.length && result.artifacts?.every(expected => revised.artifacts?.some( artifact => artifact.ref === expected.ref && artifact.sha256 === expected.sha256)) && revised.dependencies?.some(link => link.relation === "revises" && link.operation_id === original!.operation_id && matches(link, first)) && revised.dependencies?.some(link => link.relation === "responds_to" && link.operation_id === response!.operation_id && matches(link, challenge)); - const refreshedAdoption = adoption?.state === "current" ? revised.adoptions?.find(row => - row.consumer_operation_id === adoption.consumer_operation_id && row.requester_agent_id === adoption.requester_agent_id - && row.consumer_agent_id === adoption.consumer_agent_id && row.state === "current") : null; + if (result.status !== "accepted" || result.error || result.recovery_required + || !matches(original!, first) || !matches(response!, challenge) + || !responseBindsOriginal || !revisionMatchesObservation) { + throw new Error("linked evidence unavailable"); + } + // Use this check's owner observation, including adoption added or restored since the report opened. + const adoption = revised.adoptions?.find(row => row.state === "current") ?? revised.adoptions?.[0] ?? null; + const refreshedAdoption = adoption?.state === "current" ? adoption : null; + // Adoption is a separate observation: its loss must not erase a current correction. + const downstream = refreshedAdoption + ? await readLoopXTeamWork(sessionId, refreshedAdoption.consumer_operation_id).catch(() => null) : null; const downstreamBindsRevision = downstream?.dependencies?.some(link => link.relation === "uses" && link.operation_id === revised.operation_id && matches(link, revised)); const downstreamMatchesReceipt = downstream?.status === "accepted" && !downstream.recovery_required && !downstream.error + && downstream.operation_id === refreshedAdoption?.consumer_operation_id + && downstream.request_id === refreshedAdoption?.consumer_request_id + && downstream.agent_id === refreshedAdoption?.consumer_agent_id + && downstream.todo_id === refreshedAdoption?.consumer_todo_id + && refreshedAdoption.consumer_artifacts.length > 0 && refreshedAdoption?.consumer_artifacts.every(expected => downstream.artifacts?.some( artifact => artifact.ref === expected.ref && artifact.sha256 === expected.sha256)); - const sourceMatchesReceipt = refreshedAdoption?.source_artifacts.every(expected => revised.artifacts?.some( + const sourceMatchesReceipt = refreshedAdoption && refreshedAdoption.source_artifacts.length > 0 + && refreshedAdoption.source_artifacts.every(expected => revised.artifacts?.some( artifact => artifact.ref === expected.ref && artifact.sha256 === expected.sha256)); - if (result.status !== "accepted" || result.error || result.recovery_required - || !matches(original!, first) || !matches(response!, challenge) - || !responseBindsOriginal || !revisionMatchesObservation - || (adoption?.state === "current" && (!sourceMatchesReceipt || !downstreamBindsRevision || !downstreamMatchesReceipt))) { - throw new Error("linked evidence unavailable"); - } if (current === generation.current) setEpisode({original: {link: original!, source: first}, - response: {link: response!, source: challenge}, downstream}); + response: {link: response!, source: challenge}, adoption, + downstream: sourceMatchesReceipt && downstreamBindsRevision && downstreamMatchesReceipt ? downstream : null}); } catch { if (current === generation.current) setError(zh ? "关联执行或版本已变化;请重新读取证据。" : "A linked execution or version changed; recheck the evidence."); } finally {if (current === generation.current) setBusy(false);} @@ -77,11 +88,13 @@ export function GoalTeamEpisode({sessionId, result, zh, onInspect}: {
  • 02 · {zh ? "复核回应" : "Review response"}{episode.response.source.agent_id}
  • 03 · {zh ? "修订产物 · 当前验收有效" : "Revised output · currently accepted"}{result.agent_id} - {zh ? "与原始版本的正文对照见下方" : "Compare with the original below"}
  • + {zh ? "与原始版本的正文对照见下方" : "Compare with the original below"} + {zh ? "独立验收 · 证据未提供" : "Independent verification · evidence not provided"} + {zh ? "当前读回未提供独立验收者与指定版本回执。" : "This readback does not provide an independent verifier and an exact-version receipt."}
  • 04 · {episode.downstream ? (zh ? "后续结果 · 当前验收与采用记录有效" : "Downstream result · acceptance and adoption current") - : adoption ? (zh ? "采用证据无法核验" : "Adoption evidence unavailable") : (zh ? "尚无请求方采用" : "No requester adoption")} - {episode.downstream && adoption ? <>{episode.downstream.agent_id} · {adoption.requester_agent_id} - : null}
  • + : episode.adoption ? (zh ? "采用证据无法核验" : "Adoption evidence unavailable") : (zh ? "尚无请求方采用" : "No requester adoption")} + {episode.downstream && episode.adoption ? <>{episode.downstream.agent_id} · {episode.adoption.requester_agent_id} + : null} : null}
    ; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx index b65b2d43d0..a7f1550b08 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx @@ -72,6 +72,18 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres : } {result.error ?

    {result.error}

    : null} + {result.status === "accepted" && !result.error && !result.recovery_required ?
    + {zh ? "本次验收依据" : "Current validation basis"} + {result.validation ? <> +

    {result.validation.source === "goal_acceptance" ? (zh ? "Goal 验收规则" : "Goal acceptance rules") + : (zh ? "任务验收规则" : "Task validation rules")}{" · "} + {result.validation.check_count} {zh ? "项检查" : "checks"}{" · "} + {result.validation.pinned_file_count} {zh ? "项文件版本固定" : "file pins"}

    +

    {zh ? "本次读取重跑了当前规则,并核对产物版本。规则标识不证明独立复核者或异议已解决。" + : "This read reran the current rules and checked output versions. The rule identity does not attest an independent reviewer or resolve an objection."}

    + {result.validation.basis_sha256} + :

    {zh ? "此运行时未提供验收依据标识。" : "This runtime did not provide the validation basis identity."}

    } +
    : null} {result.status === "accepted" && !result.error && !result.recovery_required && result.artifacts?.length ? result.artifacts.map(artifact =>
    {zh ? "版本与来源标识" : "Version and source identifiers"} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-work.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-work.tsx index f9373651bc..3c0b4984cd 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-work.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-work.tsx @@ -53,10 +53,16 @@ function StateIcon({state}: {state: DelegationState | PulseBucket | CheckTone}) /** On-demand observations share the caller/config pin of this Goal conversation. */ export function GoalTeamWork({sessionId, members, zh, canMessage, ingress}: {sessionId: string; members: Member[]; zh: boolean; canMessage: boolean; ingress: LoopXModeSnapshot["ingress"]}) { - const [selected, setSelected] = useState(null); + // Navigation retains references only. Each visit remounts the evidence reader + // and rechecks current authority and acceptance, including on the way back. + const [evidencePath, setEvidencePath] = useState([]); + const selected = evidencePath.at(-1) ?? null; const backButton = useRef(null); const lastSelection = useRef(null); const selectedTrigger = useRef(null); + const refreshButton = useRef(null); + const pageCursor = useRef(undefined); + const pendingListFocus = useRef(false); const [page, setPage] = useState(null); const [checks, setChecks] = useState>({}); const [checkErrors, setCheckErrors] = useState>({}); @@ -64,16 +70,24 @@ export function GoalTeamWork({sessionId, members, zh, canMessage, ingress}: {ses const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const generation = useRef(0); - useEffect(() => {(selected ? backButton.current : selectedTrigger.current)?.focus();}, [selected]); + useEffect(() => { + if (selected) backButton.current?.focus(); + else if (pendingListFocus.current && !busy) { + (selectedTrigger.current ?? refreshButton.current)?.focus(); + pendingListFocus.current = false; + } + }, [selected, busy, page]); const memberKey = members.map(member => `${member.id}:${member.agent_id}:${member.todo_id}`).join("|"); useEffect(() => { - generation.current++; setPage(null); setChecks({}); setCheckErrors({}); setInspectionTotal(0); setError(""); setBusy(false); + generation.current++; pendingListFocus.current = false; + setPage(null); setChecks({}); setCheckErrors({}); setInspectionTotal(0); setError(""); setBusy(false); void read(); return () => {generation.current++;}; }, [sessionId, memberKey]); async function read(cursor?: string) { const current = ++generation.current; - setBusy(true); setError(""); setPage(null); setSelected(null); + pageCursor.current = cursor; + setBusy(true); setError(""); setPage(null); setEvidencePath([]); try { const result = await fetchLoopXTeamWork(sessionId, cursor); if (current === generation.current) setPage(result); @@ -81,6 +95,12 @@ export function GoalTeamWork({sessionId, members, zh, canMessage, ingress}: {ses if (current === generation.current) setError(failure instanceof Error ? failure.message : String(failure)); } finally {if (current === generation.current) setBusy(false);} } + function returnToList() { + // Reconcile current acceptance before restoring the original page/focus. + // A failed read leaves recovery available, rather than stale success rows. + pendingListFocus.current = true; + void read(pageCursor.current); + } async function inspect(id: string) { const current = ++generation.current; setBusy(true); setError(""); @@ -119,8 +139,17 @@ export function GoalTeamWork({sessionId, members, zh, canMessage, ingress}: {ses const unverified = Object.values(checks).filter(check => check.state === "runtime_unverified").length; const blocked = checked - ready - unverified; if (selected) return
    - - +
    + + {evidencePath.length > 1 ? : null} +
    + {if (operationId !== selected) setEvidencePath(path => [...path, operationId]);}}/>
    ; const items = page?.items ?? []; @@ -146,7 +175,7 @@ export function GoalTeamWork({sessionId, members, zh, canMessage, ingress}: {ses {row.operation_id ?? row.record_id}{row.todo_id ? {row.todo_id} : null}
    : null} {row.operation_id ? : null} ; } @@ -185,7 +214,7 @@ export function GoalTeamWork({sessionId, members, zh, canMessage, ingress}: {ses ; })}
    {zh ? "此协调身份的持久工作" : "Durable work for this coordinator"} - + {page?.has_more && page.next_cursor ? : null}
    {busy ?

    {zh ? "正在读取当前事实…" : "Reading current facts…"}

    : null} {error ?

    {error}

    : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 5707c8a412..c6ebd89295 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -399,6 +399,11 @@ const en = { "drawer.taskDeferUntil": "Defer until", "drawer.taskDetails": "Todo details", "drawer.taskInfo": "Task information", + "drawer.requestLoading": "Showing the summary while the full request loads…", + "drawer.requestError": "Only the summary is available. The full request could not be read.", + "drawer.requestLocalOnly": "This source provides a summary. Open its local workspace to read the full request.", + "drawer.requestUnidentified": "Showing the received text. This legacy Task has no authority id for a full request read.", + "drawer.requestRetry": "Retry full request", "drawer.taskManage": "Manage task", "drawer.taskNextCompleted": "Create a follow-up task", "drawer.taskNextOpen": "Advance or update status", @@ -1690,6 +1695,11 @@ const zhCN: Record = { "drawer.taskDeferUntil": "暂缓至", "drawer.taskDetails": "Todo 详情", "drawer.taskInfo": "任务信息", + "drawer.requestLoading": "正在读取完整要求,暂时显示摘要…", + "drawer.requestError": "当前仅有摘要,完整要求读取失败。", + "drawer.requestLocalOnly": "此来源提供摘要,请在它的本机工作区读取完整要求。", + "drawer.requestUnidentified": "显示已收到的正文。此旧版任务缺少原始 ID,无法另行读取完整要求。", + "drawer.requestRetry": "重试读取完整要求", "drawer.taskManage": "管理任务", "drawer.taskNextCompleted": "可创建后续任务", "drawer.taskNextOpen": "推进或更新状态", 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 1dff20f6eb..cf33d95306 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 @@ -47,6 +47,10 @@ export type WorkspaceAgentTodo = { status?: string | null; taskClass?: string | null; taskDomain?: string | null; + /** Original request retained when text is shortened for a card. */ + requestText?: string; + /** The authority id, absent when a legacy projection needs a display-only id. */ + sourceTodoId?: string | null; text: string; todoId: string; validationDigest?: string | null; @@ -66,6 +70,8 @@ export function workspaceAgentTodoFromItem(todo: Pick).receipt_id : null; return { todoId: todo.todo_id?.trim() || fallbackId, + sourceTodoId: todo.todo_id?.trim() || null, + requestText: todo.text, text: todo.text, done: todo.status === "deferred" ? false : todo.done, status: todo.status ?? null, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx new file mode 100644 index 0000000000..b3ec734963 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx @@ -0,0 +1,38 @@ +import {useEffect, useState} from "react"; +import {readTodoRequest} from "../../data/chat"; +import {useWorkspaceI18n} from "./i18n"; +import type {WorkspaceTodo} from "./personal-workspace-model"; + +// Per-open read state, never a second Task store. A different selection or +// source invalidates both the in-flight read and its earlier successful body. +type RequestRead = {identity: string} & ( + | {phase: "loading" | "error"; text?: never} + | {phase: "ready"; text: string} +); + +export function TaskRequest({todo, local}: {todo: WorkspaceTodo; local: boolean}) { + const {t} = useWorkspaceI18n(); + const sourceId = todo.sourceTodoId; + const identity = JSON.stringify([local, todo.goalId, sourceId, todo.requestText, todo.status]); + const [read, setRead] = useState(null); + const [retry, setRetry] = useState(0); + useEffect(() => { + if (!local || !sourceId) return; + const controller = new AbortController(); + setRead({identity, phase: "loading"}); + void readTodoRequest(todo.goalId, sourceId, controller.signal).then(result => { + if (!controller.signal.aborted) setRead({identity, phase: "ready", text: result.text}); + }).catch(() => { + if (!controller.signal.aborted) setRead({identity, phase: "error"}); + }); + return () => controller.abort(); + }, [identity, local, retry, todo.goalId, sourceId]); + const current = local && read?.identity === identity ? read : null; + return
    +

    {current?.phase === "ready" ? current.text : todo.requestText ?? todo.text}

    + {current?.phase !== "ready" ?
    + {t(!local ? "drawer.requestLocalOnly" : !sourceId ? "drawer.requestUnidentified" : current?.phase === "error" ? "drawer.requestError" : "drawer.requestLoading")} + {current?.phase === "error" ? : null} +
    : null} +
    ; +} diff --git a/docs/architecture/rfcs/live-team-workspace-v0.md b/docs/architecture/rfcs/live-team-workspace-v0.md index af9fe27308..53a395742d 100644 --- a/docs/architecture/rfcs/live-team-workspace-v0.md +++ b/docs/architecture/rfcs/live-team-workspace-v0.md @@ -389,17 +389,20 @@ second assignment. This strengthens the optional request/intervention entry; real dual-card click-through remains post-install acceptance. It does not prove execution, result return or whole-team stopping. `consume_return` alone still means consumption, not version-bound adoption. -Implementation checkpoint (2026-09-24): an on-demand correction path can -cross-check the original, review response, revised output and downstream -adoption against current version-bound delegation reads. It exposes actor -identities and opens each accepted artifact; an unavailable adoption or rejected -review clears the previously verified path. This is a presentation and -readback slice, not L1 completion: the relation `responds_to` does not certify -that the response is an objection, and current acceptance lacks an explicit -verifier identity in this read model. The packaged browser scenario is -synthetic; the existing real correction run must still be exercised through -the packaged UI and independently read back, with the missing and lost-observation -cases in Section 9. +Implementation checkpoint ([#5587](https://github.com/loopx-project/loopx/pull/5587), proposed): the correction reader cross-checks the original, review response, revised output and requester adoption against current version-bound delegation reads. Opening linked evidence preserves a stepwise return path. Leaving evidence rereads the current execution page once, retains that page and returns keyboard focus; revoked output withdraws its earlier accepted row/count. A failed inventory read clears earlier success and offers refresh recovery. A failed downstream read or withdrawn adoption keeps freshly checked original, response and revision evidence readable, with adoption marked unavailable. Core version loss still clears the trace; explicit recheck restores adoption only against the current receipt and exact consumer identity/input/output. + +This remains a presentation/readback slice. `responds_to` does not certify an objection. Current native acceptance validates the canonical task and configured validators, but its readback does not expose an independent verifier identity and exact-version receipt. The revision therefore shows that missing evidence explicitly, even beside a valid requester adoption. The evidence reader separately exposes the current validation source, definition digest and check/file-pin counts from the existing typed validation plan. CLI and HTTP readback carry the same path-free observation after the original checks pass; failed validation or changed pins withdraw it with the report. This rule identity is not a persisted success or independent-verifier receipt. The frontend must consume such evidence from the existing acceptance owner when available; it cannot infer it from the reviewer name, relationship or artifact hash. + +Packaged desktop, 390px, keyboard/reduced-motion and pagination checks cover these returns and recovery. An isolated production SQLite/HTTP/CLI fixture also rejects changed output, withdraws its list acceptance and restores the original version without launching additional work. [Public-safe fixture views](../../reference/local-delegation.md#inspect-accepted-evidence-and-return) make these states reviewable. These checks do not establish a real objection, independent semantic acceptance, installed native behavior or L1 completion. The existing real correction episode must still be exercised through the packaged UI and independently read back with Section 9's missing/lost-observation cases. +Task inspectors read the original request on demand through the existing Todo +authority. Status/list summaries and thin reads retain their bounded budget; +an exact non-thin CLI read now returns the complete source text. The packaged +drawer distinguishes loading, unavailable source and display-only legacy ids, +offers retry, and discards stale selection responses. A failed read never +reuses an earlier full body. File/SQLite CLI and loopback HTTP checks cover +active/retained requests and recovery; packaged desktop/390px checks cover a +988-character request, mismatched identity, late response and keyboard return. +These read-only checks do not certify semantic acceptance or L1 completion. Motion is retained only when it clarifies these transitions; remove effects that obscure absent execution, absent acceptance or source loss. diff --git a/docs/architecture/rfcs/live-team-workspace-v0.zh-CN.md b/docs/architecture/rfcs/live-team-workspace-v0.zh-CN.md index b9ca10ae7f..c647368754 100644 --- a/docs/architecture/rfcs/live-team-workspace-v0.zh-CN.md +++ b/docs/architecture/rfcs/live-team-workspace-v0.zh-CN.md @@ -300,12 +300,16 @@ Chat 会话推断报告。复用现有 authority 与产物读取边界,不另 点击重复分配。这加强可选请求/干预入口;真实双卡点击仍需安装后验收,不能证明 执行、结果回报或整队停止。 `consume_return` 单独仍只代表消费,不能替代版本绑定的采用。 -实施检查点(2026-09-24):按需打开的纠偏路径可用当前版本绑定的 delegation -回读,交叉核验原始产物、复核回应、修订产物和后续采用,显示成员身份并打开每份已 -验收产物;采用不可用或复核被拒绝时清除旧路径。这是展示与回读切片,尚非 L1 -完成:`responds_to` 关系不能证明回应确实提出异议,当前回读中的验收也缺少明确 -的 verifier 身份。打包前端的浏览器场景仍是合成数据;已有的真实纠偏运行还须 -在打包界面和独立 CLI 上核验同一过程,并覆盖第 9 节的缺失与失联情况。 +实施检查点([#5587](https://github.com/loopx-project/loopx/pull/5587),提案):纠偏阅读器用当前版本绑定的 delegation 回读,交叉核验原产物、复核回应、修订产物与请求方采用。打开关联证据后可逐级返回;退出证据时仅重读一次当前执行页,保留分页并恢复键盘焦点。产物失效会撤回原先的验收行/计数,列表读失败清除旧成功状态并提供刷新恢复。后续结果失联或采用撤回时,保留本次核验仍有效的原产物、回应与修订,单独显示采用无法核验。核心版本失效仍清除路径;显式重查只有在当前回执和准确的接收方身份、输入、输出一致时才恢复采用。 + +这仍是展示与回读切片。`responds_to` 不能证明回应确实提出异议。原生接受规则核验 canonical 任务与已配置 validator,但当前回读未提供独立验收者身份和准确版本回执,因此修订行明确显示这一缺口,即使请求方采用仍然有效。此证据应由既有 acceptance owner 提供,前端不能从 reviewer 名称、关联关系或产物哈希推断。 证据详情另展示既有 typed validation plan 的当前规则来源、定义摘要及检查/文件固定项数;原规则通过后 CLI 与 HTTP 返回同一份无路径观察,验收失败或文件固定项变化时随产物撤回。规则身份不等于持久成功回执或独立验收者回执。 + +打包桌面、390px、键盘/reduced motion 和分页检查覆盖上述返回与恢复。隔离的真实 SQLite/HTTP/CLI fixture 也验证文件变化后拒绝正文、撤回列表验收,以及恢复原版本,全程不启动额外工作。[公共安全的合成界面](../../reference/local-delegation.md#inspect-accepted-evidence-and-return)让这些状态可评审。上述检查不证明真实异议、独立语义验收、已安装 Native 行为或 L1 完成;已有真实纠偏事件仍须在打包前端和独立 CLI 核验,并覆盖第 9 节的缺失与失联情况。 +任务抽屉按需从既有 Todo authority 读取原始要求,状态/列表摘要与 thin 读保持原预算; +准确 ID 的非 thin CLI 读取现在返回完整原文。打包抽屉区分加载、来源不可用及仅用于 +显示的旧版 ID,提供重试并丢弃旧选择晚到的响应;失败时不复用之前成功的完整正文。 +File/SQLite CLI 与本机 HTTP 检查覆盖 active/retained 正文及恢复,打包桌面/390px +覆盖 988 字要求、身份不匹配、晚到响应与键盘返回。这些只读检查不证明语义验收或 L1 完成。 动态只有让上述变化更清楚才保留;掩盖未执行、未验收 或来源失联的效果应移除。更广的语义缩放、成员扩张和 renderer 探索在此后推进。 diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 882076d808..78642c030e 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -829,7 +829,7 @@ TS validation and canonical receipt/display recovery. Real-provider mixed-graph counterexamples cover concurrent changes and lost responses. [Scope and remaining boundaries](../../reference/canonical-terminal-review.md); this does not settle R5 or D1–D3. -L3 checkpoint: standalone acquisition/takeover, atomic claim admission and maintenance share typed lease facts/rules and provider opening. Exact acquisition retry verifies current execution proof; real CLI completion can recover missing Markdown display. Full-state scope conflicts, process interruption and File/SQLite/PostgreSQL read-only rehearsal are covered. [Remaining executor and integration boundaries](../../reference/canonical-lease-renew.md); R5, D2/D3 and default qualification remain open. +L3 checkpoint: standalone acquisition/takeover, atomic claim admission and maintenance share typed lease facts/rules and provider opening. Exact acquisition retry verifies current execution proof; real CLI completion can recover missing Markdown display. Full-state scope conflicts, process interruption and File/SQLite/PostgreSQL read-only rehearsal are covered. Verified cooperative code-edit mode treats overlapping files as integration advisories, including unknown legacy workspace grants; known same-checkout collisions, Todo-instance ownership and ordinary exclusive acquisition retain their fences. This bounded coordination change does not qualify a real peer correction/adoption journey. [Remaining executor and integration boundaries](../../reference/canonical-lease-renew.md); R5, D2/D3 and default qualification remain open. - **Owner:** TS T0–T4 and shared-authority D1–D3; retain their numbering and gates. - **Selection:** prioritize an entire hot-path transaction or recovery lifecycle used by R1–R4. Record before/after callers, owners, crossings, actual deletions and performance. Stop adding per-field Python→TS RPCs; do not rebuild the merged Todo update. diff --git a/docs/assets/personal-workspace/team-adoption-unavailable.png b/docs/assets/personal-workspace/team-adoption-unavailable.png new file mode 100644 index 0000000000..4f717667ea Binary files /dev/null and b/docs/assets/personal-workspace/team-adoption-unavailable.png differ diff --git a/docs/assets/personal-workspace/team-evidence-desktop.png b/docs/assets/personal-workspace/team-evidence-desktop.png index 1547149ef5..20b1eb446a 100644 Binary files a/docs/assets/personal-workspace/team-evidence-desktop.png and b/docs/assets/personal-workspace/team-evidence-desktop.png differ diff --git a/docs/assets/personal-workspace/team-evidence-mobile.png b/docs/assets/personal-workspace/team-evidence-mobile.png index e109840250..97ef5a19d9 100644 Binary files a/docs/assets/personal-workspace/team-evidence-mobile.png and b/docs/assets/personal-workspace/team-evidence-mobile.png differ diff --git a/docs/assets/personal-workspace/team-evidence-stale.png b/docs/assets/personal-workspace/team-evidence-stale.png index 744fb85851..0fa9d8fe28 100644 Binary files a/docs/assets/personal-workspace/team-evidence-stale.png and b/docs/assets/personal-workspace/team-evidence-stale.png differ diff --git a/docs/reference/canonical-lease-renew.md b/docs/reference/canonical-lease-renew.md index f00b3d8adf..ea3c5cda74 100644 --- a/docs/reference/canonical-lease-renew.md +++ b/docs/reference/canonical-lease-renew.md @@ -31,12 +31,14 @@ New canonical acquisitions freeze the canonical Todo's normalized `task_repository` as `lease.write_repository`. There is no caller repository override and no inference from the CLI working directory. Within one Goal, overlapping relative paths conflict unless **both** execution grants have known, -different repository identities, or the explicit code-edit worktree mode below proves sibling checkout isolation. Host/path case aliases remain overlapping. +different repository identities, or the caller uses the verified cooperative +code-edit mode below. Host/path case aliases remain overlapping. The existing complete-head scan, owner eligibility, TTL, generations, CAS and receipt identities are unchanged; an empty scope set still does not conflict. Old grants without `write_repository` (or with null) remain unknown and -conservatively overlap any repository. Reading or renewing them does not +conservatively overlap any repository for ordinary exclusive acquisition. +Reading or renewing them does not backfill a namespace from today's Todo. Fresh acquisition after legal retirement can freeze the current Todo identity. Malformed frozen identities fail closed. Renewal, transfer and release preserve the frozen value and historical receipts. @@ -64,11 +66,14 @@ loopx --registry registry.json task-lease acquire \ --write-scope 'src/**' --write-worktree "$PWD" ``` -The TypeScript entrypoint verifies the Git root, origin against the Todo's -repository, machine identity and filesystem identity. Two grants in distinct -sibling worktrees on the same machine may overlap: acquisition returns +The TypeScript entrypoint verifies the caller's Git root, origin against the +Todo's repository, machine identity and filesystem identity. This selects +cooperative code editing: overlapping relative file scopes return `integration_overlap_advisories` identifying the other Todo and paths, so their -owners can coordinate and validate the combined changes before merge. This is +owners can coordinate and validate the combined changes before merge. The +other grant may describe a different checkout or omit workspace identity; an +unknown legacy grant no longer blocks the verified code editor. The advisory +does not attest the other editor's isolation or rewrite its grant. This is cooperative code-edit coordination, not a filesystem access-control mechanism. It does not authorize changing shared runtime data, Git administration, remote branches, or merging. Use ordinary exclusive leases for those operations. @@ -78,8 +83,10 @@ explicit default transport ports (including SSH `:22` and Git `:9418`) do not create another repository, while nondefault ports remain distinct. Origins with passwords or unsafe path segments are rejected before lease acquisition. -Same-worktree aliases, the same Todo, other machines or clones, and grants -without a verified workspace retain existing exclusion. Repository mismatch, +Known same-checkout aliases and the same Todo retain existing exclusion, even +if retained repository metadata differs for that physical checkout. +Requests without a verified caller workspace retain ordinary exclusive scope +checks, including against unknown legacy grants. Repository mismatch, redirected paths and a non-worktree root fail closed. Verified machine discovery currently supports macOS and Linux; other hosts retain ordinary leases. No workspace path or machine identifier is stored directly: only opaque digests @@ -87,18 +94,25 @@ and the existing public repository identity enter the private authority record. Renewal preserves this identity. Acquire retries must use the same worktree, scopes and execution key; an alias resolving to the same worktree is valid. -To change directories or return to ordinary exclusion, release the current -lease with its version and acquire a new execution key. Existing leases are -never retroactively reclassified; their holders can release and reacquire -explicitly. No automatic migration or grant expansion occurs. +To change directories or return to ordinary exclusion, release your current +lease with its version and acquire a new execution key without +`--write-worktree`. Other holders need not release their grants to admit an +isolated code editor. Existing records and historical receipts are retained; +no foreign ownership, shared-runtime permission or merge authority changes. + +**Behavior change:** worktree mode no longer requires both holders to have +verified sibling-worktree identities. File overlap is an integration advisory +unless the retained identity positively identifies the same physical checkout. +The Todo lease still fences the execution instance, exact retries, renewal and +lifecycle writes; code-file exclusivity is not that instance fence. ## 仓库相对路径的冲突边界 新的 canonical 租约从权威 Todo 的 `task_repository` 冻结 `lease.write_repository`,不接受调用者覆盖,也不从 CLI 当前目录猜测。同一 Goal -内,只有双方都是已知且不同的仓库,才隔离同名相对路径;大小写别名仍互斥。 +内,普通独占模式只有双方都是已知且不同的仓库,才隔离同名相对路径;大小写别名仍互斥。 完整 head 扫描、owner 资格、TTL、generation、CAS 与回执身份保持原规则,空 scope -仍不产生写冲突。旧记录缺少该字段或为 null 时保持未知、保守互斥,读回和续租不 +仍不产生写冲突。旧记录缺少该字段或为 null 时保持未知,普通独占模式保守互斥,读回和续租不 回填;合法退役后的新执行才冻结当前仓库。损坏身份拒绝执行,续租、转交和释放 保留冻结值与原历史回执。当前执行证明、领取重放与 inspect 拒绝已知仓库漂移 (`lease_repository_divergence`);清理仍凭精确 owner/key/version,不能借 metadata @@ -112,6 +126,22 @@ JSON 与 Markdown 读回同一仓库字段或未知状态。这只是 Goal 内 (包括 SSH `:22` 和 Git `:9418`)不产生另一个仓库身份,非默认端口仍须匹配。 带密码或不安全路径段的 origin 在获取租约前被拒绝。 +### 独立 worktree 的协作代码编辑 + +在独立 Git worktree 根目录使用上方 `--write-worktree "$PWD"`。TS 入口验证 +调用者的仓库、物理目录和主机身份;代码文件的 scope 重叠只返回 +`integration_overlap_advisories`,供集成、评审与合并时协调。对方旧 lease +缺少 worktree 身份也不阻塞,且不会被改写或被推断成已隔离。已确认同一物理 +checkout 的冲突、同一 Todo 的执行归属仍拒绝;软链接、Git 管理目录和仓库不匹配 +仍在入口拒绝。 + +这是 worktree 模式的行为变更:文件范围从跨 checkout 的执行锁改为集成提示, +不再要求双方都先释放、重新绑定 worktree。Todo 实例 lease、CAS、TTL、重放、 +续租及生命周期写入仍按原规则保护。共享运行状态、远端分支和合并权限不由 +此模式授予;未提供经验证的调用者 worktree 时,普通独占规则保持不变。 +如需恢复普通独占,按读回版本释放自己的 lease,再用新的执行 key、不带 +`--write-worktree` 重新取得;无需修改其他角色的 lease。 + ## Operate the current lease Read the current canonical lease and use its owner, execution key and version: diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index dab60c3c88..91cb4b6fe0 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -868,6 +868,8 @@ paths, credential/endpoint configuration, or provider payloads. 或原 runner 配置,再重新核验。模块可用不证明凭据、profile、任务验收或远端容量; 此检查不暴露解释器路径、凭据/endpoint 配置或 provider 原始数据。 +### Inspect accepted evidence and return + Enabled MCP exposes `inspect_execution_binding`; newly enrolled Goal Chat tools accept `action=inspect` with `binding_id`. Existing native thread schemas remain unchanged. Owners can use **Team execution** directly below the Goal conversation @@ -878,7 +880,7 @@ acceptance, canonical completion and current file bytes are checked again. Changed or unavailable evidence clears the prior content. These are on-demand observations, not continuous liveness; accepted output does not prove requester adoption. Text is rendered inertly, and source/version identifiers remain -inspectable. Returning preserves the execution list and keyboard focus. +inspectable. Returning rereads the current execution page and restores keyboard focus. Configured Goal conversations also expose **Team results** in the main view. Select an accepted artifact to recheck its exact operation, reference and hash @@ -931,16 +933,33 @@ operator entrypoint does not grant a Lark audience access. 中文:配置原有执行绑定后,在 Goal 对话的「团队执行情况」中选择原执行的 「查看证据与反馈」,直接读取经当前验收、绑定和文件核验的产物正文。文件变化或 读取失败时清除旧内容;这是按需观察,验收通过不代表协调员已采用。来源和版本标识 -可展开查看,返回列表保留位置与键盘焦点。协调员运行时,可把执行标识、看到的 +可展开查看,打开关联证据后可逐级返回;返回执行列表会重读当前页,保留分页与 +键盘焦点。产物失效时撤回原先的验收计数;列表读取失败会清除旧记录,可重新核验 +恢复。协调员运行时,可把执行标识、看到的 产物哈希和反馈投递到原收件箱;等待投递、已交付和已应用不能混为一谈。不确定响应 后重试同一消息和标识,避免重复投递。面板内的「暂停协调员」显示实际反馈,但不会 停止已派发成员,也不宣称整个团队停止。要停止某个成员,显式使用 `delegation stop --execute` 或 `stop_delegation` 并阅读其回执。暂停时仍可检查证据;读取不启动模型。 -Screenshots use isolated synthetic research data, not a live-model qualification: -[desktop evidence](../assets/personal-workspace/team-evidence-desktop.png), -[mobile evidence](../assets/personal-workspace/team-evidence-mobile.png), and -[stale evidence](../assets/personal-workspace/team-evidence-stale.png). +Linked evidence has a stepwise back action and a separate exit to the execution list. Returning to that list now reads its current page once: a changed output loses its accepted count, and an unavailable inventory clears earlier rows instead of replaying cached success. The current page and keyboard focus are retained. Refresh recovers after a failed read; no additional work or model is launched. + +A lost downstream result or revoked adoption leaves the freshly verified original/response/revision readable and marks adoption unavailable. Select **Verify linked work** again after recovery; adoption returns only when the current receipt and exact consumer identity, input and output agree. A lost core version still clears the correction trace. + +Expand **Current validation basis** in the existing evidence reader to inspect the source, definition digest, check count and file-pin count from this read. The additive `validation` object on `delegate read` contains `source`, `basis_sha256`, `check_count` and `pinned_file_count`; its source reuses `goal_acceptance` or `todo_validation`. The digest binds the current canonical requirements and selected validation effects. It does not export their commands, paths or labels, and it is neither a stored success receipt nor verifier identity. A read still reruns the original checks and requires the exact stored output versions. Rule-file drift or validation failure withdraws the report and basis; restoration needs an explicit recheck. Older runtimes remain readable with the basis identity marked unavailable. + +A revision can have current task acceptance and valid requester adoption while independent-verifier evidence is missing. These are distinct facts. The correction path explicitly says **Independent verification · evidence not provided**; neither `responds_to`, a reviewer's name nor a successful validator is an exact-version independent-verifier receipt. + +The following views use the packaged frontend with an isolated production SQLite/HTTP/CLI fixture. They contain synthetic data and do not qualify a live-model correction or the installed native App. The desktop view exposes the missing verifier beside valid adoption; the mobile view shows the same gap in the scrollable correction path. The stale view shows acceptance withdrawn after changed output: + +![Packaged correction evidence: current validation basis and the independent-verifier gap](../assets/personal-workspace/team-evidence-desktop.png) + +![390px correction evidence with the missing-verifier state](../assets/personal-workspace/team-evidence-mobile.png) + +![Current execution list withdraws acceptance for changed output](../assets/personal-workspace/team-evidence-stale.png) + +![Downstream loss preserves the current correction and marks adoption unavailable](../assets/personal-workspace/team-adoption-unavailable.png) + +中文:证据详情可展开“本次验收依据”,查看当前规则来源、定义摘要、检查与文件固定项数;不暴露命令、路径或私有标签,也不代表独立验收者。规则文件变化或验收失败清除产物与依据,恢复后显式重读;旧运行时明确标为依据未提供。返回执行列表现在单次重读当前页,保留分页与键盘焦点;产物变化撤回验收,列表失联清除旧行,可刷新恢复,不启动额外工作。任务接受有效和请求方采用有效,仍不能证明独立验收者验证了准确版本。纠偏路径对此明确留缺口。后续结果失联或采用撤回不会抹去当前仍有效的纠偏证据;原地重新核验可恢复准确版本的采用,核心来源失效则仍清除路径。图中均为隔离 production SQLite/HTTP/CLI 与打包前端的合成数据,不作为真实模型纠偏或已安装 Native App 验收。 ## Use the same bindings through MCP diff --git a/examples/control_plane/refresh-state-write-correctness-smoke.py b/examples/control_plane/refresh-state-write-correctness-smoke.py index fd7bb3ae6a..74beee06b0 100644 --- a/examples/control_plane/refresh-state-write-correctness-smoke.py +++ b/examples/control_plane/refresh-state-write-correctness-smoke.py @@ -76,7 +76,7 @@ def dry_run_payload(registry_path: Path, runtime: Path, project: Path) -> dict: state_file=None, classification="state_refreshed", recommended_action="preview refresh-state correctness packet", - next_action="Review the dry-run packet before writing local state.", + next_action=None, delivery_batch_scale="single_surface", delivery_outcome="surface_only", dry_run=True, @@ -90,8 +90,13 @@ def main() -> None: state_refresh.now_local = lambda: GENERATED_AT with tempfile.TemporaryDirectory(prefix="loopx-refresh-write-correctness-") as raw_tmp: registry_path, runtime, project = write_fixture(Path(raw_tmp)) + state_path = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + state_before, registry_before = state_path.read_bytes(), registry_path.read_bytes() first = dry_run_payload(registry_path, runtime, project) second = dry_run_payload(registry_path, runtime, project) + assert state_path.read_bytes() == state_before + assert registry_path.read_bytes() == registry_before + assert not runtime.exists() assert first["dry_run"] is True, first assert first["appended"] is False, first @@ -120,7 +125,8 @@ def main() -> None: preview = packet["preview"] assert preview["mode"] == "dry_run", packet assert preview["non_destructive"] is True, packet - assert preview["expected_write_scopes"] == ["active_state", "runtime_history"], packet + # A history-only refresh does not rewrite the shared Next Action. + assert preview["expected_write_scopes"] == ["runtime_history"], packet assert "append refresh-state run" in preview["patch_summary"], packet assert packet["lock_boundary"]["kind"] == "per_goal", packet diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 538fbe316f..8b2a9a110a 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -40,6 +40,7 @@ import { automationCadenceScenario } from "./personal-workspace-browser/automati import { turnStepsScenario } from "./personal-workspace-browser/turn-steps.mjs"; import { monitorReadbackScenario } from "./personal-workspace-browser/monitor-readback.mjs"; import { teamEvidenceScenario } from "./personal-workspace-browser/team-evidence.mjs"; +import { taskInspectorReturnScenario } from "./personal-workspace-browser/task-inspector-return.mjs"; import { managedGoalResultsScenario } from "./personal-workspace-browser/managed-goal-results.mjs"; import { loopxModeScenario } from "./personal-workspace-browser/loopx-mode.mjs"; import { progressiveLoadingScenario } from "./personal-workspace-browser/progressive-loading.mjs"; @@ -80,6 +81,7 @@ scenarioCatalog.push(nativeChildActivityScenario); scenarioCatalog.push(externalEvidenceReadbackScenario); scenarioCatalog.push(configurationBackupScenario); scenarioCatalog.push(prReviewAgentOrderScenario); +scenarioCatalog.push(taskInspectorReturnScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 7326fa376d..12716f103e 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -479,6 +479,8 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true }, operatorCredentialWrites: [], turnRequests: [], + todoRequestTexts: new Map(), + todoRequestReads: [], decidedGateTodoIds: new Set(), hostThreadActivity: {}, answerForMessage: null, @@ -728,6 +730,15 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true }, ); } + for (const goal of fixture.attention_queue.items) { + for (const todo of goal.agent_todos?.items ?? []) { + if (todo.todo_id) { + const key = JSON.stringify([goal.goal_id, todo.todo_id]); + if (!state.todoRequestTexts.has(key)) state.todoRequestTexts.set(key, todo.text); + todo.text = state.todoRequestTexts.get(key).slice(0, 500); + } + } + } for (const [goalId, activity] of Object.entries(state.hostThreadActivity)) { const goal = fixture.run_history.goals.find((item) => item.id === goalId); if (goal) goal.host_thread_activity = activity; @@ -900,6 +911,16 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true await page.route("**/api/chat/**", async (route) => { const request = route.request(); const url = new URL(request.url()); + if (url.pathname === "/api/chat/todo/detail") { + const goalId = url.searchParams.get("goal_id"); + const todoId = url.searchParams.get("todo_id"); + state.todoRequestReads.push({ goalId, todoId }); + const text = state.todoRequestTexts.get(JSON.stringify([goalId, todoId])); + await route.fulfill({ json: text === undefined ? { ok: false, error: "Task not found" } : { + ok: true, goal_id: goalId, todo_id: todoId, text, status: "open", archive_state: "active", updated_at: null, + } }); + return; + } if (url.pathname === "/api/chat/completed-todos") { const total = url.searchParams.get("goal_id") === "progress-projection" ? 4087 : 0; const offset = Number(url.searchParams.get("cursor") || 0); @@ -913,6 +934,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true completion_validation_revision_history: index === 0 ? [{ revision: 3, previous_declaration_sha256: "b".repeat(64), declaration_sha256: "a".repeat(64), actor_agent_id: "example-reviewer", revised_at: "2026-08-01T00:00:00Z" }] : [], }; }); + for (const item of items) state.todoRequestTexts.set(JSON.stringify([url.searchParams.get("goal_id"), item.todo_id]), item.text); await route.fulfill({ json: { ok: true, total, items, next_cursor: offset + 40 < total ? String(offset + 40) : null } }); return; } diff --git a/examples/personal-workspace-browser/task-inspector-return.mjs b/examples/personal-workspace-browser/task-inspector-return.mjs new file mode 100644 index 0000000000..f637c8b6fd --- /dev/null +++ b/examples/personal-workspace-browser/task-inspector-return.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +export const taskInspectorReturnScenario = { + id: "task-inspector-return", + async run({ browser, collectCoverage, url }) { + const coverageEntries = []; + const tail = " FINAL_ACCEPTANCE: every requirement is visible; stale or unavailable reads remain explicit."; + const prefix = "[P0] Full queue follow-up. "; + const request = prefix + "A".repeat(988 - prefix.length - tail.length) + tail; + for (const width of [1512, 390]) { + const context = await openWorkspacePage(browser, url, { + collectCoverage, viewport: { width, height: 844 }, + beforeGoto(api) { + api.todoRequestTexts.set(JSON.stringify(["progress-projection", "todo-progress-full"]), request); + }, + }); + const { page, api } = context; + try { + await page.emulateMedia({ reducedMotion: "reduce" }); + const menu = page.locator(".personal-mobile-menu"); + if (await menu.isVisible()) await menu.click(); + await page.locator(".personal-goal-link", { hasText: "Progress Projection" }).click(); + const card = page.locator(".personal-task-card", { hasText: "Current Todo" }); + const opener = card.locator(":scope > button"); + await opener.waitFor(); + assert.match(await opener.innerText(), /未分配/, "An unclaimed task must never inherit a Goal or execution owner"); + const drawer = page.getByRole("dialog", { name: "Todo 详情" }); + + // WebKit on macOS can leave focus on the preceding control after a + // pointer click. Preserve that condition independently of the browser + // engine, so Chromium CI also exercises the installed App's failure. + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Tasks|任务)$/ }).focus(); + await opener.evaluate(element => element.addEventListener("mousedown", event => event.preventDefault())); + await opener.click(); + await drawer.waitFor(); + await drawer.locator("dl > div", { has: page.getByText("Owner", { exact: true }) }).getByText("未分配", { exact: true }).waitFor(); + await page.keyboard.press("Escape"); + await drawer.waitFor({ state: "hidden" }); + await page.waitForFunction(() => document.activeElement?.closest(".personal-task-card")?.textContent.includes("Current Todo")); + assert.equal(await opener.evaluate(element => document.activeElement === element), true, "Escape must return to the pointer-opened Task"); + await page.keyboard.press("Enter"); + await drawer.waitFor(); + await drawer.getByRole("button", { name: /关闭详情/ }).click(); + await drawer.waitFor({ state: "hidden" }); + await page.waitForFunction(() => document.activeElement?.closest(".personal-task-card")?.textContent.includes("Current Todo")); + + const more = card.locator(".personal-task-card-actions > button").last(); + await more.evaluate(element => element.addEventListener("mousedown", event => event.preventDefault())); + await more.click(); + await drawer.waitFor(); + await page.keyboard.press("Escape"); + await drawer.waitFor({ state: "hidden" }); + await page.waitForFunction(() => document.activeElement?.classList.contains("personal-task-card-actions") || document.activeElement?.parentElement?.classList.contains("personal-task-card-actions")); + assert.equal(await more.evaluate(element => document.activeElement === element), true, "The actions opener retains its own keyboard context"); + + const longOpener = page.locator(".personal-task-card", { hasText: "Idless long Todo" }).locator(":scope > button"); + assert.ok((await longOpener.innerText()).length < 150, "The card keeps its bounded preview"); + await longOpener.click(); + await drawer.waitFor(); + assert.match(await drawer.locator(".personal-task-inspector-summary h3").innerText(), /keeps one card$/, "Opening a Task must retain its original requirements beyond the card preview"); + await page.keyboard.press("Escape"); + await drawer.waitFor({ state: "hidden" }); + await page.waitForFunction(() => document.activeElement?.closest(".personal-task-card")?.textContent.includes("Idless long Todo")); + assert.equal(await longOpener.evaluate(element => document.activeElement === element), true); + + const fullOpener = page.locator(".personal-task-card", { hasText: "Full queue follow-up" }).locator(":scope > button"); + const body = drawer.locator(".personal-task-inspector-summary h3"); + const waitForFull = () => page.waitForFunction(text => document.querySelector(".personal-task-inspector-summary h3")?.textContent === text, request); + await fullOpener.click(); + await waitForFull(); + assert.equal(await body.innerText(), request, "A cold read restores every one of the 988 source characters beyond the 500-character status projection"); + await page.screenshot({ path: resolve(outputDir, `task-request-full-${width}.png`), animations: "disabled" }); + await page.keyboard.press("Escape"); + await drawer.waitFor({ state: "hidden" }); + + // A failed or mismatched read must discard an earlier successful body, + // preserve the received summary, and offer a real retry in the drawer. + let nextRead = "failed"; + let releaseLate; + let lateFinished; + await page.route("**/api/chat/todo/detail?*", async route => { + if (new URL(route.request().url()).searchParams.get("todo_id") !== "todo-progress-full" || !nextRead) return route.fallback(); + const outcome = nextRead; + nextRead = null; + if (outcome === "late") { + await new Promise(resolveWait => { releaseLate = resolveWait; }); + await route.fulfill({ json: { ok: true, goal_id: "progress-projection", todo_id: "todo-progress-full", text: request, status: "open", archive_state: "active", updated_at: null } }); + lateFinished(); + return; + } + await route.fulfill({ json: outcome === "failed" ? { ok: false, error: "Source unavailable" } : { + ok: true, goal_id: "another-goal", todo_id: "todo-progress-full", text: request, status: "open", archive_state: "active", updated_at: null, + } }); + }); + for (const outcome of ["failed", "mismatched"]) { + nextRead = outcome; + await fullOpener.click(); + const retry = drawer.getByRole("button", { name: "重试读取完整要求" }); + await retry.waitFor(); + assert.equal((await body.innerText()).length, 500); + assert.ok(!(await body.innerText()).includes("FINAL_ACCEPTANCE"), "A previous successful body is never reused after a failed exact read"); + await retry.click(); + await waitForFull(); + await page.keyboard.press("Escape"); + await drawer.waitFor({ state: "hidden" }); + } + nextRead = "late"; + const finished = new Promise(resolveWait => { lateFinished = resolveWait; }); + await fullOpener.click(); + await drawer.getByRole("status").waitFor(); + await page.keyboard.press("Escape"); + await drawer.waitFor({ state: "hidden" }); + await opener.click(); + await page.waitForFunction(() => document.querySelector(".personal-task-inspector-summary h3")?.textContent === "Current Todo"); + releaseLate(); + await finished; + assert.equal(await body.innerText(), "Current Todo", "A late response from the closed Task cannot overwrite the next selection"); + await page.keyboard.press("Escape"); + await drawer.waitFor({ state: "hidden" }); + assert.ok(api.todoRequestReads.every(read => read.todoId), "A display-only legacy identity must never be sent as an authority id"); + assert.equal(api.turnRequests.length, 0, "Inspecting Tasks never starts model work"); + assert.equal(api.actionApplies.length, 0, "Focus recovery never changes Todo authority"); + assert.equal(context.errors.length, 0, context.errors.join(" | ")); + await page.screenshot({ path: resolve(outputDir, `task-inspector-return-${width}.png`), animations: "disabled" }); + } finally { + coverageEntries.push(...await context.close()); + } + } + return { coverageEntries, note: "The 988-character source survives a bounded 500-character status; failed/mismatched reads, retry and late selection responses are explicit on packaged desktop/390px. Pointer/Escape/Enter/close/More retain the opener with reduced motion; no authority or model write. Backend authority is qualified separately by the real File/SQLite HTTP+CLI tests." }; + }, +}; diff --git a/loopx/chat_server.py b/loopx/chat_server.py index dc898da8df..e89fb95f17 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -91,6 +91,7 @@ ) from .history import load_registry from .chat_completed_todos import CompletedTodoPages, CompletedTodoRequestMixin +from .chat_todo_detail import TodoDetailRequestMixin from .kiro_cli_goal_mode import KIRO_CLI_BIN from .paths import resolve_runtime_root from .release_manifest import release_runtime_identity @@ -449,6 +450,7 @@ def server_close(self) -> None: class ChatRequestHandler( PrivateConversationRequestMixin, CompletedTodoRequestMixin, + TodoDetailRequestMixin, AttachedSessionRequestMixin, SshSourceRequestMixin, GoalSubagentConfigurationRequestMixin, @@ -1421,6 +1423,7 @@ def do_GET(self) -> None: } ) get_dispatch = { + "/api/chat/todo/detail": self._todo_detail, "/api/chat/completed-todos": self._completed_todos, "/api/chat/goal-results": self._goal_results, CHAT_SESSIONS_PATH: self._list_sessions, diff --git a/loopx/chat_todo_detail.py b/loopx/chat_todo_detail.py new file mode 100644 index 0000000000..34b8becc66 --- /dev/null +++ b/loopx/chat_todo_detail.py @@ -0,0 +1,46 @@ +"""Loopback-only exact Task request reads over the existing Todo owner.""" + +from urllib.parse import parse_qs, urlparse + +from .status_server import is_loopback_host +from .todos import list_goal_todos + + +class TodoDetailRequestMixin: + def _todo_detail(self) -> None: + if not is_loopback_host(str(self.server.server_address[0])): + self._send_error("Task requests require a loopback LoopX Chat server.", status=403) + return + if not self._require_loopback_origin(): + return + query = parse_qs(urlparse(self.path).query) + if any(len(query.get(key, [])) != 1 for key in ("goal_id", "todo_id")): + self._send_error("Choose one Goal and Task to read.", status=400) + return + goal_id, todo_id = query["goal_id"][0], query["todo_id"][0] + try: + if not self.server.registry_path.is_file(): + raise OSError("Task registry is unavailable") + self._registry_and_goal(goal_id) + payload = list_goal_todos( + registry_path=self.server.registry_path, goal_id=goal_id, + role="agent", todo_id=todo_id, + runtime_root_arg=self.server.runtime_root_override, + ) + if payload.get("ambiguous"): + self._send_error("The Task source is ambiguous. Refresh its Goal.", status=409) + return + item = payload.get("todo") + if item is None: + self._send_error("The Task is no longer available in this Goal.", status=404) + return + self._send_json({ + "ok": True, "goal_id": goal_id, "todo_id": item["todo_id"], + "text": item["text"], "status": item["status"], + "archive_state": item.get("archive_state", "active"), + "updated_at": item.get("updated_at"), + }) + except ValueError: + self._send_error("The Goal or Task source could not be verified.", status=400) + except (OSError, RuntimeError): + self._send_error("The Task request could not be read. Retry when its source is available.", status=503) diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py index 8659773ded..e5563fe80f 100644 --- a/loopx/collaboration_mcp.py +++ b/loopx/collaboration_mcp.py @@ -852,10 +852,10 @@ def _read_current(self, operation_id: str) -> dict: result["stop"] = {"stop_id": stop["stop_id"], "phase": stop["phase"]} if row["status"] == "accepted": # A saved receipt cannot hide an amended task, verifier or output. - artifacts = self._accepted(binding) - if artifacts != row["artifacts"]: + accepted = self._accepted(binding) + if accepted["artifacts"] != row["artifacts"]: raise ValueError("delegation output changed after completion") - result["artifacts"] = artifacts + result.update(accepted) if row.get("error"): result["error"] = row["error"] return result @@ -1323,7 +1323,7 @@ def _cli(self, binding: dict, *args: str, timeout: int = 60, def _validate(self, binding: dict) -> dict: return delegation_validation.validate(self, binding) - def _accepted(self, binding: dict) -> list[dict]: + def _accepted(self, binding: dict) -> dict: validation = self._validate(binding) if not validation["plan"]["canonical_done"]: raise ValueError("delegation requires current canonical completion") @@ -1345,7 +1345,7 @@ def _accepted(self, binding: dict) -> list[dict]: "text": content.decode("utf-8")}) if len(json.dumps(artifacts).encode()) > 64_000: raise ValueError("delegation aggregate return exceeds limit") - return artifacts + return {"artifacts": artifacts, "validation": validation["plan"]["observation"]} def execute(self, operation_id: str) -> None: path = self.path(operation_id) @@ -1808,7 +1808,7 @@ def _execute(self, path: Path, row: dict, binding: dict) -> None: delegation_results.require_dependencies( self, binding, delegation_results.operation_brief(self, row) ) - row["artifacts"] = self._accepted(binding) + row["artifacts"] = self._accepted(binding)["artifacts"] if not (_root(self.root) / "replies" / request_id / "conclusion.json").exists(): return_result( self.root, diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts index 257271bd7a..d1ca3c599d 100644 --- a/loopx/control_plane/collaboration/delegation.ts +++ b/loopx/control_plane/collaboration/delegation.ts @@ -55,8 +55,15 @@ export function delegationValidationPlan(params: JsonObject): JsonObject { "Todo without canonical validation authority cannot supply a declaration"); if (requirements === null) return unavailable("independent_delegation_validation_required"); } - return {todo_id: todo.todo_id, state: "ready", - source: requirements === null ? "todo_validation" : "goal_acceptance", + const source = requirements === null ? "todo_validation" : "goal_acceptance"; + // Definition identity, not a stored success or independent-verifier receipt. + // Host validation must pass before exposing this path-free current observation. + const observation = {source, + basis_sha256: canonicalAuthoritySha256({todo_id: todo.todo_id, requirements, effects}), + check_count: effects.length, + pinned_file_count: effects.reduce((count, effect) => count + + (Array.isArray(effect.validation_files) ? effect.validation_files.length : 0), 0)}; + return {todo_id: todo.todo_id, state: "ready", source, observation, effects, canonical_done: todo.done === true && todo.status === "done"}; } export function selectDelegationBinding(params: JsonObject): JsonObject { diff --git a/loopx/control_plane/coordination/task_lease_acquire.ts b/loopx/control_plane/coordination/task_lease_acquire.ts index 0f0ff5bca1..c0d98cb2db 100644 --- a/loopx/control_plane/coordination/task_lease_acquire.ts +++ b/loopx/control_plane/coordination/task_lease_acquire.ts @@ -81,7 +81,7 @@ export async function executeCanonicalTaskLeaseAcquire(store: AuthorityStore, ra if (decision.outcome === "rejected" || decision.outcome === "conflict") { return failed(decision.code, `canonical task lease acquire rejected: ${decision.code}`, { ...(decision.code === "write_scope_conflict" ? {recommended_action: - "Coordinate with the listed holders to narrow scopes. For isolated code edits, both holders may release and reacquire with --write-worktree; existing grants remain exclusive. Never take over a foreign lease or use this mode for shared runtime state."} : {}), + "Coordinate shared writes with the listed holders. For isolated code edits, use your own separate Git worktree and acquire with --write-worktree; file overlaps become integration advisories. A known same-checkout collision remains rejected. Never take over a foreign lease or use code-edit mode for shared runtime state."} : {}), handoff_mode: mode, expected_version: input.expected_version, actual_version: leaseVersion(facts.current), ...(facts.todo ? {todo_status: facts.todo.status, claimed_by: facts.todo.claimed_by, excluded_agents: [...facts.todo.excluded_agents]} : {}), ...(decision.conflict_indexes.length ? {conflicts: decision.conflict_indexes.map(i => facts.other_leases[i])} : {})}); diff --git a/loopx/control_plane/goals/acceptance_contract.ts b/loopx/control_plane/goals/acceptance_contract.ts index c4e8adc0ae..e6420f21e5 100644 --- a/loopx/control_plane/goals/acceptance_contract.ts +++ b/loopx/control_plane/goals/acceptance_contract.ts @@ -188,7 +188,7 @@ const NON_WORK_FIELDS = new Set([ "schema_version", "source_section", "index", "title", "priority", "status", "done", "archive_state", "claimed_by", "created_by", "last_actor_agent_id", "updated_at", "completed_at", "completion_turn_key", "completion_validation_sha256", "completion_recovery", "completion_continuation", "no_followup", "decision_outcome", - "completion_result", + "completion_result", "completion_receipt_id", "decision_scope_outcomes", "note", "evidence", "reason", "handoff_note", "resume_ready", "resume_monitor_generation", "last_checked_at", "result_hash", "consecutive_no_change", "material_change", "material_change_generation", "monitor_effect_id", @@ -196,13 +196,21 @@ const NON_WORK_FIELDS = new Set([ export function goalAcceptanceTodoDigest(todo: JsonObject): string { return canonicalAuthoritySha256(Object.fromEntries(Object.entries(todo).filter(([key]) => !NON_WORK_FIELDS.has(key)))); } +function acceptanceDigestMatches(todo: JsonObject, boundDigest: string): boolean { + if (goalAcceptanceTodoDigest(todo) === boundDigest) return true; + // Before completion checkpoints were classified as observations, an owner + // could confirm work that already contained one. Preserve that exact binding. + return Object.hasOwn(todo, "completion_receipt_id") && + canonicalAuthoritySha256(Object.fromEntries(Object.entries(todo).filter(([key]) => + !NON_WORK_FIELDS.has(key) || key === "completion_receipt_id"))) === boundDigest; +} /** Existing owner bindings persist the v0 digest, including fields later used * for validator revision bookkeeping and successor links. Keep that digest * format so previously ready bindings stay ready. When it differs, check only * historical states that the current append-only metadata can reconstruct; * changing the Todo's work declaration still requires owner confirmation. */ function acceptanceBindingMatches(todo: JsonObject, boundDigest: string): boolean { - if (goalAcceptanceTodoDigest(todo) === boundDigest) return true; + if (acceptanceDigestMatches(todo, boundDigest)) return true; // Adding a wait condition changes when existing work can resume, not which // owner-confirmed Goal criterion it serves. Only the absent -> present case @@ -244,7 +252,7 @@ function acceptanceBindingMatches(todo: JsonObject, boundDigest: string): boolea successorVariants.push(withoutSuccessors); } for (const successorVariant of successorVariants) { - if (successorVariant !== todo && goalAcceptanceTodoDigest(successorVariant) === boundDigest) return true; + if (successorVariant !== todo && acceptanceDigestMatches(successorVariant, boundDigest)) return true; for (const priorRevision of revisionPrefixes) { const previous: JsonObject = {...successorVariant, completion_validation_revision: priorRevision, completion_validation_revision_history: history.slice(0, priorRevision)}; @@ -253,14 +261,14 @@ function acceptanceBindingMatches(todo: JsonObject, boundDigest: string): boolea delete previous.completion_validation_revision; delete previous.completion_validation_revision_history; Object.assign(previous, history[0].previous_validation_authority); - if (goalAcceptanceTodoDigest(previous) === boundDigest) return true; + if (acceptanceDigestMatches(previous, boundDigest)) return true; continue; } - if (goalAcceptanceTodoDigest(previous) === boundDigest) return true; + if (acceptanceDigestMatches(previous, boundDigest)) return true; if (priorRevision === 0) { delete previous.completion_validation_revision; delete previous.completion_validation_revision_history; - if (goalAcceptanceTodoDigest(previous) === boundDigest) return true; + if (acceptanceDigestMatches(previous, boundDigest)) return true; } } } diff --git a/loopx/control_plane/todos/list_readback.py b/loopx/control_plane/todos/list_readback.py index cc2b6891a1..c0a03e9073 100644 --- a/loopx/control_plane/todos/list_readback.py +++ b/loopx/control_plane/todos/list_readback.py @@ -148,6 +148,24 @@ def list_goal_todos( source = projected.source summaries = projected.summaries todos = projected.todos + # Exact cold reads restore source bytes after the shared summary owner has + # evaluated identity/status/guards. List and thin projections stay bounded; + # a hot summary is never treated as the original request. + if normalized_todo_id and not thin and len(todos) == 1: + if canonical_read is not None: + source_items = canonical_read["todos"] + else: + active, archived, _sections = parse_todo_source( + state_text, goal=goal, state_path=resolved_state_file, + ) + source_items = [*active["user"], *active["agent"], *archived] + detail = todos[0] + matches = [item for item in source_items + if item.get("todo_id") == normalized_todo_id + and item.get("role") == detail.get("role") + and item.get("archive_state", "active") == detail.get("archive_state", "active")] + if len(matches) == 1: + detail["text"] = str(matches[0].get("text") or "") unfiltered_count = projected.unfiltered_count uncapped_todo_count = projected.uncapped_todo_count diff --git a/loopx/control_plane/work_items/task_lease_acquire_decision.ts b/loopx/control_plane/work_items/task_lease_acquire_decision.ts index cf95e0c967..478d57e9ef 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -1,4 +1,4 @@ -import {leaseWorkspace, sameLeaseWorkspace, independentLeaseWorktrees, type LeaseWorkspace} from "./task_lease_workspace.ts"; +import {leaseWorkspace, sameLeaseWorkspace, sameLeaseCheckout, type LeaseWorkspace} from "./task_lease_workspace.ts"; /** Shared acquire/reclaim admission. IO and durable receipts belong to callers. */ import {leaseOwnerRejection as ownerRejection} from "./task_lease_eligibility.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; @@ -381,9 +381,14 @@ export function decideTaskLeaseAcquire(input: AcquireDecisionInput): AcquireDeci } const advisoryIndexes: number[] = [], conflictIndexes: number[] = []; for (const [index, other] of input.other_leases.entries()) { - if (!other.active || !other.effective || !repositoryScopesMayOverlap(repository, other.write_repository) || + if (!other.active || !other.effective) continue; + const sameCheckout = workspace !== null && sameLeaseCheckout(workspace, other.write_workspace); + if ((!sameCheckout && !repositoryScopesMayOverlap(repository, other.write_repository)) || !writeScopesOverlap(command.write_scopes, other.write_scopes)) continue; - if (independentLeaseWorktrees(workspace, other.write_workspace)) advisoryIndexes.push(index); + // Verified code-edit mode coordinates integration, rather than requesting + // cross-checkout file exclusivity. Unknown legacy workspaces stay unknown; + // neither their records nor their execution ownership are changed. + if (workspace && !sameCheckout) advisoryIndexes.push(index); else conflictIndexes.push(index); } if (conflictIndexes.length > 0) { diff --git a/loopx/control_plane/work_items/task_lease_workspace.ts b/loopx/control_plane/work_items/task_lease_workspace.ts index 0ea68f074f..da573f3f2a 100644 --- a/loopx/control_plane/work_items/task_lease_workspace.ts +++ b/loopx/control_plane/work_items/task_lease_workspace.ts @@ -39,12 +39,11 @@ export function sameLeaseWorkspace(left: unknown, right: unknown): boolean { a.common_directory === b.common_directory && a.worktree === b.worktree && a.repository === b.repository; } -/** Only positively observed sibling worktrees can turn overlap into an advisory. */ -export function independentLeaseWorktrees(left: unknown, right: unknown): boolean { +/** A known physical checkout collision still rejects cooperative code editing. */ +export function sameLeaseCheckout(left: unknown, right: unknown): boolean { const a = leaseWorkspace(left), b = leaseWorkspace(right); return a !== null && b !== null && a.host === b.host && - a.repository.toLowerCase() === b.repository.toLowerCase() && - a.common_directory === b.common_directory && a.worktree !== b.worktree; + a.worktree === b.worktree; } export async function observeLeaseWorktree(path: string, overlaps: (path: string) => boolean): Promise { diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 0f18d8bb9e..5de8d42322 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": 1002, + "line": 1004, "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": 529, + "line": 531, "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": 1572, + "line": 1575, "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": 1677, + "line": 1680, "column": 20, "kind": "codec_read", "api": "load_registry", diff --git a/tests/control_plane_ts/delegation.test.ts b/tests/control_plane_ts/delegation.test.ts index 50441b90ab..e29666e242 100644 --- a/tests/control_plane_ts/delegation.test.ts +++ b/tests/control_plane_ts/delegation.test.ts @@ -90,6 +90,33 @@ test("owner acceptance and ordinary Todo validation remain cumulative", () => { } }); +test("current validation provenance identifies definitions without exporting private commands", () => { + const args = {binding, basis: validationBasis, declaration}; + const plan = delegationValidationPlan(args); + const observed = plan.observation as Record; + assert.equal(observed.source, "todo_validation"); + assert.equal(observed.check_count, 1); + assert.equal(observed.pinned_file_count, 0); + assert.match(String(observed.basis_sha256), /^[a-f0-9]{64}$/); + assert.deepEqual(Object.keys(observed).sort(), ["basis_sha256", "check_count", "pinned_file_count", "source"]); + assert.deepEqual(delegationValidationPlan({...args, + basis: {...validationBasis, provider_revision: "fixture:2"}}).observation, observed); + const changed = {...declaration, validation_command_argv: ["node", "other-private-validator.ts"]}; + const changedPlan = delegationValidationPlan({...args, declaration: changed, basis: {...validationBasis, + todo: {...validationTodo, completion_validation_sha256: canonicalAuthoritySha256(changed)}}}); + assert.notEqual((changedPlan.observation as Record).basis_sha256, observed.basis_sha256); + assert.equal(delegationValidationPlan({...args, declaration: null}).observation, undefined); + const criteria = [{id: "review", description: "Private owner rule", validation_argv: ["node", "private-owner.ts"], + validation_timeout_seconds: 5, validation_files: [{path: "private-owner.ts", sha256: "a".repeat(64)}]}]; + const combined = delegationValidationPlan({...args, basis: {...validationBasis, + completion_requirements: {todo_id: binding.todo_id, criteria}}}).observation as Record; + assert.equal(combined.source, "goal_acceptance"); + assert.equal(combined.check_count, 2); + assert.equal(combined.pinned_file_count, 1); + assert.notEqual(combined.basis_sha256, observed.basis_sha256); + assert.doesNotMatch(JSON.stringify(combined), /private|Independent verification|validation_argv/); +}); + test("same explicit grant contract applies to a coordinator and an ordinary member", () => { assert.deepEqual(selectDelegationBinding(params), binding); assert.deepEqual(selectDelegationBinding({...params, agent_id: "analyst"}), binding); diff --git a/tests/control_plane_ts/goal_acceptance_authority.test.ts b/tests/control_plane_ts/goal_acceptance_authority.test.ts index 4ca3fafed6..459a28596d 100644 --- a/tests/control_plane_ts/goal_acceptance_authority.test.ts +++ b/tests/control_plane_ts/goal_acceptance_authority.test.ts @@ -52,11 +52,31 @@ function originalHead() { test("terminal continuation observations preserve work while changed requirements invalidate it", () => { const work = todo("todo_first"); const completed = {...work, status: "done", done: true, no_followup: true, - completion_continuation: "no_followup", note: "Bounded task completed"}; + completion_continuation: "no_followup", completion_receipt_id: `tcw_${"a".repeat(64)}`, + note: "Bounded task completed"}; assert.equal(goalAcceptanceTodoDigest(completed), goalAcceptanceTodoDigest(work)); assert.notEqual(goalAcceptanceTodoDigest({...completed, text: "Deliver different work"}), goalAcceptanceTodoDigest(work)); assert.notEqual(goalAcceptanceTodoDigest({...completed, completion_validation_required: true}), goalAcceptanceTodoDigest(work)); }); +test("owner bindings confirmed with a completion checkpoint retain their declared work", () => { + const receipt = `tcw_${"a".repeat(64)}`; + const work = todo("todo_first", {status: "done", done: true, completion_receipt_id: receipt}); + const contract = normalizeGoalAcceptanceDocument({...document(), + bindings: [{todo_id: "todo_first", criterion_ids: ["prerequisite"]}]}); + // This is the persisted pre-fix work shape, independently of the new digest. + const legacyDigest = canonicalAuthoritySha256({todo_id: "todo_first", role: "agent", + text: "Implement todo_first", task_class: "advancement_task", action_kind: "implement", + completion_receipt_id: receipt}); + const state = {schema_version: "loopx_goal_acceptance_v0", enabled: true, revision: 1, + digest: canonicalAuthoritySha256(contract), document: contract, verification: null, + bindings: [{todo_id: "todo_first", todo_semantic_digest: legacyDigest, + revision: 1, criterion_ids: ["prerequisite"], confirmed_by: "owner"}]}; + const guard = (target: JsonObject) => acceptanceWorkGuard( + authorityProjectionFixture(goal, [target], [], "native", {goal_acceptance: state}), goal, "todo_first"); + assert.equal(guard(work)?.state, "ready"); + for (const patch of [{text: "Different work"}, {required_capabilities: ["shell"]}, + {required_write_scopes: ["src"]}]) assert.equal(guard({...work, ...patch})?.state, "stale"); +}); test("validator revisions and successor links preserve an existing acceptance binding", () => { const original = todo("todo_first", {completion_validation_required: true, completion_validation_sha256: "a".repeat(64), completion_validation_revision: 0, diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index 054d86a98b..2bf98d0bd0 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -5,7 +5,7 @@ import {mkdtemp, rm} from "node:fs/promises"; import {platform, tmpdir} from "node:os"; import {join} from "node:path"; import {evaluateTaskLeaseAcquireDecision} from "../../loopx/control_plane/work_items/task_lease_acquire_decision.ts"; -import {leaseWorkspace, independentLeaseWorktrees, observeLeaseWorktree} from "../../loopx/control_plane/work_items/task_lease_workspace.ts"; +import {leaseWorkspace, sameLeaseCheckout, observeLeaseWorktree} from "../../loopx/control_plane/work_items/task_lease_workspace.ts"; const repo = "git:github.com/example/project"; const workspace = {host: "1".repeat(64), common_directory: "2".repeat(64), worktree: "3".repeat(64), repository: repo}; @@ -61,19 +61,41 @@ test("observed Git origins use the Todo repository identity contract", { function request(other: unknown = {...workspace, worktree: "4".repeat(64)}) { return {handoff_mode: "hard_lease", registered_agents: ["agent-a", "agent-b"], todo: {todo_id: "todo_a", status: "open", claimed_by: "agent-a", excluded_agents: [], task_repository: repo}, lease: null, - other_leases: [{todo_id: "todo_b", active: true, effective: true, write_repository: repo, write_scopes: ["src/**"], write_workspace: other}], + other_leases: [{todo_id: "todo_b", active: true, effective: true, write_repository: repo as string | null, write_scopes: ["src/**"], write_workspace: other}], command: {owner: "agent-a", idempotency_key: "work-a", ttl_seconds: 60, expected_version: 0, write_scopes: ["src/main.ts"], write_workspace: workspace}}; } -test("only verified sibling worktree overlaps become integration advisories", () => { +test("verified code-edit worktrees treat file overlaps as integration advisories", () => { const result = evaluateTaskLeaseAcquireDecision(request()); assert.equal(result.outcome, "apply"); assert.deepEqual(result.overlap_advisory_indexes, [0]); assert.deepEqual(result.next_lease?.write_workspace, workspace); - for (const other of [null, workspace, {...workspace, host: "5".repeat(64)}, {...workspace, common_directory: "6".repeat(64)}]) { + for (const other of [null, {...workspace, host: "5".repeat(64)}, + {...workspace, common_directory: "6".repeat(64), worktree: "7".repeat(64)}]) { + const input = request(other); + if (other === null) input.other_leases[0].write_repository = null; + const advisory = evaluateTaskLeaseAcquireDecision(input); + assert.equal(advisory.outcome, "apply"); + assert.deepEqual(advisory.overlap_advisory_indexes, [0]); + assert.deepEqual(input.other_leases[0].write_workspace, other); + } + for (const other of [workspace, {...workspace, common_directory: "6".repeat(64)}]) { assert.equal(evaluateTaskLeaseAcquireDecision(request(other)).code, "write_scope_conflict"); - assert.equal(independentLeaseWorktrees(workspace, other), false); + assert.equal(sameLeaseCheckout(workspace, other), true); + } +}); +test("ordinary exclusive grants retain overlap rejection without worktree mode", () => { + for (const other of [null, workspace]) { + const input = request(other); + const exclusive = {...input, command: {...input.command, write_workspace: null}}; + assert.equal(evaluateTaskLeaseAcquireDecision(exclusive).code, "write_scope_conflict"); } }); +test("a known same-checkout collision wins over changed repository metadata", () => { + const other = {...workspace, repository: "git:github.com/example/other"}; + const input = request(other); + input.other_leases[0].write_repository = other.repository; + assert.equal(evaluateTaskLeaseAcquireDecision(input).code, "write_scope_conflict"); +}); test("worktree identity cannot weaken Todo ownership or repository identity", () => { const input = request(); assert.equal(evaluateTaskLeaseAcquireDecision({...input, todo: {...input.todo, claimed_by: "agent-b"}}).outcome, "rejected"); @@ -99,14 +121,18 @@ test("PostgreSQL retains worktree identity and overlap receipts across reload", try { await installPostgreSqlAuthorityStoreSchema(database, `postgresql:${"b".repeat(32)}`); const store = new PostgreSqlAuthorityStore(database, options); - const todos = ["alpha", "beta"].map(key => ({todo_id: `todo_${key}`, role: "agent", status: "open", done: false, + const todos = ["alpha", "beta", "legacy"].map(key => ({todo_id: `todo_${key}`, role: "agent", status: "open", done: false, archive_state: "active", task_class: "advancement_task", text: "Independent code edits", claimed_by: "agent-a", task_repository: repo})); assert.equal((await store.commitAuthority({expected_provider_revision: null, operation_id: "seed", events: [], receipts: [], next_projection: authorityProjectionFixture(goal, todos, [], "native", {handoff_mode: "hard_lease"})})).status, "applied"); const command = {goal_id: goal, todo_id: "todo_alpha", owner: "agent-a", idempotency_key: "alpha", expected_version: 0, ttl_seconds: 600, write_scopes: ["src/**"], registered_agents: ["agent-a"], now: new Date(), write_workspace: workspace}; + const legacyCommand = {...command, todo_id: "todo_legacy", idempotency_key: "legacy", write_workspace: null}; + const legacy = await executeCanonicalTaskLeaseAcquire(store, legacyCommand); + assert.equal(legacy.status, "applied"); const first = await executeCanonicalTaskLeaseAcquire(store, command); assert.equal(first.status, "applied"); + assert.equal((first.integration_overlap_advisories as unknown[]).length, 1); const reopened = new PostgreSqlAuthorityStore(database, options); const replay = await executeCanonicalTaskLeaseAcquire(reopened, command); assert.deepEqual(replay.original_receipt, first.original_receipt); @@ -114,7 +140,10 @@ test("PostgreSQL retains worktree identity and overlap receipts across reload", const second = await executeCanonicalTaskLeaseAcquire(reopened, {...command, todo_id: "todo_beta", idempotency_key: "beta", write_workspace: {...workspace, worktree: "4".repeat(64)}}); assert.equal(second.status, "applied"); - assert.equal((second.integration_overlap_advisories as unknown[]).length, 1); + assert.equal((second.integration_overlap_advisories as unknown[]).length, 2); + const retained = await executeCanonicalTaskLeaseAcquire(reopened, legacyCommand); + assert.deepEqual(retained.original_receipt, legacy.original_receipt); + assert.deepEqual(retained.lease, legacy.lease); } finally { for (const table of ["authority_receipts", "authority_events", "authority_commits", "authority_heads"]) { await pool.query(`DELETE FROM loopx_control_plane.${table} WHERE tenant_id=$1 AND goal_id=$2`, [options.tenant_id, goal]); diff --git a/tests/test_chat_todo_detail.py b/tests/test_chat_todo_detail.py new file mode 100644 index 0000000000..b2e1c122fc --- /dev/null +++ b/tests/test_chat_todo_detail.py @@ -0,0 +1,95 @@ +"""Exact Task reading, from native CLI through the real loopback HTTP owner.""" + +import json +from pathlib import Path +import subprocess +import sys +import threading +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +import pytest + +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 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_exact_task_cli_http_preserve_full_current_and_retained_request(tmp_path, monkeypatch, provider): + monkeypatch.syspath_prepend(str(Path(__file__).parent / "control_plane")) + from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + + isolate_sqlite_runtime(tmp_path, monkeypatch) + tail = " FINAL_ACCEPTANCE: read every requirement and preserve the original opener." + text = "Inspect the complete current requirements. " + "A" * 880 + tail + records = [{ + "schema_version": "todo_item_v0", "todo_id": todo_id, "index": index + 1, + "role": "agent", "status": "done" if archived else "open", "done": archived, + "archive_state": "archive" if archived else "active", "text": text, + "source_section": "Completed Work Archive" if archived else "Agent Todo", + "claimed_by": "agent-a", "task_class": "advancement_task", "priority": "P0", + } for index, (todo_id, archived) in enumerate([("todo_current", False), ("todo_retained", True)])] + state, runtime, registry = tmp_path / "state.md", tmp_path / "runtime", tmp_path / "registry.json" + state.write_text("# Synthetic Goal\n\n## Agent Todo\n") + projection = build_todo_runtime_shadow_projection(goal_id="reading-goal", todos=records, handoff_mode="soft_claim") + initialize_canonical_authority(runtime, "reading-goal", projection, state_path=state, provider=provider) + # The authoritative reader must work without a stale display file. + state.unlink() + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [ + {"id": "reading-goal", "repo": str(tmp_path), "state_file": "state.md"}]})) + before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="reading-goal") + + def cli(*args): + process = subprocess.run([sys.executable, "-c", "from loopx.cli import main; raise SystemExit(main())", + "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", + "todo", "list", "--goal-id", "reading-goal", *args], capture_output=True, text=True, timeout=60) + assert process.returncode == 0, process.stdout + process.stderr + return json.loads(process.stdout) + + hot = cli() + assert len(hot["todos"]) == 1 and len(hot["todos"][0]["text"]) == 500 + assert tail not in hot["todos"][0]["text"] + for todo_id in ("todo_current", "todo_retained"): + exact = cli("--todo-id", todo_id) + assert exact["matched"] and exact["todo"]["text"] == text + assert tail in exact["todo"]["text"] + assert len(cli("--todo-id", todo_id, "--thin")["todo"]["text"]) <= 500 + + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path, server.runtime_root_override, server.verbose = registry, str(runtime), False + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + base = f"http://127.0.0.1:{server.server_port}/api/chat/todo/detail" + try: + for todo_id, archived in [("todo_current", False), ("todo_retained", True)]: + with urlopen(f"{base}?goal_id=reading-goal&todo_id={todo_id}") as response: + detail = json.load(response) + assert detail["goal_id"] == "reading-goal" and detail["todo_id"] == todo_id + assert detail["text"] == text and detail["archive_state"] == ("archive" if archived else "active") + assert "state_file" not in detail and "required_capabilities" not in detail and "authority_read" not in detail + for query, expected in [("goal_id=other-goal&todo_id=todo_current", 400), + ("goal_id=reading-goal&todo_id=todo_missing", 404), + ("goal_id=reading-goal&todo_id=../state.md", 400), + ("goal_id=reading-goal&todo_id=todo_current&todo_id=todo_retained", 400)]: + with pytest.raises(HTTPError) as failure: + urlopen(f"{base}?{query}") + assert failure.value.code == expected + with pytest.raises(HTTPError) as denied: + urlopen(Request(f"{base}?goal_id=reading-goal&todo_id=todo_current", headers={"Origin": "https://unrelated.example"})) + assert denied.value.code == 403 + saved = registry.with_suffix(".saved") + registry.rename(saved) + with pytest.raises(HTTPError) as unavailable: + urlopen(f"{base}?goal_id=reading-goal&todo_id=todo_current") + assert unavailable.value.code == 503 + saved.rename(registry) + with urlopen(f"{base}?goal_id=reading-goal&todo_id=todo_current") as response: + assert json.load(response)["text"] == text + after = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="reading-goal") + assert after["provider_revision"] == before["provider_revision"] and after["todos"] == before["todos"] + assert not state.exists() + finally: + server.shutdown() + server.server_close() + worker.join() diff --git a/tests/test_local_delegation.py b/tests/test_local_delegation.py index 81ba713e2a..a56836e474 100644 --- a/tests/test_local_delegation.py +++ b/tests/test_local_delegation.py @@ -391,6 +391,13 @@ async def disconnect_requester(): (root / "release").touch() result = wait(reconnected) assert result["status"] == "accepted", result + validation = result["validation"] + assert validation["source"] == "goal_acceptance" + # The fixture rule pins the validator, oracle module and source material. + assert validation["check_count"] == 1 and validation["pinned_file_count"] == 3 + assert len(validation["basis_sha256"]) == 64 + assert set(validation) == {"source", "check_count", "pinned_file_count", "basis_sha256"} + assert reconnected.read("analysis-1")["validation"] == validation assert (root / "analyst" / "initial" / "host-invocations").read_text() == "1" assert not (root / "analyst" / "initial" / "DELEGATION.json").exists() assert demo.canonical_tasks(root)["todo_analyst-initial"]["done"] diff --git a/tests/test_task_lease_worktree.py b/tests/test_task_lease_worktree.py index b9e067b0a5..37c652ad7b 100644 --- a/tests/test_task_lease_worktree.py +++ b/tests/test_task_lease_worktree.py @@ -45,7 +45,7 @@ def git(*args): "schema_version": "todo_item_v0", "todo_id": f"todo_worktree_{key}", "role": "agent", "status": "open", "done": False, "text": "Isolated code editing", "archive_state": "active", "source_section": "Agent Todo", "index": i, "task_class": "advancement_task", "claimed_by": owner, "task_repository": repository, - } for i, (key, owner) in enumerate([("a", "agent-a"), ("b", "agent-b"), ("c", "agent-b"), ("d", "agent-b")], 1)]) + } for i, (key, owner) in enumerate([("a", "agent-a"), ("b", "agent-b"), ("c", "agent-b"), ("d", "agent-b"), ("e", "agent-b")], 1)]) initialize_canonical_authority(runtime, goal, projection, state_path=state, provider=provider) state.unlink() @@ -81,6 +81,8 @@ def acquire(key, path, expected=0, scope="src/**"): (a / "src" / "redirect").unlink() # An unrelated ignored link must not prevent a narrow code-edit lease. (a / "outside").symlink_to(project, target_is_directory=True) + legacy = acquire("d", None) + assert "write_workspace" not in legacy["lease"] monkeypatch.chdir(a) git("remote", "set-url", "origin", "ssh://git@github.com:2223/example/project.git") assert acquire("a", a, expected=1)["error_code"] == "lease_workspace_repository_mismatch" @@ -90,14 +92,16 @@ def acquire(key, path, expected=0, scope="src/**"): assert "write_workspace" in first["lease"] assert first["lease"]["write_repository"] == repository assert first["lease"]["write_workspace"]["repository"] == repository + assert first["integration_overlap_advisories"][0]["todo_id"] == "todo_worktree_d" + assert cli("inspect", "d")["lease"] == legacy["lease"] assert str(tmp_path) not in json.dumps(first["lease"]) conflict = acquire("c", alias, expected=1) assert conflict["error_code"] == "write_scope_conflict" assert conflict["conflicts"][0]["owner"] == "agent-a" assert "--write-worktree" in conflict["recommended_action"] - assert acquire("d", None, expected=1)["error_code"] == "write_scope_conflict" + assert acquire("e", None, expected=1)["error_code"] == "write_scope_conflict" second = acquire("b", Path("../b")) - assert second["integration_overlap_advisories"][0]["todo_id"] == "todo_worktree_a" + assert {row["todo_id"] for row in second["integration_overlap_advisories"]} == {"todo_worktree_a", "todo_worktree_d"} assert second["lease"]["write_workspace"] != first["lease"]["write_workspace"] replay = acquire("a", alias) assert replay["original_receipt"] == first["original_receipt"] @@ -110,4 +114,5 @@ def acquire(key, path, expected=0, scope="src/**"): assert cli("inspect", "a")["lease"] == renewal["lease"] released = cli("release", "a", "--owner", "agent-a", "--idempotency-key", "edit-a", "--expected-version", "2") assert released["released"] + assert cli("inspect", "d")["lease"] == legacy["lease"] assert not state.exists()