diff --git a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs index 8932cd50a3..40df8de4ba 100644 --- a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs +++ b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs @@ -30,7 +30,11 @@ try { await page.getByRole("button", {name: "团队执行情况", exact: true}).click(); const dialog = page.getByRole("dialog", {name: "团队执行情况"}); const openEvidence = dialog.getByRole("button", {name: "查看证据与反馈", exact: true}); + const firstRead = page.waitForResponse(response => response.request().method() === "POST" + && response.request().postDataJSON()?.operation === "read" + && response.request().postDataJSON()?.operation_id === "accepted-analysis"); await openEvidence.first().click(); + const legacyReadback = await (await firstRead).json(); const evidence = dialog.getByRole("region", {name: "执行证据"}); const original = () => evidence.getByLabel("证据内容: report.json"); await original().waitFor(); @@ -43,6 +47,67 @@ try { // Older readbacks remain readable without invented provenance. await evidence.getByText("本次验收依据", {exact: true}).click(); await evidence.getByText("此运行时未提供验收依据标识。", {exact: true}).waitFor(); + // Transport fixtures exercise presentation only; real File/SQLite checks are + // qualified by test_local_delegation, never inferred from these synthetic rows. + const checkTime = "2026-01-02T03:04:05.123456Z"; + const outputVersions = [{ref: "report.json", sha256: "d".repeat(64)}, {ref: "report.md", sha256: "9".repeat(64)}]; + const currentValidation = {source: "goal_acceptance", basis_sha256: "7".repeat(64), check_count: 1, + pinned_file_count: 3, checked_at: checkTime, output_versions: outputVersions}; + let validation = {...currentValidation, checked_at: undefined, output_versions: undefined}; + let checkUnavailable = false; + const checkedRead = route => { + const body = route.request().postDataJSON(); + if (body?.operation !== "read" || body.operation_id !== "accepted-analysis") return route.fallback(); + api.loopxModeRequests.push({sessionId: configured.sessionId, ...body}); + return checkUnavailable ? route.fulfill({status: 409, json: {error: "delegation output changed during validation"}}) + : route.fulfill({json: {...legacyReadback, validation}}); + }; + await page.route("**/api/chat/sessions/*/loopx", checkedRead); + const recheck = evidence.getByRole("button", {name: "重新读取证据", exact: true}); + await recheck.click(); + await original().waitFor(); + await evidence.getByText("本次验收依据", {exact: true}).click(); + await evidence.getByText("未提供与当前产物匹配的检查时间及版本记录。", {exact: true}).waitFor(); + assert.equal(await evidence.locator("time").count(), 0, "A legacy basis does not invent check records"); + validation = currentValidation; + await recheck.click(); + await original().waitFor(); + await evidence.getByText("本次验收依据", {exact: true}).click(); + const checkObservation = evidence.locator("details").filter({has: page.getByText("本次验收依据", {exact: true})}); + await checkObservation.getByText(checkTime, {exact: true}).waitFor(); + for (const version of outputVersions) await checkObservation.getByText(version.sha256, {exact: true}).waitFor(); + assert.equal(await checkObservation.getByText("未提供与当前产物匹配的检查时间及版本记录。", {exact: true}).count(), 0); + await mkdir(outputDir, {recursive: true}); + await checkObservation.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-checked-output-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 checkObservation.getByText(outputVersions[1].sha256, {exact: true}).scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-checked-output-mobile.png"), animations: "disabled"}); + await page.setViewportSize({width: 1512, height: 980}); + // Another version's witness must never appear as a check of this report. + validation = {...validation, output_versions: [{ref: "report.json", sha256: "f".repeat(64)}, outputVersions[1]]}; + await recheck.click(); + await original().waitFor(); + await evidence.getByText("本次验收依据", {exact: true}).click(); + await evidence.getByText("未提供与当前产物匹配的检查时间及版本记录。", {exact: true}).waitFor(); + assert.equal(await checkObservation.locator("time").count(), 0); + assert.equal(await checkObservation.getByText("f".repeat(64), {exact: true}).count(), 0); + checkUnavailable = true; + await recheck.click(); + await evidence.getByRole("alert").filter({hasText: "已清除上次证据"}).waitFor(); + assert.equal(await original().count(), 0); + assert.equal(await evidence.getByText("本次验收依据", {exact: true}).count(), 0); + await page.screenshot({path: resolve(outputDir, "team-checked-output-unavailable.png"), animations: "disabled"}); + checkUnavailable = false; + validation = {...validation, output_versions: outputVersions}; + await recheck.focus(); + await page.keyboard.press("Enter"); + await original().waitFor(); + await evidence.getByText("本次验收依据", {exact: true}).click(); + await checkObservation.getByText(checkTime, {exact: true}).waitFor(); + await page.unroute("**/api/chat/sessions/*/loopx", checkedRead); await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); const verificationGap = evidence.getByText("当前读回未提供独立验收者与指定版本回执。", {exact: true}); await verificationGap.waitFor({timeout: 3000}); @@ -189,7 +254,7 @@ try { 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)"); + console.log("team-evidence-return: passed (packaged checked outputs, navigation, downstream loss/revocation/restoration, core loss, keyboard return, fresh evidence/list, pagination, mobile and no execution)"); } finally { await workspace?.close(); await browser?.close(); diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 25f5d7ab15..e371dc8cd5 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1144,7 +1144,8 @@ export type DelegationReadback = { 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}; + check_count: number; pinned_file_count: number; checked_at?: string; + output_versions?: Array<{ref: string; sha256: string}>}; 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 564a847832..6d4e20b0ad 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -618,6 +618,11 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention

{t("drawer.taskReadbackUnavailable")}

+ ) : selection.item.detailMode === "request_only" ? ( +
+

{t("drawer.taskRequestOnly")}

+ +
) : ( <>
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx index 2a0bd368be..05ef4f5d45 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx @@ -121,9 +121,10 @@ export function DeliveryReview({ goal, items, userTodos, onSelect, active }: Del const item = scopedAttention.find(todo => todo.todoId === id); return item ? { kind: "attention", item } : null; } - // History outside the status window still opens with the projected facts only. + // The status window is bounded; its absence cannot revoke exact request reading. const todo = goal.agentTodos.find(item => item.todoId === id) ?? { - todoId: id, text: node.title, done: node.state === "done", claimedBy: node.owner_agent ?? null, + todoId: id, sourceTodoId: id, detailMode: "request_only" as const, + text: node.title, done: node.state === "done", claimedBy: node.owner_agent ?? null, status: node.state === "waiting" ? "deferred" : node.state === "blocked" || node.state === "done" ? node.state : "open", taskDomain: node.task_domain ?? null, taskClass: node.kind === "monitor" ? "continuous_monitor" : 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 18f4ed3f30..c6081c0ee9 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 @@ -58,6 +58,13 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres : (zh ? "投递状态待核实" : "Delivery requires reconciliation"); const hasCorrectionPath = Boolean(result?.dependencies?.some(link => link.relation === "revises") && result?.dependencies?.some(link => link.relation === "responds_to")); + // Defensive transport readback: never present a witness for another report version. + const checkedOutputs = result?.validation?.output_versions; + const checkedAt = result?.validation?.checked_at; + const hasCheckedOutputs = Boolean(checkedAt && Number.isFinite(Date.parse(checkedAt)) + && checkedOutputs?.length && checkedOutputs.length === result?.artifacts?.length + && new Set(checkedOutputs.map(row => row.ref)).size === checkedOutputs.length + && checkedOutputs.every(row => result?.artifacts?.some(artifact => artifact.ref === row.ref && artifact.sha256 === row.sha256))); return

{zh ? "执行证据" : "Execution evidence"}

@@ -84,6 +91,11 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres {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."}

+ {hasCheckedOutputs ? <> +

{zh ? "本次检查完成于" : "Checks completed at"}{" "}

+

{zh ? "检查前后保持一致的产物版本" : "Output versions unchanged across these checks"}

+ {checkedOutputs!.map(row =>

{row.ref}{" · "}{row.sha256}

)} + :

{zh ? "未提供与当前产物匹配的检查时间及版本记录。" : "Check time and version records matching this report are unavailable."}

} {result.validation.basis_sha256} :

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

} : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 49c0db5ea5..9d8303ccf2 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -431,6 +431,7 @@ const en = { "drawer.blockedReason": "Why it is blocked", "drawer.blockedReasonMissing": "No reason was recorded when this task was blocked. Ask the responsible Agent in the conversation.", "drawer.taskReadbackUnavailable": "Current task state is unavailable. Refresh or open its retained history before acting.", + "drawer.taskRequestOnly": "This task is outside the current summary. Read its original request here; current state and actions remain unavailable.", "drawer.taskOrdinary": "Task", "drawer.taskStatusBlocked": "Blocked", "drawer.taskStatusDeferred": "Deferred", @@ -1751,6 +1752,7 @@ const zhCN: Record = { "drawer.blockedReason": "为何受阻", "drawer.blockedReasonMissing": "标记受阻时未记录原因,可在对话中询问负责的 Agent。", "drawer.taskReadbackUnavailable": "当前任务状态不可用。请刷新或打开保留的历史记录,再决定后续操作。", + "drawer.taskRequestOnly": "此事项不在当前摘要中。可在这里读取原始请求;当前状态与操作仍不可用。", "drawer.taskOrdinary": "普通任务", "drawer.taskStatusBlocked": "受阻", "drawer.taskStatusDeferred": "已延期", 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 103d6ad7eb..fd6293bf5c 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 @@ -52,6 +52,8 @@ export type WorkspaceAgentTodo = { requestText?: string; /** The authority id, absent when a legacy projection needs a display-only id. */ sourceTodoId?: string | null; + /** Local map fallback: exact request reading without current-state actions. */ + detailMode?: "request_only"; text: string; todoId: string; validationDigest?: string | null; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 816de7abb6..75dbcf6d3c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -1876,7 +1876,7 @@ export function PersonalWorkspacePage({ notice={serviceNotice} drawer={drawerSelection ? setSelection({ kind: "attention", item })} callbacks={effectiveDrawerCallbacks} goalNotifications={model.goalNotifications ?? []} goals={workspaceGoals} inspectorExpanded={taskInspectorExpanded} larkConnections={readOnly ? [] : larkConnections} todoReadbackUnavailable={drawerSelection.kind === "todo" && !workspaceGoals.some((goal) => - goal.goalId === drawerSelection.item.goalId && (drawerSelection.item.done + goal.goalId === drawerSelection.item.goalId && (drawerSelection.item.detailMode === "request_only" || drawerSelection.item.done || goal.agentTodos.some((todo) => todo.todoId === drawerSelection.item.todoId)))} proposalReadbackUnavailable={actionReadback.isError || !actionReadback.data || (drawerSelection.kind === "proposal" && !actionReadback.data.some(proposal => proposal.proposal_id === drawerSelection.item.previewId))} diff --git a/docs/architecture/rfcs/live-team-workspace-v0.md b/docs/architecture/rfcs/live-team-workspace-v0.md index 8538caec70..ec222f7d39 100644 --- a/docs/architecture/rfcs/live-team-workspace-v0.md +++ b/docs/architecture/rfcs/live-team-workspace-v0.md @@ -393,6 +393,8 @@ Implementation checkpoint ([#5587](https://github.com/loopx-project/loopx/pull/5 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. +The current host-check observation additionally names its UTC completion time and the declared output versions that remained unchanged before and after those checks. A changed output withdraws even the first return; the evidence detail displays matching records and leaves older or mismatched records unavailable. Stability reuses the acceptance owner's task work digest plus current claim/lifecycle and selected rules/pins/workspace: unrelated canonical commits and observation metadata do not revoke a stable check, while changed work, authorization or validators still reject it. This replaces whole-Goal revision comparison only on this read path; mutation CAS remains intact. The delegation TS owner and read-only Chat/CLI projection add no verifier configuration or independent semantic verdict, and do not close Section 9's independent exact-version acceptance gap. + 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; @@ -402,7 +404,13 @@ 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. +Map entries outside the bounded status summary now retain their exact Todo reference +for this request-only read. The drawer does not treat graph metadata as current +state or expose mutation actions; a current status entry restores the ordinary +inspector. Packaged desktop/390px checks use the actual isolated SQLite/HTTP +reader, remove its registry to withdraw the earlier body, and restore it for an +explicit retry. Canonical task state remains unchanged. These read-only checks +do not certify semantic acceptance, installed native behavior 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/assets/personal-workspace/goal-map-request-only-desktop.png b/docs/assets/personal-workspace/goal-map-request-only-desktop.png new file mode 100644 index 0000000000..1c28cd7bf0 Binary files /dev/null and b/docs/assets/personal-workspace/goal-map-request-only-desktop.png differ diff --git a/docs/assets/personal-workspace/goal-map-request-only-mobile.png b/docs/assets/personal-workspace/goal-map-request-only-mobile.png new file mode 100644 index 0000000000..5beea33d29 Binary files /dev/null and b/docs/assets/personal-workspace/goal-map-request-only-mobile.png differ diff --git a/docs/assets/personal-workspace/team-check-records-desktop.png b/docs/assets/personal-workspace/team-check-records-desktop.png new file mode 100644 index 0000000000..d04a1318c7 Binary files /dev/null and b/docs/assets/personal-workspace/team-check-records-desktop.png differ diff --git a/docs/assets/personal-workspace/team-check-records-mobile.png b/docs/assets/personal-workspace/team-check-records-mobile.png new file mode 100644 index 0000000000..917e1e7220 Binary files /dev/null and b/docs/assets/personal-workspace/team-check-records-mobile.png differ diff --git a/docs/assets/personal-workspace/team-check-records-unavailable.png b/docs/assets/personal-workspace/team-check-records-unavailable.png new file mode 100644 index 0000000000..94b781fc43 Binary files /dev/null and b/docs/assets/personal-workspace/team-check-records-unavailable.png differ diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index 91cb4b6fe0..0a8b746426 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -945,21 +945,50 @@ Linked evidence has a stepwise back action and a separate exit to the execution 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. +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`, `pinned_file_count`, `checked_at` and `output_versions` (relative `ref` and bare `sha256` pairs); 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, absolute paths or labels, and it is neither a stored success receipt nor verifier identity. + +The host now reads declared outputs before and after running the current checks. If their versions differ, the first return is refused as well as later reads; the original journal cannot accept newly changed, unchecked bytes. `checked_at` is the host's UTC completion observation for that read, and `output_versions` names the bytes that remained unchanged across the checks. This bounded observation does not prove that a validator semantically evaluated every output, detect a change and restoration between snapshots, or establish an independent reviewer. Each read still 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 missing basis or check/version records marked unavailable. A frontend response carrying records for another output version cannot present them as this report's checks. These are read-only observations through the existing delegation grant; no new configuration, verifier authority or task execution is implied. + +Validation stability is scoped to this task, its current claim/lifecycle, selected rules, file pins and verified workspace. The internal TS plan reuses the acceptance owner's work digest: unrelated Todo commits and observation-only note/evidence updates do not revoke otherwise identical checks. Task work changes, including unknown future work fields, claim/lifecycle changes and changed validators still reject the attempt. This changes the prior whole-Goal revision comparison; provider revisions remain authoritative for mutation CAS, not a reason to reject another task's stable read. 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: +The following packaged transport-fixture views show current check records and their withdrawal. A separate isolated production SQLite/HTTP/CLI journey exercises the same reader with actual host checks, missing output and explicit restoration. All data is synthetic; these checks do not qualify a live-model correction or the installed native App. + +![Packaged validation detail: host check time and stable declared output versions](../assets/personal-workspace/team-check-records-desktop.png) + +![390px validation detail with readable output versions](../assets/personal-workspace/team-check-records-mobile.png) + +![Unavailable output clears the report and check records](../assets/personal-workspace/team-check-records-unavailable.png) + +Task details opened from the work map can read the original request even when +the task is outside the current bounded status summary. That drawer explicitly +marks current state and actions unavailable; graph captions are not authoritative +request text. A missing source withdraws the earlier body, and an explicit retry +reads the same exact Goal/Todo again. This changes the former summary-only detail +guard without weakening current-state or mutation checks. The existing Todo CLI +and HTTP authority remain the reader; no new execution or settings are added. + +The following desktop and 390px views use the packaged frontend with an isolated +real SQLite/HTTP reader; the surrounding workspace/map data is synthetic. Source +loss, recovery and unchanged canonical state are checked in that same journey. +They do not qualify installed native behavior or an independent semantic verdict. + +![Original task request outside the current status summary](../assets/personal-workspace/goal-map-request-only-desktop.png) -![Packaged correction evidence: current validation basis and the independent-verifier gap](../assets/personal-workspace/team-evidence-desktop.png) +![390px request-only task details](../assets/personal-workspace/goal-map-request-only-mobile.png) -![390px correction evidence with the missing-verifier state](../assets/personal-workspace/team-evidence-mobile.png) +中文:工作地图里的事项即使不在当前状态摘要中,也能按准确 Goal/Todo 读取原始要求。 +详情明确提示当前状态和操作不可用,不把地图标题当作权威正文;来源失联撤回旧正文, +恢复后显式重试。读取复用原有 Todo CLI/HTTP 权威源,不新增配置或执行权限。 +上述打包桌面及 390px 场景使用隔离的真实 SQLite/HTTP 正文读取,周边工作区与地图为 +合成数据,并验证失联、恢复与规范任务状态不变;不代表已安装 Native App 或独立语义验收。 ![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 验收。 +中文:证据详情可展开“本次验收依据”,查看当前规则来源、定义摘要、检查与文件固定项数,以及 host 的 UTC 检查完成时间和检查前后保持一致的产物版本。首次返回同样拒绝检查期间变更的文件;不暴露命令、绝对路径或私有标签,也不代表独立验收者或证明规则语义完整,无法发现两次快照之间变更后恢复的内容。稳定性只比较本任务、当前 claim/生命周期、规则、固定文件与工作区,复用 TS 验收 owner 的工作摘要;无关 Todo 提交和观察性 note/evidence 更新不再误拒绝本次检查。本任务工作声明(含未来未知字段)、claim/生命周期或检查器变化仍拒绝;写事务仍按 provider revision 做 CAS。规则文件变化或验收失败清除产物与依据,恢复后显式重读;旧运行时和不匹配版本明确标为检查记录未提供。返回执行列表单次重读当前页,保留分页与键盘焦点;产物变化撤回验收,列表失联清除旧行,可刷新恢复,不启动额外工作。任务接受有效和请求方采用有效,仍不能证明独立验收者验证了准确版本。纠偏路径对此明确留缺口。后续结果失联或采用撤回不会抹去当前仍有效的纠偏证据;原地重新核验可恢复准确版本的采用,核心来源失效则仍清除路径。图中为打包前端的合成数据;另用隔离生产 SQLite/HTTP/CLI 验证同一读回和实际失效/恢复,不作为真实模型纠偏或已安装 Native App 验收。 ## Use the same bindings through MCP diff --git a/examples/personal-workspace-browser/goal-work-map.mjs b/examples/personal-workspace-browser/goal-work-map.mjs index db7e7dd0ad..00b897bd97 100644 --- a/examples/personal-workspace-browser/goal-work-map.mjs +++ b/examples/personal-workspace-browser/goal-work-map.mjs @@ -1,9 +1,75 @@ import assert from "node:assert/strict"; import { resolve } from "node:path"; -import { readFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { mkdtemp, rename, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { createInterface } from "node:readline"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; import { outputDir, repoRoot } from "./fixture.mjs"; import { openWorkspacePage } from "./scenario-context.mjs"; +async function requestAuthority(text) { + const root = await mkdtemp(resolve(tmpdir(), "loopx-map-request-")); + const child = spawn(resolveTestPython(), ["-u", "-c", ` +from pathlib import Path +import json, os, signal, sys, tempfile +root = Path(sys.argv[1]); tempfile.tempdir = str(root) +for key in ("TMPDIR", "TEMP", "TMP"): os.environ[key] = str(root) +sys.path.insert(0, str(Path.cwd() / "tests/control_plane")) +from canonical_authority_fixture import initialize_canonical_authority +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.effect_runtime import restart_effect_runtime +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +runtime, state, registry = root / "runtime", root / "state.md", root / "registry.json" +state.write_text("# Synthetic Goal\\n\\n## Agent Todo\\n") +record = dict(schema_version="todo_item_v0", todo_id="todo_map_reserve", index=1, + role="agent", status="open", done=False, archive_state="active", text=sys.argv[2], + source_section="Agent Todo", task_class="advancement_task", claimed_by="logistics") +projection = build_todo_runtime_shadow_projection(goal_id="product-release", todos=[record], handoff_mode="soft_claim") +initialize_canonical_authority(runtime, "product-release", projection, state_path=state, provider="sqlite") +state.unlink() +registry.write_text(json.dumps(dict(common_runtime_root=str(runtime), goals=[dict(id="product-release", repo=str(root), state_file="state.md")]))) +before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="product-release") +server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) +server.registry_path, server.runtime_root_override, server.verbose = registry, str(runtime), False +signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) +print(server.server_port, flush=True) +try: server.serve_forever() +finally: + server.server_close() + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="product-release") == before + restart_effect_runtime() +`, root, text], { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] }); + const lines = createInterface({ input: child.stdout }); + let diagnostic = ""; + child.stderr.on("data", chunk => { diagnostic = (diagnostic + chunk).slice(-3000); }); + try { + const port = await new Promise((accept, reject) => { + const timer = setTimeout(() => reject(new Error(`Request authority startup: ${diagnostic}`)), 20_000); + child.once("error", error => { clearTimeout(timer); reject(error); }); + child.once("exit", () => { clearTimeout(timer); reject(new Error(`Request authority exited: ${diagnostic}`)); }); + lines.once("line", line => { clearTimeout(timer); accept(Number(line)); }); + }); + assert.ok(Number.isInteger(port) && port > 0); + return { url: `http://127.0.0.1:${port}`, registry: resolve(root, "registry.json"), async close() { + lines.close(); + let code = child.exitCode; + if (code === null && child.signalCode === null) { + const exited = new Promise(accept => child.once("exit", accept)); + child.kill("SIGTERM"); + code = await exited; + } + assert.equal(code, 0, `Request authority cleanup: ${diagnostic}`); + await rm(root, { recursive: true, force: true }); + } }; + } catch (error) { + lines.close(); child.kill("SIGTERM"); + await rm(root, { recursive: true, force: true }); + throw error; + } +} + const node = (id, kind, title, state, depth, extra = {}) => ({ node_id: `node_${id}`, kind, title, state, depth, refs: { todo_ids: [`todo_map_${id}`] }, ...extra }); const edge = (from, to, relation, enforcement) => @@ -40,88 +106,111 @@ export const goalWorkMapScenario = { async run({ browser, collectCoverage, url }) { let limits = {}; const reads = []; - const statusFixture = JSON.parse(await readFile(resolve(repoRoot, "examples/status.example.json"), "utf8")); - const firstGoal = statusFixture.attention_queue.items[0]; - const sourceTodos = goalMap(firstGoal.goal_id).nodes.filter(item => item.kind === "deliverable").map(item => ({ - todo_id: item.refs.todo_ids[0], goal_id: firstGoal.goal_id, role: "agent", task_class: "advancement_task", - text: item.title, title: item.title, status: item.state, done: item.state === "done", claimed_by: item.owner_agent, - })); - firstGoal.agent_todos.items.push(...sourceTodos); - const routeReview = async (api, page) => { - for (const todo of sourceTodos) api.todoRequestTexts.set(JSON.stringify([todo.goal_id, todo.todo_id]), todo.text); - await page.route("**/status.json*", route => { - if (new URL(route.request().url()).pathname !== "/status.json") return route.fallback(); - return route.fulfill({ json: statusFixture }); + const originalRequest = "Reserve the hall only after approval; confirm the full room capacity."; + const authority = await requestAuthority(originalRequest); + try { + const routeRequest = (api, page) => page.route("**/api/chat/todo/detail?*", async route => { + const url = new URL(route.request().url()); + api.todoRequestReads.push({ goalId: url.searchParams.get("goal_id"), todoId: url.searchParams.get("todo_id") }); + await route.fulfill({ response: await route.fetch({ url: authority.url + url.pathname + url.search }) }); }); - return page.route("**/api/chat/delivery-review?*", route => { + const routeReview = (api, page) => page.route("**/api/chat/delivery-review?*", route => { const goalId = new URL(route.request().url()).searchParams.get("goal_id"); reads.push(goalId); return route.fulfill({ json: { ok: true, goal_id: goalId, observed_at: new Date().toISOString(), graph: null, goal_map: goalMap(goalId, limits), acceptance: null } }); }); - }; - const desktop = await openWorkspacePage(browser, url, { collectCoverage, beforeGoto: routeReview }); - const { page } = desktop; - await page.locator(".personal-goal-link").first().click(); - await page.getByRole("button", { name: "概览", exact: true }).click(); - const map = page.locator(".work-map"); - await map.getByRole("heading", { name: "工作地图" }).waitFor(); - const canvas = map.getByRole("region", { name: "工作地图画布" }); - const titles = locator => locator.locator(".work-map-node strong").allInnerTexts(); + const beforeGoto = async (api, page) => { await routeReview(api, page); await routeRequest(api, page); }; + const desktop = await openWorkspacePage(browser, url, { collectCoverage, beforeGoto }); + const { page } = desktop; + await page.locator(".personal-goal-link", { hasText: "Product Release" }).click(); + await page.getByRole("button", { name: "概览", exact: true }).click(); + const map = page.locator(".work-map"); + await map.getByRole("heading", { name: "工作地图" }).waitFor(); + const canvas = map.getByRole("region", { name: "工作地图画布" }); + const titles = locator => locator.locator(".work-map-node strong").allInnerTexts(); + + assert.deepEqual(new Set(await titles(canvas)), new Set(["Approve the venue hold", "Reserve the hall", "Release the deposit", + "Compare three venues", "Reprice catering"]), "Current work keeps unfinished items and their direct prerequisites"); + assert.equal(await canvas.locator("path[marker-end]").count(), 3, "Two relations between one pair draw one line; none are invented"); + assert.deepEqual(await titles(map.getByRole("region", { name: "未与其他工作关联" })), ["Check registration totals"]); + assert.match(await map.locator(".work-map-summary").innerText(), /2\/5\s+项任务已完成.*1 需你决策.*2 受阻.*1 持续监控/s); + await map.getByRole("button", { name: "1 项已完成或延后的工作已隐藏" }).click(); + assert.equal(await canvas.locator(".work-map-node").count(), 6); + assert.equal(await canvas.locator("path[marker-end]").count(), 4); + await map.getByRole("button", { name: "当前工作", exact: true }).click(); - assert.deepEqual(new Set(await titles(canvas)), new Set(["Approve the venue hold", "Reserve the hall", "Release the deposit", - "Compare three venues", "Reprice catering"]), "Current work keeps unfinished items and their direct prerequisites"); - assert.equal(await canvas.locator("path[marker-end]").count(), 3, "Two relations between one pair draw one line; none are invented"); - assert.deepEqual(await titles(map.getByRole("region", { name: "未与其他工作关联" })), ["Check registration totals"]); - assert.match(await map.locator(".work-map-summary").innerText(), /2\/5\s+项任务已完成.*1 需你决策.*2 受阻.*1 持续监控/s); - await map.getByRole("button", { name: "1 项已完成或延后的工作已隐藏" }).click(); - assert.equal(await canvas.locator(".work-map-node").count(), 6); - assert.equal(await canvas.locator("path[marker-end]").count(), 4); - await map.getByRole("button", { name: "当前工作", exact: true }).click(); + await canvas.focus(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Enter"); + assert.equal(await canvas.locator('.work-map-node[aria-pressed="true"] strong').innerText(), "Approve the venue hold", "Keyboard reaches and selects the first node"); + const inspector = map.getByRole("region", { name: "选中事项" }); + await inspector.getByText("此事项的详情未加载到工作区。").waitFor(); + assert.equal(await inspector.getByRole("button", { name: "打开详情" }).count(), 0, "An unloaded decision is never opened as an agent task"); - await canvas.focus(); - await page.keyboard.press("Tab"); - await page.keyboard.press("Enter"); - assert.equal(await canvas.locator('.work-map-node[aria-pressed="true"] strong').innerText(), "Approve the venue hold", "Keyboard reaches and selects the first node"); - const inspector = map.getByRole("region", { name: "选中事项" }); - await inspector.getByText("此事项的详情未加载到工作区。").waitFor(); - assert.equal(await inspector.getByRole("button", { name: "打开详情" }).count(), 0, "An unloaded decision is never opened as an agent task"); + await canvas.locator(".work-map-node", { hasText: "Reserve the hall" }).click(); + assert.deepEqual(await inspector.locator(".work-map-relations > div").evaluateAll(columns => columns.map(column => + [...column.querySelectorAll("li span")].map(span => span.textContent))), [["Approve the venue hold"], ["Release the deposit"]]); + const dimmed = await canvas.locator(".work-map-node[data-dimmed] strong").allInnerTexts(); + assert.deepEqual(new Set(dimmed), new Set(["Compare three venues", "Reprice catering"]), "Selection traces only recorded lineage"); + await page.screenshot({ path: resolve(outputDir, "goal-work-map.png"), animations: "disabled" }); + await inspector.getByRole("button", { name: "打开详情" }).click(); + const drawer = page.getByRole("dialog", { name: "Todo 详情" }); + await drawer.getByRole("heading", { name: originalRequest, exact: true }).waitFor(); + assert.deepEqual(desktop.api.todoRequestReads, [{ goalId: reads[0], todoId: "todo_map_reserve" }], + "Map details read the exact canonical Task request rather than trusting the projected node title"); + assert.match(await drawer.innerText(), /当前状态与操作仍不可用/); + assert.equal(await drawer.locator(".personal-task-inspector-fields,.personal-task-inspector-actions").count(), 0, + "A graph-only Task cannot present snapshot metadata as current or enable mutations"); + await page.screenshot({ path: resolve(outputDir, "goal-map-request-only-desktop.png"), animations: "disabled" }); + await page.getByRole("button", { name: /关闭详情/ }).click(); + assert.equal(await canvas.locator('.work-map-node[aria-pressed="true"] strong').innerText(), "Reserve the hall", "Closing details returns to the same selection"); - await canvas.locator(".work-map-node", { hasText: "Reserve the hall" }).click(); - assert.deepEqual(await inspector.locator(".work-map-relations > div").evaluateAll(columns => columns.map(column => - [...column.querySelectorAll("li span")].map(span => span.textContent))), [["Approve the venue hold"], ["Release the deposit"]]); - const dimmed = await canvas.locator(".work-map-node[data-dimmed] strong").allInnerTexts(); - assert.deepEqual(new Set(dimmed), new Set(["Compare three venues", "Reprice catering"]), "Selection traces only recorded lineage"); - await page.screenshot({ path: resolve(outputDir, "goal-work-map.png"), animations: "disabled" }); - await inspector.getByRole("button", { name: "打开详情" }).click(); - const drawer = page.getByRole("dialog", { name: "Todo 详情" }); - await drawer.getByRole("heading", { name: "Reserve the hall" }).waitFor(); - assert.match(await drawer.innerText(), /logistics/); - await page.getByRole("button", { name: /关闭详情/ }).click(); - assert.equal(await canvas.locator('.work-map-node[aria-pressed="true"] strong').innerText(), "Reserve the hall", "Closing details returns to the same selection"); + const hiddenRegistry = `${authority.registry}.hidden`; + const errorsBeforeLoss = desktop.errors.length; + await rename(authority.registry, hiddenRegistry); + await inspector.getByRole("button", { name: "打开详情" }).click(); + await drawer.getByRole("alert").filter({ hasText: "完整要求读取失败" }).waitFor(); + assert.deepEqual(desktop.errors.splice(errorsBeforeLoss), [ + "Failed to load resource: the server responded with a status of 503 (Service Unavailable)", + ], "Only the deliberately unavailable source may emit a transport error"); + assert.equal(await drawer.getByRole("heading", { name: originalRequest, exact: true }).count(), 0, + "A missing source must not retain the previously read canonical request"); + assert.equal(await drawer.locator(".personal-task-inspector-actions").count(), 0); + await rename(hiddenRegistry, authority.registry); + await drawer.getByRole("button", { name: "重试读取完整要求", exact: true }).click(); + await drawer.getByRole("heading", { name: originalRequest, exact: true }).waitFor(); + await page.getByRole("button", { name: /关闭详情/ }).click(); - limits = { omitted_node_count: 3, topology_complete: false }; - await page.getByRole("button", { name: "刷新快照", exact: true }).click(); - await map.getByText("部分工作未出现在此地图中。").waitFor(); - assert.equal(await page.locator("[role=alert]", { hasText: "工作区状态已在此快照之后变化" }).count(), 0); - assert.ok(reads.length <= 4, `Delivery review re-read ${reads.length} times`); - assert.deepEqual(desktop.errors, []); - const coverageEntries = await desktop.close(); + limits = { omitted_node_count: 3, topology_complete: false }; + await page.getByRole("button", { name: "刷新快照", exact: true }).click(); + await map.getByText("部分工作未出现在此地图中。").waitFor(); + assert.equal(await page.locator("[role=alert]", { hasText: "工作区状态已在此快照之后变化" }).count(), 0); + assert.ok(reads.length <= 4, `Delivery review re-read ${reads.length} times`); + assert.deepEqual(desktop.errors, []); + const coverageEntries = await desktop.close(); - const phone = await openWorkspacePage(browser, url, { collectCoverage, beforeGoto: routeReview, viewport: { width: 390, height: 844 }, isMobile: true }); - const navigation = phone.page.getByRole("button", { name: "打开 Goal 导航" }); - if (await navigation.isVisible()) await navigation.click(); - await phone.page.locator(".personal-goal-link").first().click(); - await phone.page.getByRole("button", { name: "概览", exact: true }).click(); - const list = phone.page.locator(".work-map-list"); - await list.waitFor(); - assert.equal(await list.locator("li").count(), 5); - assert.match(await list.locator("li", { hasText: "Release the deposit" }).innerText(), /之前 Reserve the hall/); - assert.equal(await phone.page.locator(".work-map-scroll").isVisible(), false); - assert.equal(await phone.page.getByRole("group", { name: "地图缩放" }).isVisible(), false); - assert.equal(await phone.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, "No page-level horizontal overflow"); - await list.scrollIntoViewIfNeeded(); - await phone.page.screenshot({ path: resolve(outputDir, "goal-work-map-mobile.png"), animations: "disabled" }); - coverageEntries.push(...await phone.close()); - return { coverageEntries, note: "Work map shows typed links only, traces lineage, opens details, discloses incomplete coverage and collapses to a list on phones." }; + const phone = await openWorkspacePage(browser, url, { collectCoverage, beforeGoto, viewport: { width: 390, height: 844 }, isMobile: true }); + const navigation = phone.page.getByRole("button", { name: "打开 Goal 导航" }); + if (await navigation.isVisible()) await navigation.click(); + await phone.page.locator(".personal-goal-link", { hasText: "Product Release" }).click(); + await phone.page.getByRole("button", { name: "概览", exact: true }).click(); + const list = phone.page.locator(".work-map-list"); + await list.waitFor(); + assert.equal(await list.locator("li").count(), 5); + assert.match(await list.locator("li", { hasText: "Release the deposit" }).innerText(), /之前 Reserve the hall/); + assert.equal(await phone.page.locator(".work-map-scroll").isVisible(), false); + assert.equal(await phone.page.getByRole("group", { name: "地图缩放" }).isVisible(), false); + assert.equal(await phone.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, "No page-level horizontal overflow"); + await list.scrollIntoViewIfNeeded(); + await phone.page.screenshot({ path: resolve(outputDir, "goal-work-map-mobile.png"), animations: "disabled" }); + await list.locator(".work-map-node", { hasText: "Reserve the hall" }).click(); + await phone.page.getByRole("region", { name: "选中事项" }).getByRole("button", { name: "打开详情" }).click(); + const phoneDrawer = phone.page.getByRole("dialog", { name: "Todo 详情" }); + await phoneDrawer.getByRole("heading", { name: originalRequest, exact: true }).waitFor(); + assert.equal(await phoneDrawer.locator(".personal-task-inspector-actions").count(), 0); + await phone.page.screenshot({ path: resolve(outputDir, "goal-map-request-only-mobile.png"), animations: "disabled" }); + coverageEntries.push(...await phone.close()); + return { coverageEntries, note: "Work map opens canonical requests outside the status summary, withdraws lost sources, retries without mutation, and retains typed links and mobile navigation." }; + } finally { await authority.close(); } }, }; diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py index 8e2e550a2b..276b5b9345 100644 --- a/loopx/collaboration_mcp.py +++ b/loopx/collaboration_mcp.py @@ -11,7 +11,6 @@ import argparse import asyncio -import hashlib import json import os import signal @@ -1341,28 +1340,7 @@ def _validate(self, binding: dict) -> dict: return delegation_validation.validate(self, binding) def _accepted(self, binding: dict) -> dict: - validation = self._validate(binding) - if not validation["plan"]["canonical_done"]: - raise ValueError("delegation requires current canonical completion") - workspace = Path(binding["workspace"]).resolve() - artifacts = [] - for ref in binding["output_refs"]: - path = workspace / ref - if not path.resolve().is_relative_to(workspace) or path.is_symlink() or not path.is_file(): - raise ValueError("delegation artifact unavailable or outside workspace") - if path.stat().st_size > 128_000: - raise ValueError("delegation artifact exceeds bounded return size") - with os.fdopen(os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0)), "rb") as stream: - if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): - raise ValueError("delegation artifact must be a regular file") - content = stream.read(128_001) - if len(content) > 128_000: - raise ValueError("delegation artifact exceeds bounded return size") - artifacts.append({"ref": ref, "sha256": hashlib.sha256(content).hexdigest(), - "text": content.decode("utf-8")}) - if len(json.dumps(artifacts).encode()) > 64_000: - raise ValueError("delegation aggregate return exceeds limit") - return {"artifacts": artifacts, "validation": validation["plan"]["observation"]} + return delegation_results.accepted_result(self, binding) def execute(self, operation_id: str) -> None: path = self.path(operation_id) diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts index d1ca3c599d..91dfeff6ac 100644 --- a/loopx/control_plane/collaboration/delegation.ts +++ b/loopx/control_plane/collaboration/delegation.ts @@ -4,7 +4,7 @@ import type {JsonObject} from "../effect_program.ts"; import {requireJsonObject} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; -import {acceptanceValidationEffects, type AcceptanceCompletionRequirements} from "../goals/acceptance_contract.ts"; +import {acceptanceValidationEffects, goalAcceptanceTodoDigest, type AcceptanceCompletionRequirements} from "../goals/acceptance_contract.ts"; import {normalizeTodoCompletionValidationDeclaration} from "../todos/completion_validation_declaration.ts"; import {readTurnSelectionRejection, turnSelectionRejectionState} from "../turn_driver/selection_rejection.ts"; import { BARE_SHA256_PATTERN, ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; @@ -63,9 +63,58 @@ export function delegationValidationPlan(params: JsonObject): JsonObject { 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, + // Reuse the acceptance owner's work classification. Scheduling/progress + // metadata and unrelated provider commits are not a new task declaration; + // current claim/lifecycle still fence this particular validation attempt. + const task_basis_sha256 = canonicalAuthoritySha256({work: goalAcceptanceTodoDigest(todo), + claimed_by: todo.claimed_by ?? null, role: todo.role ?? null, + status: todo.status, done: todo.done, archive_state: todo.archive_state ?? null}); + return {todo_id: todo.todo_id, state: "ready", source, observation, task_basis_sha256, effects, canonical_done: todo.done === true && todo.status === "done"}; } + +/** Fresh host check observation, not an independent-verifier or persisted receipt. + * Host IO brackets the actual checks; only stable declared output versions return. */ +export function delegationCheckedArtifacts(params: JsonObject): JsonObject { + const binding = requireJsonObject(params.binding, "delegation binding"); + const plan = requireJsonObject(params.plan, "current validation plan"); + requireThat(plan.todo_id === binding.todo_id && plan.state === "ready" && plan.canonical_done === true, + "delegation requires current canonical completion"); + const observation = requireJsonObject(plan.observation, "current validation observation"); + requireThat((observation.source === "goal_acceptance" || observation.source === "todo_validation") + && typeof observation.basis_sha256 === "string" && BARE_SHA256_PATTERN.test(observation.basis_sha256) + && Number.isInteger(observation.check_count) && Number(observation.check_count) > 0 + && Number.isInteger(observation.pinned_file_count) && Number(observation.pinned_file_count) >= 0, + "current validation basis required"); + requireThat(typeof params.checked_at === "string" + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(params.checked_at) + && Number.isFinite(Date.parse(params.checked_at)), "host check time required"); + const refs = binding.output_refs; + requireThat(Array.isArray(refs) && refs.length > 0 + && refs.length <= 20 && new Set(refs).size === refs.length, + "declared output refs required"); + const versions = (value: unknown) => { + requireThat(Array.isArray(value) && value.length === refs.length, + "declared output versions required"); + const rows = value.map(item => { + const row = requireJsonObject(item, "output version"); + requireThat(text(row.ref) && !row.ref.startsWith("/") && !row.ref.includes("\\") + && !row.ref.split("/").includes("..") && refs.includes(row.ref) + && typeof row.sha256 === "string" && BARE_SHA256_PATTERN.test(row.sha256), + "valid declared output version required"); + return {ref: row.ref, sha256: row.sha256}; + }); + requireThat(new Set(rows.map(row => row.ref)).size === rows.length, "duplicate output version"); + return rows.sort((a, b) => a.ref.localeCompare(b.ref)); + }; + const before = versions(params.before); + const after = versions(params.after); + requireThat(canonicalAuthoritySha256(before) === canonicalAuthoritySha256(after), + "delegation output changed during validation; recheck the original output"); + return {source: observation.source, basis_sha256: observation.basis_sha256, + check_count: observation.check_count, pinned_file_count: observation.pinned_file_count, + checked_at: params.checked_at, output_versions: after}; +} export function selectDelegationBinding(params: JsonObject): JsonObject { const config = requireJsonObject(params.config, "delegation configuration"); requireThat(config.schema_version === "loopx_local_delegation_v0", "unsupported delegation configuration"); diff --git a/loopx/control_plane/collaboration/delegation_results.py b/loopx/control_plane/collaboration/delegation_results.py index 81275ec2ac..34b2956051 100644 --- a/loopx/control_plane/collaboration/delegation_results.py +++ b/loopx/control_plane/collaboration/delegation_results.py @@ -5,12 +5,56 @@ """ from __future__ import annotations +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import stat + from .inbox import _entry, _read, _write from .peers import input_readiness, require_operation_id from ..effect_runtime import effect_runtime_result, EffectRuntimeRemoteError from ...file_lock import exclusive_file_lock +def _artifacts(binding): + """Bounded host IO; the typed owner decides version/check relationships.""" + workspace = Path(binding["workspace"]).resolve() + artifacts = [] + for ref in binding["output_refs"]: + path = workspace / ref + if not path.resolve().is_relative_to(workspace) or path.is_symlink() or not path.is_file(): + raise ValueError("delegation artifact unavailable or outside workspace") + if path.stat().st_size > 128_000: + raise ValueError("delegation artifact exceeds bounded return size") + with os.fdopen(os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_NOFOLLOW", 0)), "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise ValueError("delegation artifact must be a regular file") + content = stream.read(128_001) + if len(content) > 128_000: + raise ValueError("delegation artifact exceeds bounded return size") + artifacts.append({"ref": ref, "sha256": hashlib.sha256(content).hexdigest(), + "text": content.decode("utf-8")}) + if len(json.dumps(artifacts).encode()) > 64_000: + raise ValueError("delegation aggregate return exceeds limit") + return artifacts + + +def accepted_result(service, binding): + before = _artifacts(binding) + validation = service._validate(binding) # Executes the current canonical rules. + after = _artifacts(binding) + observation = effect_runtime_result("collaboration.delegation.checked_artifacts", { + "binding": binding, "plan": validation["plan"], + "before": [{"ref": row["ref"], "sha256": row["sha256"]} for row in before], + "after": [{"ref": row["ref"], "sha256": row["sha256"]} for row in after], + "checked_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + }) + return {"artifacts": after, "validation": observation} + + def operation_brief(service, row): identity = row["identity"] return _entry(service.root, service.goal_id, identity["binding"]["agent_id"], identity["request_id"])["brief"] diff --git a/loopx/control_plane/collaboration/delegation_validation.py b/loopx/control_plane/collaboration/delegation_validation.py index f3b69228e7..3dfe3ede21 100644 --- a/loopx/control_plane/collaboration/delegation_validation.py +++ b/loopx/control_plane/collaboration/delegation_validation.py @@ -67,7 +67,10 @@ def validate(service, binding: dict) -> dict: validation_workspace_path=Path(binding["workspace"]), ) for effect in before["plan"]["effects"]] after = capture(service, binding) - if after != before: + # The TS plan binds this task's work, claim/lifecycle and current rules. + # A whole-Goal provider revision may advance for unrelated peer progress. + if (after["plan"] != before["plan"] or not after["files_current"] + or after["delivery_workspace"] != before["delivery_workspace"]): raise ValueError("delegation task acceptance changed during validation") if not all(result["passed"] for result in results): raise ValueError("delegation task acceptance rejected") diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index e93dd629d9..3a54171505 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -569,6 +569,7 @@ export function createEffectRuntimeHandlers( ["turn.selection.rejection", lazyHandler(() => import("./turn_driver/selection_rejection.ts"), ({projectTurnSelectionRejection}) => projectTurnSelectionRejection)], ["collaboration.delegation.preflight", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationPreflight}) => delegationPreflight)], ["collaboration.delegation.validation_plan", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationValidationPlan}) => delegationValidationPlan)], + ["collaboration.delegation.checked_artifacts", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationCheckedArtifacts}) => delegationCheckedArtifacts)], ["collaboration.delegation.turn_plan", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationTurnPlanDecision}) => delegationTurnPlanDecision)], ["collaboration.delegation.inventory_query", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationInventoryQuery}) => delegationInventoryQuery)], ["collaboration.delegation.inventory_item", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationInventoryItem}) => delegationInventoryItem)], diff --git a/tests/control_plane_ts/delegation.test.ts b/tests/control_plane_ts/delegation.test.ts index e29666e242..fb399c3418 100644 --- a/tests/control_plane_ts/delegation.test.ts +++ b/tests/control_plane_ts/delegation.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import {recordDelegationAdoption, decideDelegationStop, decideDelegationWakeObservation, delegationInventoryItem, delegationInventoryQuery, delegationPreflight, delegationTurnPlanDecision, delegationValidationPlan, recoverValidatedDelegationSettlement, selectDelegationBinding, transitionDelegationObservation} from "../../loopx/control_plane/collaboration/delegation.ts"; +import {recordDelegationAdoption, decideDelegationStop, decideDelegationWakeObservation, delegationCheckedArtifacts, delegationInventoryItem, delegationInventoryQuery, delegationPreflight, delegationTurnPlanDecision, delegationValidationPlan, recoverValidatedDelegationSettlement, selectDelegationBinding, transitionDelegationObservation} from "../../loopx/control_plane/collaboration/delegation.ts"; import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import {projectTurnSelectionRejection} from "../../loopx/control_plane/turn_driver/selection_rejection.ts"; @@ -117,6 +117,50 @@ test("current validation provenance identifies definitions without exporting pri assert.doesNotMatch(JSON.stringify(combined), /private|Independent verification|validation_argv/); }); +test("validation plan isolates progress metadata but fences work and claim/lifecycle", () => { + const args = {binding, basis: validationBasis, declaration}; + const initial = delegationValidationPlan(args); + assert.match(String(initial.task_basis_sha256), /^[a-f0-9]{64}$/); + for (const delta of [{note: "Peer checked another result"}, {updated_at: "2026-10-05T00:00:00Z"}, + {priority: "P1"}, {evidence: "Current observation"}]) { + assert.deepEqual(delegationValidationPlan({...args, basis: {...validationBasis, + provider_revision: "fixture:99", todo: {...validationTodo, ...delta}}}), initial); + } + for (const delta of [{text: "Changed requested result"}, {future_work_field: "new obligation"}, + {claimed_by: "other"}, {role: "user"}, {status: "blocked"}, {archive_state: "archived"}, + {done: true, status: "done"}, {task_repository: "git:example.invalid/other/repository"}]) { + assert.notEqual(delegationValidationPlan({...args, basis: {...validationBasis, + todo: {...validationTodo, ...delta}}}).task_basis_sha256, initial.task_basis_sha256); + } +}); + +test("fresh host checks bind stable declared output versions, without attesting independence", () => { + const plan = delegationValidationPlan({binding, declaration, basis: {...validationBasis, + todo: {...validationTodo, status: "done", done: true}}}); + const outputs = [{ref: "output.json", sha256: "a".repeat(64)}]; + const checkedAt = "2026-01-02T03:04:05.123456Z"; + const args = {binding, plan, before: outputs, after: outputs, checked_at: checkedAt}; + assert.deepEqual(delegationCheckedArtifacts(args), {...plan.observation as object, + checked_at: checkedAt, output_versions: outputs}); + assert.deepEqual(delegationCheckedArtifacts({...args, after: [{...outputs[0], private_log: "never export"}]}), + delegationCheckedArtifacts(args)); + assert.throws(() => delegationCheckedArtifacts({...args, after: [{...outputs[0], sha256: "b".repeat(64)}]}), + /output changed during validation/); + for (const versions of [[], [outputs[0], outputs[0]], [{...outputs[0], ref: "other.json"}], + [{...outputs[0], ref: "/private/output.json"}], [{...outputs[0], sha256: "not a digest"}]]) { + assert.throws(() => delegationCheckedArtifacts({...args, after: versions})); + assert.throws(() => delegationCheckedArtifacts({...args, before: versions})); + } + for (const patch of [{canonical_done: false}, {state: "unbound"}, {todo_id: "other"}, + {observation: {...plan.observation as object, check_count: 0}}]) + assert.throws(() => delegationCheckedArtifacts({...args, plan: {...plan, ...patch}})); + for (const time of [null, "invalid", "2026-01-02", "2026-01-02T03:04:05+00:00"]) + assert.throws(() => delegationCheckedArtifacts({...args, checked_at: time}), /host check time/); + const two = [{ref: "output.json", sha256: "a".repeat(64)}, {ref: "second.txt", sha256: "c".repeat(64)}]; + assert.deepEqual(delegationCheckedArtifacts({...args, binding: {...binding, output_refs: ["output.json", "second.txt"]}, + before: two, after: [...two].reverse()}).output_versions, two); +}); + 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/test_chat_team_evidence.py b/tests/test_chat_team_evidence.py index 26be31ba3b..5c7248f7fb 100644 --- a/tests/test_chat_team_evidence.py +++ b/tests/test_chat_team_evidence.py @@ -53,6 +53,14 @@ def read(body=None): # not momentary liveness sampled at different times. for key in ("operation_id", "request_id", "agent_id", "todo_id", "artifacts"): assert observed[key] == cli_result[key] == accepted[key] + # Every transport reruns real checks; time is fresh, the checked versions + # and current rule definition match the report actually returned. + for result in (observed, cli_result, accepted): + assert result["validation"]["checked_at"].endswith("Z") + assert result["validation"]["output_versions"] == [ + {"ref": row["ref"], "sha256": row["sha256"]} for row in result["artifacts"]] + assert {key: value for key, value in observed["validation"].items() if key != "checked_at"} == { + key: value for key, value in cli_result["validation"].items() if key != "checked_at"} assert observed["artifacts"][0]["text"] == (root / "analyst/initial/output.json").read_text() assert store.load_session(sid) == before assert (root / "analyst/initial/host-invocations").read_text() == "1" diff --git a/tests/test_independent_delegation_validation.py b/tests/test_independent_delegation_validation.py index 2d58dfcdf5..2a95e0c059 100644 --- a/tests/test_independent_delegation_validation.py +++ b/tests/test_independent_delegation_validation.py @@ -18,6 +18,51 @@ service = delegation_service +@pytest.mark.parametrize("source", ["goal_acceptance", "todo_validation"]) +def test_other_task_commit_during_checks_preserves_current_task_evidence(service, monkeypatch, source): + """A peer's real canonical commit cannot invalidate a stable scoped check.""" + from loopx.control_plane.collaboration import delegation_validation + + root, runner = service + if source == "todo_validation": + independent_binding(service) + else: + basis = inspect_goal_acceptance(registry_path=runner.registry, goal_id=runner.goal_id, + runtime_root=str(runner.root)) + document = basis["contract"] + document["scope"] = {"kind": "selected_work", "todo_ids": ["todo_analyst-initial"]} + document["bindings"] = [row for row in document["bindings"] + if row["todo_id"] == "todo_analyst-initial"] + configure_goal_acceptance(registry_path=runner.registry, goal_id=runner.goal_id, + runtime_root=str(runner.root), document=document, + expected_provider_revision=basis["provider_revision"], execute=True) + binding = runner.binding("analysis", require_active=True) + before = delegation_validation.capture(runner, binding) + execute = delegation_validation.run_goal_acceptance_validation_effect + commits = [] + + def checked_with_peer_progress(**kwargs): + result = execute(**kwargs) # Real configured validator, not a supplied pass. + assert result["passed"] + if not commits: + committed = demo.cli(root, "todo", "add", "--goal-id", runner.goal_id, "--role", "agent", + "--text", "Review an unrelated result", "--claimed-by", "reviewer") + assert committed["ok"], committed + commits.append(committed["todo_id"]) + return result + + monkeypatch.setattr(delegation_validation, "run_goal_acceptance_validation_effect", checked_with_peer_progress) + validated = runner._validate(binding) + after = delegation_validation.capture(runner, binding) + assert before["basis"]["provider_revision"] != after["basis"]["provider_revision"] + assert before["basis"]["todo"] == after["basis"]["todo"] + assert commits[0] != binding["todo_id"] + assert validated["plan"]["source"] == source + assert before["plan"] == after["plan"] and before["files_current"] and after["files_current"] + assert not after["basis"]["todo"]["done"] + assert not (root / "analyst/initial/host-invocations").exists() + + def independent_binding(service, *, declared=True, task_repository=None, validation_argv=None): root, runner = service basis = inspect_goal_acceptance(registry_path=runner.registry, goal_id=runner.goal_id, @@ -45,6 +90,43 @@ def independent_binding(service, *, declared=True, task_repository=None, validat return todo_id +@pytest.mark.parametrize("change", ["note", "text", "claim"]) +def test_current_task_observation_isolated_but_work_and_claim_changes_reject(service, monkeypatch, change): + from loopx.control_plane.collaboration import delegation_validation + from loopx.control_plane.todos.provider_update import update_canonical_todo_if_promoted + + root, runner = service + todo_id = independent_binding(service) + binding = runner.binding("analysis", require_active=True) + execute = delegation_validation.run_goal_acceptance_validation_effect + + def checked_with_task_change(**kwargs): + result = execute(**kwargs) + assert result["passed"] + updated = update_canonical_todo_if_promoted( + registry_path=runner.registry, runtime_root=runner.root, goal_id=runner.goal_id, + todo_id=todo_id, actor_agent_id="analyst", role="agent", dry_run=False, + text="Validate a different requested result" if change == "text" else None, + note="Current progress observation" if change == "note" else None, + planning_intent={"claimed_by": "reviewer"} if change == "claim" else None, + ) + assert updated["status"] == "applied", updated + return result + + monkeypatch.setattr(delegation_validation, "run_goal_acceptance_validation_effect", checked_with_task_change) + if change == "note": + assert runner._validate(binding)["plan"]["source"] == "todo_validation" + else: + with pytest.raises(ValueError, match="task acceptance changed during validation"): + runner._validate(binding) + current = demo.canonical_tasks(root)[todo_id] + field = {"note": "note", "text": "text", "claim": "claimed_by"}[change] + assert current[field] == {"note": "Current progress observation", "text": "Validate a different requested result", + "claim": "reviewer"}[change] + assert not current["done"] + assert not (root / "analyst/initial/host-invocations").exists() + + def test_independent_validator_qualifies_preflight_without_owner_rebinding(service): todo_id = independent_binding(service) status, check = cli(service[1], "inspect", "--binding-id", "analysis") diff --git a/tests/test_local_delegation.py b/tests/test_local_delegation.py index 48db199ff7..68eb31ceb9 100644 --- a/tests/test_local_delegation.py +++ b/tests/test_local_delegation.py @@ -396,8 +396,13 @@ async def disconnect_requester(): # 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 set(validation) == {"source", "check_count", "pinned_file_count", "basis_sha256", "checked_at", "output_versions"} + assert validation["checked_at"].endswith("Z") + assert validation["output_versions"] == [{"ref": row["ref"], "sha256": row["sha256"]} for row in result["artifacts"]] + fresh_validation = reconnected.read("analysis-1")["validation"] + assert fresh_validation["checked_at"] > validation["checked_at"] + assert {key: value for key, value in fresh_validation.items() if key != "checked_at"} == { + key: value for key, value in validation.items() if key != "checked_at"} 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"] @@ -433,6 +438,66 @@ async def disconnect_requester(): reconnected.read("analysis-1") +def test_accepted_output_cannot_change_between_check_and_return(service, monkeypatch): + """Real owner checks; inject a writer at the subsequent artifact-read boundary.""" + root, runner = service + runner.start("analysis", "analysis-1", brief()) + assert wait(runner)["status"] == "accepted" + binding = runner._bound(_read(runner.path("analysis-1"))) + output = root / "analyst" / "initial" / "output.json" + original = output.read_bytes() + validate = runner._validate + + def change_after_checks(value): + checked = validate(value) # Execute the real configured oracle first. + output.write_text("{}") + return checked + + monkeypatch.setattr(runner, "_validate", change_after_checks) + try: + # _accepted also feeds the first journaled return, before a saved hash exists. + with pytest.raises(Exception, match="output changed during validation"): + runner._accepted(binding) + finally: + output.write_bytes(original) + monkeypatch.setattr(runner, "_validate", validate) + assert runner.read("analysis-1")["artifacts"][0]["text"] == original.decode() + assert (root / "analyst" / "initial" / "host-invocations").read_text() == "1" + + +def test_first_return_withholds_changed_output_and_recovers_same_operation(service, monkeypatch): + root, runner = service + monkeypatch.setattr(runner, "_spawn", lambda _operation_id: None) + output = root / "analyst" / "initial" / "output.json" + original = output.read_bytes() + validate = runner._validate + + def change_before_first_return(value): + checked = validate(value) + if checked["plan"]["canonical_done"]: + output.write_text("{}") + return checked + + monkeypatch.setattr(runner, "_validate", change_before_first_return) + runner.start("analysis", "analysis-1", brief()) + try: + runner.execute("analysis-1") + journal = _read(runner.path("analysis-1")) + assert journal["status"] != "accepted" + assert not journal.get("artifacts") + assert "output changed during validation" in journal["error"] + finally: + output.write_bytes(original) + monkeypatch.setattr(runner, "_validate", validate) + runner.resume("analysis-1") + runner.execute("analysis-1") + result = runner.read("analysis-1") + assert result["status"] == "accepted", result + assert result["artifacts"][0]["text"] == original.decode() + assert result["validation"]["output_versions"][0]["sha256"] == result["artifacts"][0]["sha256"] + assert (root / "analyst" / "initial" / "host-invocations").read_text() == "1" + + def test_host_timeout_removes_private_delegation_bootstrap(service, monkeypatch): root, runner = service monkeypatch.setattr(runner, "_spawn", lambda _operation_id: None)