diff --git a/apps/presentation/dashboard/smoke/team-result-use-recovery-smoke.mjs b/apps/presentation/dashboard/smoke/team-result-use-recovery-smoke.mjs new file mode 100644 index 0000000000..e8e9e8f4f2 --- /dev/null +++ b/apps/presentation/dashboard/smoke/team-result-use-recovery-smoke.mjs @@ -0,0 +1,101 @@ +// Packaged reader + production Chat HTTP, delegation workers and independent checks. +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {createInterface} from "node:readline"; +import {mkdir} from "node:fs/promises"; +import {resolve} from "node:path"; +import {resolveTestPython} from "../../../../scripts/test-python.mjs"; +import {launchBrowser, loadPlaywright, waitForHttp} from "../../../../examples/dashboard-browser-smoke-support.mjs"; +process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED = "1"; +const {repoRoot, outputDir, port, startServer} = await import("../../../../examples/personal-workspace-browser/fixture.mjs"); +const {openWorkspacePage} = await import("../../../../examples/personal-workspace-browser/scenario-context.mjs"); +const fixture = spawn(resolveTestPython({repoRoot}), ["-u", "tests/presentation/team_result_use_fixture.py"], {cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"]}); +const exited = once(fixture, "exit"); +const lines = createInterface({input: fixture.stdout}); +const iterator = lines[Symbol.asyncIterator](); +let stderr = ""; +fixture.stderr.on("data", chunk => {stderr += chunk;}); +async function next() {const line = await iterator.next(); assert.equal(line.done, false, stderr); return JSON.parse(line.value);} +async function command(value) {fixture.stdin.write(`${value}\n`); return next();} +let server, browser, workspace; +try { + const {origin, goal_id} = await next(); + 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"); + Object.assign(page.__loopxRuntime.loopxModes.get(configured.sessionId), {enabled: true, paused: false, + active_turn_id: "fixture-loopx-turn", native: {status: "active", tokenBudget: 100000}}); + let selected = "synthesis-1", realInventory = false; + await page.route("**/api/chat/sessions/*/loopx", async route => { + const body = route.request().postDataJSON(); + if (realInventory && body?.operation === "operations") { + const response = await page.request.post(`${origin}/api/chat/sessions/recovery/loopx`, {data: {operation: "operations"}}); + const actual = await response.json(); + return route.fulfill({json: {...actual, items: actual.items.filter(row => row.operation_id === "synthesis-1"), + has_more: false, next_cursor: null}}); + } + if (!["read", "revalidate"].includes(body?.operation)) return route.fallback(); + const response = await page.request.post(`${origin}/api/chat/sessions/recovery/loopx`, {data: { + operation: body.operation, operation_id: ["accepted-analysis", "synthesis-1"].includes(body.operation_id) ? selected : body.operation_id}}); + return route.fulfill({response}); + }); + await page.getByText("LoopX · 正在推进", {exact: true}).waitFor(); + realInventory = true; + const results = page.getByRole("region", {name: "团队成果", exact: true}); + await results.getByRole("button", {name: "刷新成果", exact: true}).click(); + await results.getByRole("button", {name: "reviewer · output.json", exact: true}).click(); + await results.getByLabel("当前报告", {exact: true}).waitFor(); + await page.getByRole("button", {name: "团队执行情况", exact: true}).click(); + const dialog = page.getByRole("dialog", {name: "团队执行情况"}); + await dialog.getByRole("button", {name: "查看证据与反馈", exact: true}).first().click(); + const evidence = dialog.getByRole("region", {name: "执行证据"}); + await evidence.getByLabel("证据内容: output.json").waitFor(); + assert.deepEqual((await command("withdraw")).host_calls, ["1", "1", "1"]); + const recheck = evidence.getByRole("button", {name: "重新读取证据", exact: true}); + await recheck.click(); + await evidence.getByText("接收方输入不可用", {exact: true}).waitFor(); + await evidence.getByRole("button", {name: "查看阻断来源", exact: true}).waitFor(); + assert.equal(await evidence.getByLabel("证据内容: output.json").count(), 0); + await mkdir(outputDir, {recursive: true}); + await page.screenshot({path: resolve(outputDir, "team-result-use-blocked.png")}); + await page.setViewportSize({width: 390, height: 844}); + assert.ok(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth)); + await page.screenshot({path: resolve(outputDir, "team-result-use-blocked-mobile.png")}); + await dialog.getByRole("button", {name: "关闭", exact: true}).click(); + await results.getByRole("button", {name: "刷新成果", exact: true}).click(); + await results.getByText("已检查的页面没有可读取的已验收产物。", {exact: true}).waitFor(); + assert.equal(await results.getByLabel("当前报告", {exact: true}).count(), 0); + assert.deepEqual((await command("restore")).host_calls, ["1", "1", "1"]); + await results.getByRole("button", {name: "刷新成果", exact: true}).click(); + await results.getByLabel("当前报告", {exact: true}).waitFor(); + await page.getByRole("button", {name: "团队执行情况", exact: true}).click(); + await dialog.getByRole("button", {name: "查看证据与反馈", exact: true}).first().click(); + // Wait for the reader's initial request and navigation focus before testing + // keyboard recheck; opening the view must not race its mount effects. + await evidence.getByLabel("证据内容: output.json").waitFor(); + await recheck.press("Enter"); + await evidence.getByLabel("证据内容: output.json").waitFor(); + assert.equal(await evidence.getByText("接收方输入不可用", {exact: true}).count(), 0); + const restored = await command("inspect"); + assert.equal(restored.operation.current_use.state, "current"); + assert.equal(restored.historical_unchanged, true); + assert.deepEqual(restored.host_calls, ["1", "1", "1"]); + console.log("PASS: packaged source withdrawal, current evidence recovery, immutable history and no redispatch"); +} catch (error) { + if (workspace) console.error({body: (await workspace.page.locator("body").innerText()).slice(-3000), errors: workspace.errors}); + throw error; +} finally { + await workspace?.close(); await browser?.close(); server?.kill("SIGTERM"); + fixture.stdin.end(); const [code] = await exited; lines.close(); assert.equal(code, 0, stderr); +} diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 3d42a62f0c..d8e2a44aae 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1127,7 +1127,7 @@ export function fetchLoopXMode(sessionId: string) { export type DelegationInventory = { items: Array<{record_id: string; operation_id: string | null; agent_id?: string; todo_id?: string; status: string; worker_active?: boolean; recovery_required: boolean | null; - artifacts?: Array<{ref: string; sha256: string}>}>; + artifacts?: Array<{ref: string; sha256: string}>; current_use?: DelegationReadback["current_use"]}>; has_more: boolean; next_cursor: string | null; page_readback_complete: boolean; }; export type {DelegationPreflight} from "./delegation-preflight.js"; @@ -1148,6 +1148,9 @@ export type DelegationReadback = { validation?: {source: "goal_acceptance" | "todo_validation"; basis_sha256: string; check_count: number; pinned_file_count: number; checked_at?: string; output_versions?: Array<{ref: string; sha256: string}>}; + current_use?: {state: "current" | "unavailable"; checked_operation_count: number; + reason?: "input_unavailable" | "source_unavailable" | "source_version_changed" | "dependency_cycle" | "verification_budget_exhausted"; + blocking_operation_id?: string; blocking_input_ref?: string; path?: string[]}; dependencies?: DelegationDependency[]; adoptions?: DelegationAdoption[]; }; export function readLoopXTeamWork(sessionId: string, operationId: string) { @@ -1179,11 +1182,12 @@ export function readManagedGoalResult(goalId: string, todoId: string) { } export type DelegationState = "unavailable" | "accepted" | "rejected" | "recovery_required" | "stopped" | "executing" | "validating" | "dispatched" | "unknown"; -type DelegationStateFacts = {status: string; worker_active?: boolean; recovery_required: boolean | null}; +type DelegationStateFacts = {status: string; worker_active?: boolean; recovery_required: boolean | null; + current_use?: DelegationReadback["current_use"]}; // Keep inventory and selected-operation labels consistent; unknown states stay unknown. export function delegationState(row: DelegationStateFacts): DelegationState { if (row.status === "unavailable") return "unavailable"; - if (row.status === "accepted") return "accepted"; + if (row.status === "accepted") return row.current_use?.state === "unavailable" ? "unavailable" : "accepted"; if (row.status === "rejected") return "rejected"; // A recorded stop precedes recovery: only the separate stop receipt proves // the original execution released its Host group, so the stored status alone diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-comparison.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-comparison.tsx index 3cf2a71757..4521f41f63 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-comparison.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-comparison.tsx @@ -17,7 +17,7 @@ export function GoalTeamComparison({sessionId, result, zh}: { setError(""); setBusy(false); return () => {generation.current++;}; }, [result, sessionId]); - const artifacts = result.status === "accepted" && !result.error && !result.recovery_required ? result.artifacts ?? [] : []; + const artifacts = result.status === "accepted" && result.current_use?.state !== "unavailable" && !result.error && !result.recovery_required ? result.artifacts ?? [] : []; const links = result.dependencies ?? []; const currentSelection = currentComparisonSelection(selection, result, sessionId); async function compare(link: DelegationDependency) { 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 bd9a51ff3d..ec1468438a 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 @@ -33,20 +33,20 @@ export function GoalTeamEpisode({sessionId, result, zh, onInspect, onObservation ]); const matches = (link: DelegationDependency, source: DelegationReadback) => link.state === "current" && source.operation_id === link.operation_id - && source.status === "accepted" && !source.recovery_required && !source.error + && source.status === "accepted" && source.current_use?.state !== "unavailable" && !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.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.status === "accepted" && revised.current_use?.state !== "unavailable" && 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)); - if (result.status !== "accepted" || result.error || result.recovery_required + if (result.status !== "accepted" || result.current_use?.state === "unavailable" || result.error || result.recovery_required || !matches(original!, first) || !matches(response!, challenge) || !responseBindsOriginal || !revisionMatchesObservation) { throw new Error("linked evidence unavailable"); @@ -59,7 +59,7 @@ export function GoalTeamEpisode({sessionId, result, zh, onInspect, onObservation ? 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 + const downstreamMatchesReceipt = downstream?.status === "accepted" && downstream.current_use?.state !== "unavailable" && !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 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 1f1b928182..c007002b03 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 @@ -122,7 +122,7 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres : } {result.error ?

{result.error}

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

{result.validation.source === "goal_acceptance" ? (zh ? "Goal 验收规则" : "Goal acceptance rules") @@ -139,7 +139,7 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres {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 =>
+ {result.status === "accepted" && result.current_use?.state !== "unavailable" && !result.error && !result.recovery_required && result.artifacts?.length ? result.artifacts.map(artifact =>
{zh ? "版本与来源标识" : "Version and source identifiers"} {operationId}{result.request_id}{result.todo_id} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-lineage.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-lineage.tsx index 8cf9ecdc56..da9334da30 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-lineage.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-lineage.tsx @@ -7,6 +7,18 @@ export function GoalTeamLineage({result, zh, onInspect}: { const relations = zh ? {responds_to: "回应此版本", revises: "修订此版本", uses: "使用此版本"} : {responds_to: "Respond to this version", revises: "Revise this version", uses: "Use this version"}; return
+ {result.current_use?.state === "unavailable" ?
+

{zh ? "历史执行已完成,当前来源链无法核验,暂不能作为后续任务的依据。" : "The execution completed historically; its source chain cannot currently be verified for downstream use."}

+

{({input_unavailable: zh ? "接收方输入不可用" : "Receiver input unavailable", + source_unavailable: zh ? "来源执行不可核验" : "Source execution unavailable", + source_version_changed: zh ? "来源版本已变化" : "Source version changed", + dependency_cycle: zh ? "来源关系存在循环" : "Dependency cycle", + verification_budget_exhausted: zh ? "本次核验达到上限" : "Verification limit reached"})[result.current_use.reason ?? "source_unavailable"]}

+ {result.current_use.blocking_input_ref ?

{zh ? "需核对的输入" : "Input to reconcile"}: {result.current_use.blocking_input_ref}

: null} + {result.current_use.path?.length ?

{result.current_use.path.join(" → ")}

: null} + {result.current_use.blocking_operation_id ? : null} +

{zh ? "修复来源或接收方输入后,重新读取证据;不会自动重跑已完成任务。" : "Repair the source or receiver input, then recheck evidence. Completed work is not automatically rerun."}

+
: null} {result.dependencies?.length ? <>

{zh ? "请求中的版本依据" : "Versions in the request"}

    {result.dependencies.map((link, index) =>
  • diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx index dc2ab35fa1..e72c0cad8d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx @@ -6,7 +6,7 @@ import {GoalTeamLineage} from "./goal-team-lineage"; type Selection = {operationId: string; ref?: string; sha256?: string}; const readableRows = (page: DelegationInventory) => page.items.filter(row => - row.operation_id && row.status === "accepted" && !row.recovery_required && row.artifacts?.length); + row.operation_id && row.status === "accepted" && row.current_use?.state !== "unavailable" && !row.recovery_required && row.artifacts?.length); const AUTO_DISCOVERY_PAGES = 3; const MAX_INSPECTED_PAGES = 10; @@ -34,7 +34,7 @@ export function GoalTeamResults({sessionId, zh, refreshKey}: {sessionId: string; const matches = expected?.ref ? artifacts.filter(row => row.ref === expected.ref) : []; const artifact = expected?.ref ? (matches.length === 1 && matches[0].sha256 === expected.sha256 ? matches[0] : undefined) : artifacts.find(row => isMarkdownArtifact(row.ref)) ?? artifacts[0]; - if (value.operation_id !== operationId || value.status !== "accepted" || value.error || value.recovery_required || !artifact) { + if (value.operation_id !== operationId || value.status !== "accepted" || value.current_use?.state === "unavailable" || value.error || value.recovery_required || !artifact) { setError(changed); } else { chosen.current = {operationId, ref: artifact.ref, sha256: artifact.sha256}; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/manager-team-result.tsx b/apps/presentation/dashboard/src/features/personal-workspace/manager-team-result.tsx index bf3e3fe96a..4d3910f688 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/manager-team-result.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/manager-team-result.tsx @@ -111,7 +111,7 @@ async function readAdoptedResult(goalId: string, todoIds: Set, force: bo if (!row.operation_id || !row.todo_id || !todoIds.has(row.todo_id) || row.status !== "accepted") continue; const source = await readLoopXTeamWork(session.sessionId, row.operation_id); if (source.operation_id !== row.operation_id || source.todo_id !== row.todo_id - || source.status !== "accepted" || source.recovery_required || source.error) { + || source.status !== "accepted" || source.current_use?.state === "unavailable" || source.recovery_required || source.error) { incomplete = true; continue; } @@ -137,7 +137,7 @@ async function readAdoptedResult(goalId: string, todoIds: Set, force: bo && consumer.request_id === adoption.consumer_request_id && consumer.agent_id === adoption.consumer_agent_id && consumer.todo_id === adoption.consumer_todo_id - && consumer.status === "accepted" && !consumer.recovery_required && !consumer.error && artifact) { + && consumer.status === "accepted" && consumer.current_use?.state !== "unavailable" && !consumer.recovery_required && !consumer.error && artifact) { const key = `${session.sessionId}:${consumer.operation_id}`; const earlier = adopted.get(key); if (!earlier || (!isMarkdownArtifact(earlier.artifact.ref) && isMarkdownArtifact(artifact.ref))) { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/team-artifact-comparison.ts b/apps/presentation/dashboard/src/features/personal-workspace/team-artifact-comparison.ts index 8a80648454..f616c5ded6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/team-artifact-comparison.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/team-artifact-comparison.ts @@ -13,7 +13,7 @@ export type ComparisonSelection = { /** Preserve reading context only while both exact version bindings remain current. */ export function currentComparisonSelection(selection: ComparisonSelection | null, result: DelegationReadback, sessionId: string): ComparisonSelection | null { - if (!selection || selection.sessionId !== sessionId || result.status !== "accepted" || + if (!selection || selection.sessionId !== sessionId || result.status !== "accepted" || result.current_use?.state === "unavailable" || result.error || result.recovery_required || selection.target.operation_id !== result.operation_id || selection.target.request_id !== result.request_id || selection.target.agent_id !== result.agent_id || selection.target.todo_id !== result.todo_id) return null; @@ -37,7 +37,7 @@ export function preferredComparisonIndex(ref: string, artifacts: Artifact[]): nu /** Compare only the exact source version bound to the request, never a newer file. */ export function comparisonSource(link: DelegationDependency, source: DelegationReadback): Artifact | null { if (link.state !== "current" || source.operation_id !== link.operation_id || - source.status !== "accepted" || source.recovery_required || source.error) return null; + source.status !== "accepted" || source.current_use?.state === "unavailable" || source.recovery_required || source.error) return null; const matches = (source.artifacts ?? []).filter(row => row.ref === link.ref && row.sha256 === link.sha256); return matches.length === 1 ? matches[0] : null; } diff --git a/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.md b/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.md index 7d92d0726c..724938125a 100644 --- a/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.md +++ b/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.md @@ -308,7 +308,7 @@ The table tracks independent composition boundaries within M2/M3 and roadmap R2/ | Bounded outcome | Existing entry and owner | Current checkpoint and decisive exit | | --- | --- | --- | -| Unavailable declared ancestry prevents new dependent use | `Delegations._read_current/start`, `delegation_results.require_dependencies/adoption_evidence`, `delegation.ts` | Alignment §3.8: source → A → B with A input invalidated must refuse current dependent use even if A output is unchanged. Qualify real read/start/adopt/settlement and packaged evidence readback; keep historical completion. | +| Unavailable declared ancestry prevents current use | `Delegations.read/start`, `delegation_results.py`, `delegation_result_use.ts` | `current_use` separates historical completion from present eligibility. New dispatch, adoption and settlement share ancestry checks; same-operation replay only reads back. Real File/SQLite three-level counterexamples and the packaged team reader cover withdrawal, cause/input references and restored readback. | | Independent check failure reaches an actionable original-task recovery journey | `executor._task_validation_stage`, `ValidatedTurnReceipt`, canonical `turn_loop_controller_contract_v0.json`, `turn_journal.ts` | Preserve qualified failure scope and repair/replan detail; complete bounded repair or verifier-only retry, current validation and original-effect settlement. Host assertions cannot become trusted validation. | | Shadow review explains criteria and coverage | progress-review receipt/context, `progress_review_evidence.ts`, canonical acceptance inspect | Select current canonical task criteria; exact GoalRef preserves instance identity, while changed tasks or recreated instances withdraw old judgments. Independent older cores retain only the legacy manual-study shape; canonical scope never downgrades. Show separate dimensions, declared-file net change, missing evidence and unreadable storage. Default off and existing assist trigger rules retain their semantics. | @@ -343,6 +343,13 @@ complete the journey. Bound repeated verification and source-chain traversal using measured workloads. Roll back code through its owner while retaining receipts, completed effects and unresolved recovery obligations. +One ancestry admission reads at most 64 operations and 16 levels, merging +repeated source reads by operation. The 15-second elapsed budget stops starting +further checks; an already running validator retains its configured timeout. +This is not a 15-second HTTP deadline. Exhaustion is unavailable, never saved +success. Coverage is explicit local delegation inputs, not undeclared memory or +arbitrary reasoning; multiple sources are not an atomic snapshot. + An optional `acceptance_scope` refers only to registry/runtime paths inside the selected workspace. Criteria come from the current owner; private commands and paths stay out of model questions. Hashes, versions and declared coverage are diff --git a/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.zh-CN.md b/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.zh-CN.md index e24de651af..e370e242af 100644 --- a/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.zh-CN.md +++ b/docs/architecture/rfcs/composable-state-machines-recovery-verification-v0.zh-CN.md @@ -253,7 +253,7 @@ PostgreSQL authority、successor 调度和 App/Lark 送达尚未覆盖。因此 | 有界结果 | 既有入口与 owner | 当前 checkpoint 与决定性出口 | | --- | --- | --- | -| 声明来源链不可用时,阻止新的依赖使用 | `Delegations._read_current/start`、`delegation_results.require_dependencies/adoption_evidence`、`delegation.ts` | 遵循对齐 §3.8:source → A → B 中 A input 失效,即使 A output 不变也须拒绝当前依赖使用;验证真实 read/start/adopt/settlement 和打包证据读回,保留历史完成。 | +| 声明来源链失效阻断当前使用 | `Delegations.read/start`、`delegation_results.py`、`delegation_result_use.ts` | `current_use` 区分历史完成和当前资格。新派发、采用与结算共用来源链检查;原 operation 重放只读回。File/SQLite 三层来源反例和打包团队证据页验证撤回、原因、输入引用与修复后重读。 | | 独立检查失败进入原任务可操作的恢复旅程 | `executor._task_validation_stage`、`ValidatedTurnReceipt`、canonical `turn_loop_controller_contract_v0.json`、`turn_journal.ts` | 传递已资格化失败范围与 repair/replan 细节;完成有界修复或只运行 verifier 的重试、当前验证与原效果结算。Host 声明不能冒充可信验证。 | | shadow 说明条款及观察范围 | progress-review receipt/context、`progress_review_evidence.ts`、canonical acceptance inspect | 可选择任务当前规范 criterion;精确 GoalRef 保持实例身份,任务变化或实例重建撤回旧判断。独立旧核心只保留手工研究的旧格式;规范 scope 不降级。显示独立维度、净文件变化覆盖、缺失及存储未知。默认 off 和 assist 既有触发规则保持不变。 | @@ -279,6 +279,11 @@ result,只重跑 validation,不再次调用 Host。**实际修复工作**需 复制命令按钮或后端 receipt 不能独自完成旅程。用实际测量限制重复验证和来源链遍历 成本;经既有 owner 回退代码,同时保留 receipt、已提交效果和未解决恢复义务。 +来源链每次准入最多读取 64 个 operation、16 层,按 operation 合并重复来源读取。 +15 秒经过时间预算停止发起后续检查;已启动的 validator 仍受其配置超时约束, +这不是 15 秒 HTTP 响应承诺。预算耗尽返回不可用,不以缓存成功回退。该路径只 +覆盖明确声明的本地委派输入;跨来源不是原子快照,未声明的记忆或任意推理仍不在范围内。 + 规范审查 basis 的 `acceptance_scope` 只引用选定 workspace 内的 registry/runtime, 条款内容从当前 owner 读取,原命令及私有路径不进入模型问题。criterion hash、 版本和覆盖是来源观察,不证明模型答案正确、完整业务 checkpoint 或全部任务完成。 diff --git a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md index 68b60e3e50..b197461400 100644 --- a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md +++ b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.md @@ -377,38 +377,30 @@ new context delivery, not erasure of all previous sessions or proof that a model understood the correction. Already committed external effects retain their own reconciliation or explicitly authorized compensation contract. -**Implementation boundary, source `44931b6d22a50b949d43354e6ea498fb6b68d231`:** -[`Delegations._read_current`](../../../loopx/collaboration_mcp.py) rechecks an -accepted operation's own rules and outputs; `read` subsequently attaches -[`result_relationships`](../../../loopx/control_plane/collaboration/delegation_results.py). -That operation can remain accepted while an incoming dependency is unavailable. -`dependencies` reads a source through `_read_current`, so this source's own -incoming dependency is not transitively checked. A bounded file-I/O diagnostic -with synthetic local validation reproduced this for source → A → B: A's input -changed, its output stayed unchanged, A showed an unavailable dependency and B's -direct use still qualified. This is not a live worker or canonical-store test. - -The nearest implementation slice qualifies **current use of explicitly linked -local delegation results**, while retaining historical accepted/done receipts. -Reuse [`delegation.ts`](../../../loopx/control_plane/collaboration/delegation.ts) -for typed eligibility and `delegation_results.py` for host observations. Apply -the same current-use check to read, start, adoption and settlement; do not change -`todo_done` into recursive artifact validation. Traverse only requester-scoped, -declared links, with per-read cycle detection, shared-source memoization and -measured depth/operation/time bounds. An unvisited tail is unavailable, never -assumed accepted. Cache no success across admissions. This does not provide an -atomic snapshot against arbitrary concurrent file writers. - -Qualify direct and transitive invalidation, a shared-source diamond, cycles, -source loss, unchanged erroneous content caught by a real declared validator, -and invalidation between start and settlement. Verify no-link parity, unchanged -historical records, bounded verifier cost and recovery without duplicate worker -dispatch. The packaged team/evidence view must show current unavailability and -its reason, allow a current recheck after authorized source/input repair and -read the result back. If repair requires new execution, use a separately admitted -task; rechecking an accepted operation must not redispatch its worker. -CLI/MCP/Chat share the same owner. Full Goal-wide reverse propagation, -all-session context correction and business rollback remain outside this slice. +**Local implementation checkpoint:** the direct-only gap observed at +`44931b6d22a50b949d43354e6ea498fb6b68d231` is addressed by the candidate +[`delegation_result_use.ts`](../../../loopx/control_plane/collaboration/delegation_result_use.ts) +owner and [`delegation_results.py`](../../../loopx/control_plane/collaboration/delegation_results.py) +host observations. Read, start, adoption and settlement qualify explicit, +requester-scoped ancestry. Historical accepted/done records remain unchanged. +Collection has per-admission memoization, cycle detection and bounds of 64 +operations, depth 16 and a 15-second elapsed budget; the budget stops new checks, +not an already running validator. Missing or unvisited evidence is unavailable. +No success is cached across admissions, and this is not an atomic file snapshot. +Admission and adoption consume the aggregate result; expiry after per-link checks +also withdraws those links instead of preserving early success. + +[`test_delegation_result_use.py`](../../../tests/test_delegation_result_use.py) +exercises real local workers, independent validators and both File/SQLite stores: +source → A → B withdraws current use when A's input changes, refuses downstream +start/adoption, retains history and recovers after repair without redispatching +accepted workers. Typed tests cover shared sources, cycles and exhausted bounds. +The packaged team/evidence journey reads these results through production HTTP, +withdraws the affected report, exposes its cause and rechecks after repair. +CLI/MCP/Chat share the same owner; usage is in +[local delegation](../../reference/local-delegation.md#repair-and-recheck). +This is a locally validated candidate, not installed behavior or full Goal-wide +reverse propagation, session correction, business rollback or cross-host HA. ## 4. Authority matrix diff --git a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md index bdf42a0ef5..e037d9ad4b 100644 --- a/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-alignment-and-governed-amendment-v0.zh-CN.md @@ -314,31 +314,25 @@ R1/R4/R5 与 S11,本 RFC 不隐式开启新的 acceptance policy。下一完 不代表清除全部旧 session,也不证明模型理解了纠正。已经提交的外部 effect 继续 服从自己的对账或显式授权补偿合同。 -**实现边界,源码 `44931b6d22a50b949d43354e6ea498fb6b68d231`:** -[`Delegations._read_current`](../../../loopx/collaboration_mcp.py)重验 accepted -operation 自己的规则与输出;`read` 随后才附加 -[`result_relationships`](../../../loopx/control_plane/collaboration/delegation_results.py)。 -它可以保持 accepted,同时 incoming dependency 已 unavailable。`dependencies` -通过 `_read_current` 读取 source,因而不继续检查 source 自己的 incoming dependency。 -有界文件 I/O 诊断配合 synthetic local validation 复现了 source → A → B:A input -改变、output 不变,A 显示依赖不可用,B 的直接使用仍合格。该诊断不是 live worker -或 canonical store 测试。 - -最近的实现切片是:资格化**显式关联的本机委派结果的当前使用**,保留历史 accepted/ -done receipt。复用 [`delegation.ts`](../../../loopx/control_plane/collaboration/delegation.ts) -决定 typed eligibility,`delegation_results.py` 采集 host 观察。在 read、start、 -adoption 与 settlement 使用同一 current-use 检查;不把 `todo_done` 改成递归产物 -验证。只沿 requester 范围内的声明关系,采用单次读取的循环检测、共享来源缓存及 -经测量的深度/operation/时间界限。未访问的尾部保持 unavailable,不能假定 accepted; -不跨准入缓存成功。对任意并发文件 writer,不声称原子快照。 - -验收覆盖直接/传递失效、共享来源菱形、循环、来源丢失、真实声明 validator 发现 -字节不变的错误内容,以及 start 后 settlement 前失效。验证无关联输入行为等价、 -历史记录不变、verifier 成本有界、恢复不重复派发 worker。打包 team/evidence 视图 -须显示当前不可用及原因,在获授权修复来源/输入后支持当前复验并读回结果。 -修复若需要新的执行,使用另行准入的任务;复验 accepted operation 不得重新派发其 -worker。CLI/MCP/Chat 共用 owner。全 Goal 反向传播、全部 session context 纠正与 -业务回滚仍在该切片之外。 +**本地实现检查点:** 在 `44931b6d22a50b949d43354e6ea498fb6b68d231` +观察到的直接来源检查缺口,已有候选实现: +[`delegation_result_use.ts`](../../../loopx/control_plane/collaboration/delegation_result_use.ts) +拥有判定,[`delegation_results.py`](../../../loopx/control_plane/collaboration/delegation_results.py) +采集 host 观察。read、start、adoption 与 settlement 资格化 requester 范围内的 +显式来源链,保留历史 accepted/done 记录。每次准入独立缓存来源观察并检测循环, +界限为 64 个 operation、16 层、15 秒经过时间预算;预算停止后续检查,不中止已 +运行的 validator。缺失与未访问证据均 unavailable;不跨准入缓存成功,不声称原子 +文件快照。准入与采用消费整体验证结果;逐项检查之后预算耗尽,也会撤回此前 +的 link 成功,避免读回与准入分歧。 + +[`test_delegation_result_use.py`](../../../tests/test_delegation_result_use.py) +通过真实本机 worker、独立 validator 和 File/SQLite 存储验证 source → A → B: +A input 变化时撤回当前使用,拒绝下游 start/adoption,保留历史,修复后恢复且不重复 +派发 accepted worker。typed 测试覆盖共享来源、循环与预算耗尽。打包 team/evidence +经真实 HTTP 读回,撤下受影响报告、显示原因,并在修复后复验。CLI/MCP/Chat 共用 +owner;操作见[本地委派](../../reference/local-delegation.md#repair-and-recheck)。 +这是本地验证的候选实现,不等于已安装行为;全 Goal 反向传播、全部 session 纠正、 +业务回滚与跨主机 HA 仍在边界之外。 ## 4. Authority matrix diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index 0a8b746426..eaeafa65b7 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -136,6 +136,21 @@ workspace、Goal、registry、runtime 与 operator execution configuration;模 Todo 或外部动作权限。下方 shell 命令继续作为既有 Session、无 MCP host 或显式 shell-only 协调的兼容入口;两种入口复用同一个 `Delegations` 服务和同一套验收规则。 +## Source use and recheck + +`read` qualifies the current use of declared source operations separately from +historical acceptance. A result with `current_use.state` +`unavailable` cannot supply current evidence for another launch or adoption. +The team evidence view shows the blocking input/source and withdraws its report. +Repair the declared input/source under its existing authority, then use `read` +or the view's recheck. That read runs validators; it never redispatches an +accepted worker. Traversal is limited to 64 operations, 16 levels and a +15-second elapsed budget for starting checks. An already running validator keeps +its configured timeout. Undeclared assumptions and concurrent file snapshots +are outside this check. + +中文:历史 accepted 与当前来源资格分开;获授权修复声明输入或来源后,重新读取恢复资格,不重派已验收 worker。 + ## Use an existing Agent conversation through its shell An attached Codex or other shell-capable Agent can use the same execution diff --git a/loopx/chat_loopx_mode.py b/loopx/chat_loopx_mode.py index b9d3ef1202..a8f1210aac 100644 --- a/loopx/chat_loopx_mode.py +++ b/loopx/chat_loopx_mode.py @@ -67,9 +67,9 @@ "identity and authorized execution catalog. Use loopx_context_read for fresh Goal facts and " "loopx_collaboration to organize actual member work. Decide questions, ordering and recovery " "yourself; no business-phase script is provided. Members may delegate through the same " - "service when authorized. Only current accepted results returned by that service establish " + "service when authorized. Only currently usable accepted results returned by that service establish " "member completion. Synthesize their actual artifacts and report remaining gaps here. " - "Read action=messages between work steps for owner inbox additions. " + "When current_use.state=unavailable, repair the declared source or receiver input and read again; do not propagate those artifacts as current facts. Read action=messages between work steps for owner inbox additions. " "Do not claim the whole canonical Goal is complete. Keep independent analysis substantive. " "Before completing or blocking, return a self-contained report with substantive accepted findings, " "exact artifact hashes and remaining gaps; do not require readers to reconstruct earlier streamed replies. " diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts index 91dfeff6ac..5829cc176e 100644 --- a/loopx/control_plane/collaboration/delegation.ts +++ b/loopx/control_plane/collaboration/delegation.ts @@ -379,6 +379,11 @@ export function delegationInventoryItem(params: JsonObject): JsonObject { if (observation.status === "accepted") { requireThat(Array.isArray(observation.artifacts) && observation.artifacts.length > 0, "accepted inventory requires current artifacts"); + if (observation.current_use !== undefined) { + const use = requireJsonObject(observation.current_use, "delegation current use"); + requireThat(use.state === "current" || use.state === "unavailable", "invalid delegation current use"); + result.current_use = use; + } result.artifacts = observation.artifacts.map(value => { const artifact = requireJsonObject(value, "accepted artifact"); requireThat(text(artifact.ref) && typeof artifact.sha256 === "string" diff --git a/loopx/control_plane/collaboration/delegation_result_use.ts b/loopx/control_plane/collaboration/delegation_result_use.ts new file mode 100644 index 0000000000..15c7a1f643 --- /dev/null +++ b/loopx/control_plane/collaboration/delegation_result_use.ts @@ -0,0 +1,105 @@ +/** Current use of explicit local delegation sources; never rewrites completion. + * Host adapters collect authorized current observations. This owner alone decides + * version agreement and transitive eligibility; absence is not a saved success. */ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; + +export type DelegationResultUseState = "current" | "unavailable"; +export type DelegationResultUseReason = "input_unavailable" | "source_unavailable" + | "source_version_changed" | "dependency_cycle" | "verification_budget_exhausted"; +export type DelegationResultUse = {state: DelegationResultUseState; checked_operation_count: number; + reason?: DelegationResultUseReason; blocking_operation_id?: string; blocking_input_ref?: string; path?: string[]}; +type Input = {operation_id: string; ref: string; sha256: string; input_ref: string; input_available: boolean}; +type Node = {operation_id: string; accepted: boolean; artifacts: {ref: string; sha256: string}[]; inputs: Input[]}; +export const DELEGATION_RESULT_USE_MAX_OPERATIONS = 64; +export const DELEGATION_RESULT_USE_MAX_DEPTH = 16; + +export function delegationResultUseLimits(): JsonObject { + return {max_operations: DELEGATION_RESULT_USE_MAX_OPERATIONS, + max_depth: DELEGATION_RESULT_USE_MAX_DEPTH, max_seconds: 15}; +} + +function requireThat(value: unknown, message: string): asserts value { + if (!value) throw new EffectRuntimeRequestError(message); +} +function operation(value: unknown): string { + requireThat(typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(value), + "valid delegation operation required"); + return value; +} +function reference(value: unknown): string { + requireThat(typeof value === "string" && value.length > 0 && value.length <= 1024 + && !value.startsWith("/") && !value.includes("\\") && !value.split("/").includes(".."), + "local delegation artifact reference required"); + return value; +} +function version(value: unknown): string { + requireThat(typeof value === "string" && BARE_SHA256_PATTERN.test(value), "delegation version required"); + return value; +} +function inputs(value: unknown): Input[] { + requireThat(Array.isArray(value) && value.length <= 32, "bounded delegation inputs required"); + return value.map(raw => { + const row = requireJsonObject(raw, "delegation source input"); + requireThat(typeof row.input_available === "boolean", "current input observation required"); + return {operation_id: operation(row.operation_id), ref: reference(row.ref), + sha256: version(row.sha256), input_ref: reference(row.input_ref), input_available: row.input_available}; + }); +} +export function qualifyDelegationResultUse(params: JsonObject): DelegationResultUse { + const roots = inputs(params.roots); + requireThat(Array.isArray(params.nodes) && params.nodes.length <= DELEGATION_RESULT_USE_MAX_OPERATIONS, + "bounded delegation source observations required"); + requireThat(params.truncated === undefined || typeof params.truncated === "boolean", "collection outcome required"); + const nodes = new Map(); + for (const raw of params.nodes) { + const row = requireJsonObject(raw, "delegation source observation"); + const id = operation(row.operation_id); + requireThat(!nodes.has(id), "duplicate delegation source observation"); + requireThat(typeof row.accepted === "boolean" && Array.isArray(row.artifacts) && row.artifacts.length <= 20, + "current source acceptance and artifacts required"); + nodes.set(id, {operation_id: id, accepted: row.accepted, + artifacts: row.artifacts.map(rawArtifact => { + const artifact = requireJsonObject(rawArtifact, "delegation source artifact"); + return {ref: reference(artifact.ref), sha256: version(artifact.sha256)}; + }), inputs: inputs(row.inputs)}); + } + const checked = new Set(); + const complete = new Set(); + const visiting = new Set(); + const unavailable = (reason: DelegationResultUseReason, path: string[]): DelegationResultUse => ({ + state: "unavailable", reason, blocking_operation_id: path.at(-1), path, + checked_operation_count: checked.size, + }); + function visit(input: Input, path: string[]): DelegationResultUse | null { + // The receiver's input is part of each edge, even for a memoized source. + if (!input.input_available) return {...unavailable("input_unavailable", path), blocking_input_ref: input.input_ref}; + const next = [...path, input.operation_id]; + if (next.length > DELEGATION_RESULT_USE_MAX_DEPTH) return unavailable("verification_budget_exhausted", next); + if (visiting.has(input.operation_id)) return unavailable("dependency_cycle", next); + const source = nodes.get(input.operation_id); + if (!source) return unavailable(params.truncated === true ? "verification_budget_exhausted" : "source_unavailable", next); + checked.add(input.operation_id); + if (!source.accepted) return unavailable("source_unavailable", next); + if (!source.artifacts.some(row => row.ref === input.ref && row.sha256 === input.sha256)) + return unavailable("source_version_changed", next); + if (complete.has(input.operation_id)) return null; + visiting.add(input.operation_id); + for (const ancestor of source.inputs) { + const failure = visit(ancestor, next); + if (failure) return failure; + } + visiting.delete(input.operation_id); + complete.add(input.operation_id); + return null; + } + for (const input of roots) { + const failure = visit(input, []); + if (failure) return failure; + } + if (params.truncated === true) return unavailable("verification_budget_exhausted", + roots.length ? [roots[0].operation_id] : []); + return {state: "current", checked_operation_count: checked.size}; +} diff --git a/loopx/control_plane/collaboration/delegation_results.py b/loopx/control_plane/collaboration/delegation_results.py index 34b2956051..e21098c240 100644 --- a/loopx/control_plane/collaboration/delegation_results.py +++ b/loopx/control_plane/collaboration/delegation_results.py @@ -11,6 +11,8 @@ import os from pathlib import Path import stat +import time +from collections import deque from .inbox import _entry, _read, _write from .peers import input_readiness, require_operation_id @@ -60,39 +62,86 @@ def operation_brief(service, row): return _entry(service.root, service.goal_id, identity["binding"]["agent_id"], identity["request_id"])["brief"] -def dependencies(service, binding, brief): - inputs = [item for item in brief["inputs"] if "delegation" in item] - if not inputs: - return [] - # The operator-owned binding already authorizes the managed worker's - # absolute workspace. It may intentionally live in another repository, - # unlike an ambient peer-inbox workspace which still requires a canonical - # project alias before it can replace the Goal root. - materials = input_readiness( - service.registry, - service.goal_id, - {"inputs": inputs}, - workspace=binding["workspace"], - configured_workspace=True, - ) - result = [] - for item, material in zip(inputs, materials, strict=True): - link = item["delegation"] - current = False - try: - source = service._read_current(link["operation_id"]) - current = source["status"] == "accepted" and material["status"] == "available" and any( - artifact["ref"] == link["ref"] and artifact["sha256"] == item["sha256"] - for artifact in source.get("artifacts", [])) - except (OSError, ValueError, KeyError, EffectRuntimeRemoteError): - pass # Preserve the reference, never a saved success or private exception text. - result.append({**link, "sha256": item["sha256"], "input_ref": item["ref"], - "state": "current" if current else "unavailable"}) - return result +class _ResultUseRead: + """One admission's authorized host IO, with no eligibility decision cache.""" + + def __init__(self, service): + self.service = service + self.limits = effect_runtime_result("collaboration.delegation.result_use_limits", {}) + self.deadline = time.monotonic() + self.limits["max_seconds"] + self.nodes = {} + self.truncated = False + + def inputs(self, binding, brief): + inputs = [item for item in brief["inputs"] if "delegation" in item] + if not inputs: + return [] + materials = input_readiness( + self.service.registry, self.service.goal_id, {"inputs": inputs}, + workspace=binding["workspace"], configured_workspace=True, + ) + return [{**item["delegation"], "sha256": item["sha256"], "input_ref": item["ref"], + "input_available": material["status"] == "available"} + for item, material in zip(inputs, materials, strict=True)] + + def collect(self, roots): + pending = deque((item["operation_id"], 1) for item in roots) + while pending: + operation, depth = pending.popleft() + if operation in self.nodes: + continue + if (len(self.nodes) >= self.limits["max_operations"] + or depth > self.limits["max_depth"] or time.monotonic() >= self.deadline): + self.truncated = True + continue + # path/_bound enforce the requester's existing operation grant. + node = {"operation_id": operation, "accepted": False, "artifacts": [], "inputs": []} + self.nodes[operation] = node + try: + source = self.service._read_current(operation) + row = _read(self.service.path(operation)) + binding = self.service._bound(row) + node.update(accepted=source["status"] == "accepted", + artifacts=[{"ref": item["ref"], "sha256": item["sha256"]} + for item in source.get("artifacts", [])], + inputs=self.inputs(binding, operation_brief(self.service, row))) + pending.extend((item["operation_id"], depth + 1) for item in node["inputs"]) + except (OSError, ValueError, KeyError, EffectRuntimeRemoteError): + # Only the typed owner interprets unavailable observations. + # Private paths, validator output and exception text never escape. + node.update(accepted=False, artifacts=[], inputs=[]) + if time.monotonic() > self.deadline: + self.truncated = True + + def qualify(self, roots): + return effect_runtime_result("collaboration.delegation.result_use", { + "roots": roots, "nodes": list(self.nodes.values()), "truncated": self.truncated, + }) + + +def _dependency_observation(service, binding, brief): + if not any("delegation" in item for item in brief["inputs"]): + return [], None # Ordinary reads preserve their existing wire shape. + current = _ResultUseRead(service) + roots = current.inputs(binding, brief) + current.collect(roots) + links = [] + for item in roots: + decision = current.qualify([item]) + links.append({key: value for key, value in item.items() if key != "input_available"} + | {"state": decision["state"]}) + # Account for time spent qualifying links before the admission decision. + current.truncated |= time.monotonic() >= current.deadline + aggregate = current.qualify(roots) + if current.truncated: + for link in links: + link["state"] = aggregate["state"] + return links, aggregate def require_dependencies(service, binding, brief): - if any(row["state"] != "current" for row in dependencies(service, binding, brief)): + _, aggregate = _dependency_observation(service, binding, brief) + if aggregate is not None and aggregate["state"] != "current": raise ValueError("delegation input version unavailable; reconcile source and receiver input") @@ -104,10 +153,10 @@ def adoption_evidence(service, operation_id, consumer_operation_id): row = _read(service.path(consumer_operation_id)) binding = service._bound(row) brief = operation_brief(service, row) - links = dependencies(service, binding, brief) + links, aggregate = _dependency_observation(service, binding, brief) return effect_runtime_result("collaboration.delegation.adoption", { "source": source, "consumer": consumer, "inputs": brief["inputs"], - "inputs_current": bool(links) and all(link["state"] == "current" for link in links), + "inputs_current": bool(links) and aggregate["state"] == "current", }) @@ -133,7 +182,7 @@ def adopt_result(service, operation_id, consumer_operation_id): def result_relationships(service, operation_id): row = _read(service.path(operation_id)) binding = service._bound(row) - links = dependencies(service, binding, operation_brief(service, row)) + links, current_use = _dependency_observation(service, binding, operation_brief(service, row)) adoptions = [] for recorded in row.get("adoptions", []): current = False @@ -144,4 +193,5 @@ def result_relationships(service, operation_id): adoptions.append({**recorded, "requester_agent_id": service.agent_id, "state": "current" if current else "unavailable"}) # Preserve feature-off shape; ordinary reads never gain an inferred relationship. - return {**({"dependencies": links} if links else {}), **({"adoptions": adoptions} if adoptions else {})} + return {**({"dependencies": links, "current_use": current_use} if links else {}), + **({"adoptions": adoptions} if adoptions else {})} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index da920408e9..f5ec400d14 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -605,6 +605,8 @@ export function createEffectRuntimeHandlers( ["collaboration.delegation.observe_wake", lazyHandler(() => import("./collaboration/delegation.ts"), ({decideDelegationWakeObservation}) => decideDelegationWakeObservation)], ["collaboration.delegation.recover_validated_settlement", lazyHandler(() => import("./collaboration/delegation.ts"), ({recoverValidatedDelegationSettlement}) => recoverValidatedDelegationSettlement)], ["collaboration.delegation.stop", lazyHandler(() => import("./collaboration/delegation.ts"), ({decideDelegationStop}) => decideDelegationStop)], + ["collaboration.delegation.result_use_limits", lazyHandler(() => import("./collaboration/delegation_result_use.ts"), ({delegationResultUseLimits}) => delegationResultUseLimits)], + ["collaboration.delegation.result_use", lazyHandler(() => import("./collaboration/delegation_result_use.ts"), ({qualifyDelegationResultUse}) => qualifyDelegationResultUse)], ["progress_review.evidence_scope", lazyHandler(() => import("./work_items/progress_review_evidence.ts"), ({progressReviewEvidenceScope}) => progressReviewEvidenceScope)], ["progress_review.criterion_basis", lazyHandler(() => import("./work_items/progress_review_evidence.ts"), ({progressReviewCriterionBasis}) => progressReviewCriterionBasis)], ["collaboration.delegation.adoption", lazyHandler(() => import("./collaboration/delegation.ts"), ({recordDelegationAdoption}) => recordDelegationAdoption)], diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 083f00d281..a06658731c 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -94,6 +94,7 @@ const CANONICAL_CONSUMERS = [ "control_plane/capabilities/external_evidence.ts", "control_plane/collaboration/chat_mode.ts", "control_plane/collaboration/delegation.ts", + "control_plane/collaboration/delegation_result_use.ts", "control_plane/collaboration/goal_instance_lifecycle.ts", "control_plane/collaboration/inbox_receipts.ts", "control_plane/collaboration/result_publication.ts", diff --git a/tests/control_plane_ts/delegation_result_use.test.ts b/tests/control_plane_ts/delegation_result_use.test.ts new file mode 100644 index 0000000000..26ad01625b --- /dev/null +++ b/tests/control_plane_ts/delegation_result_use.test.ts @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {qualifyDelegationResultUse} from "../../loopx/control_plane/collaboration/delegation_result_use.ts"; + +const hash = "a".repeat(64); +const input = (operation_id: string) => ({operation_id, ref: "output.json", sha256: hash, + input_ref: "source.json", input_available: true}); +const node = (operation_id: string, inputs: ReturnType[] = []) => ({operation_id, + accepted: true, artifacts: [{ref: "output.json", sha256: hash}], inputs}); + +test("a consumer cannot reuse accepted output with unavailable declared ancestry", () => { + const nodes = [node("source"), node("middle", [{...input("source"), input_available: false}]), + node("consumer", [input("middle")])]; + const result = qualifyDelegationResultUse({roots: [input("consumer")], nodes}); + assert.equal(result.state, "unavailable"); + assert.equal(result.reason, "input_unavailable"); + assert.equal(result.blocking_operation_id, "middle"); + assert.equal(result.blocking_input_ref, "source.json"); + const receiver = qualifyDelegationResultUse({roots: [{...input("source"), input_available: false}], nodes: []}); + assert.equal(receiver.blocking_operation_id, undefined, "A receiver input failure must not blame the source execution"); + assert.equal(receiver.blocking_input_ref, "source.json"); + assert.deepEqual(result.path, ["consumer", "middle"]); + assert.equal(qualifyDelegationResultUse({roots: [input("consumer")], nodes: nodes.map(row => + row.operation_id === "middle" ? node("middle", [input("source")]) : row)}).state, "current"); +}); + +test("missing source, wrong version and cycles are unavailable; siblings cannot hide them", () => { + assert.equal(qualifyDelegationResultUse({roots: [input("missing")], nodes: []}).reason, "source_unavailable"); + assert.equal(qualifyDelegationResultUse({roots: [{...input("source"), sha256: "b".repeat(64)}], + nodes: [node("source")]}).reason, "source_version_changed"); + assert.equal(qualifyDelegationResultUse({roots: [input("a")], + nodes: [node("a", [input("b")]), node("b", [input("a")])]}).reason, "dependency_cycle"); + assert.equal(qualifyDelegationResultUse({roots: [input("source"), input("missing")], + nodes: [node("source")]}).state, "unavailable"); +}); + +test("incomplete collection is never acceptance, and a diamond verifies each source once", () => { + assert.equal(qualifyDelegationResultUse({roots: [input("source")], nodes: [], + truncated: true}).reason, "verification_budget_exhausted"); + const result = qualifyDelegationResultUse({roots: [input("left"), input("right")], + nodes: [node("source"), node("left", [input("source")]), node("right", [input("source")])]}); + assert.equal(result.state, "current"); + assert.equal(result.checked_operation_count, 3); + assert.throws(() => qualifyDelegationResultUse({roots: [input("source")], + nodes: [node("source"), node("source")]}), /duplicate/); +}); + + test("collection deadline and depth cannot become current; existing long IDs remain valid", () => { + const id = "a".repeat(160); + assert.equal(qualifyDelegationResultUse({roots: [input(id)], nodes: [node(id)]}).state, "current"); + assert.equal(qualifyDelegationResultUse({roots: [input("a")], nodes: [node("a")], + truncated: true}).reason, "verification_budget_exhausted"); + const nodes = Array.from({length: 17}, (_, i) => node(`op-${i}`, i < 16 ? [input(`op-${i + 1}`)] : [])); + assert.equal(qualifyDelegationResultUse({roots: [input("op-0")], nodes}).reason, "verification_budget_exhausted"); +}); diff --git a/tests/presentation/team_result_use_fixture.py b/tests/presentation/team_result_use_fixture.py new file mode 100644 index 0000000000..d6e4a44d89 --- /dev/null +++ b/tests/presentation/team_result_use_fixture.py @@ -0,0 +1,74 @@ +"""Disposable production delegation + Chat HTTP for packaged recovery validation.""" +from pathlib import Path +import json +import sys +import tempfile +import threading +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import pytest +from test_local_delegation import service +from test_delegation_result_use import accepted_chain +from loopx.chat_runtime import ChatRuntimeController +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.chat_store import ChatSessionStore + + +def main(): + cleanups = [] + patch = pytest.MonkeyPatch() + with tempfile.TemporaryDirectory(prefix="loopx-result-use-") as directory: + try: + root, runner = service.__wrapped__(Path(directory), + SimpleNamespace(param="file", addfinalizer=cleanups.append), patch) + accepted_chain(root, runner) + config = root / "project/.loopx/config/delegations.json" + config.parent.mkdir(parents=True, exist_ok=True) + catalog = json.loads(runner.config.read_text()) + config.write_text(json.dumps(catalog)) + runner.config = config + historical = {op: runner.path(op).read_bytes() for op in ("analysis-1", "middle-1", "synthesis-1")} + store = ChatSessionStore(runner.root) + controller = ChatRuntimeController(store=store, codex_bin="missing-codex", registry_path=runner.registry) + store.create_session(goal_id=runner.goal_id, agent_id="codex", channel_id=f"goal.{runner.goal_id}", + upstream_thread_id="fixture", session_id="recovery", adapter_kind="codex_app_server", upstream_mode="chat") + store.update_session("recovery", native_goal={"status": "active"}, loopx_mode={"settings": { + "agent_id": "lead", "execution_config": ".loopx/config/delegations.json"}}) + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.verbose = False + server.registry_path = runner.registry + server.runtime_root = runner.root + server.chat_store = store + server.runtime_controller = controller + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + source_input = root / "analyst/corrected/declared-input.json" + original = source_input.read_bytes() + print(json.dumps({"origin": f"http://127.0.0.1:{server.server_port}", "goal_id": runner.goal_id}), flush=True) + try: + for command in sys.stdin: + action = command.strip() + if action == "withdraw": + source_input.write_text("{}") + elif action == "restore": + source_input.write_bytes(original) + elif action != "inspect": + raise ValueError("unknown fixture command") + print(json.dumps({"operation": runner.read("synthesis-1"), + "historical_unchanged": all(runner.path(op).read_bytes() == value for op, value in historical.items()), "host_calls": [(root / path / "host-invocations").read_text() + for path in ("analyst/initial", "analyst/corrected", "reviewer/corrected")]}), flush=True) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + controller.close() + finally: + for cleanup in reversed(cleanups): + cleanup() + patch.undo() + + +if __name__ == "__main__": + main() diff --git a/tests/test_delegation_result_use.py b/tests/test_delegation_result_use.py index 50ddc874e0..2995ff785e 100644 --- a/tests/test_delegation_result_use.py +++ b/tests/test_delegation_result_use.py @@ -1,6 +1,7 @@ """Version-bound requester decisions through real Turn acceptance and durable IO.""" import asyncio import json +import os import subprocess import sys import time @@ -108,7 +109,11 @@ def test_result_use_requires_exact_accepted_input_and_survives_reconnect(service assert adopted[0]["consumer_artifacts"][0]["sha256"] == result["artifacts"][0]["sha256"] reconnected = Delegations(runner.root, runner.registry, runner.goal_id, runner.agent_id, runner.config) async def reconnect_mcp(): - params = StdioServerParameters(command=sys.executable, args=[ + # Keep the MCP child on the same disposable authority runtime as its + # parent. The SDK's default environment omits these isolation controls. + params = StdioServerParameters(command=sys.executable, env={ + key: os.environ[key] for key in ("NODE_OPTIONS", "TMPDIR", "TEMP", "TMP") + if key in os.environ}, args=[ "-m", "loopx.collaboration_mcp", "--registry", str(runner.registry), "--runtime-root", str(runner.root), "--goal-id", runner.goal_id, "--agent-id", runner.agent_id, "--workspace", str(root / "lead"), @@ -139,3 +144,113 @@ async def reconnect_mcp(): assert runner.read("analysis-1")["adoptions"][0]["state"] == "unavailable" with pytest.raises(ValueError, match="acceptance rejected"): runner.adopt_result("analysis-1", "synthesis-1") + + +def accepted_chain(root, runner): + """Synthetic workers through production admission, validation and settlement.""" + config = json.loads(runner.config.read_text()) + template = config["bindings"][0] + config["bindings"] += [ + {**template, "id": "middle", "agent_id": "analyst", "todo_id": "todo_analyst-corrected", + "workspace": str(root / "analyst/corrected")}, + {**template, "id": "synthesis", "agent_id": "reviewer", "todo_id": "todo_reviewer-corrected", + "workspace": str(root / "reviewer/corrected")}, + ] + runner.config.write_text(json.dumps(config)) + runner.start("analysis", "analysis-1", brief()) + source = wait(runner) + + def linked_request(source, workspace): + artifact = source["artifacts"][0] + path = workspace / "declared-input.json" + path.write_text(artifact["text"]) + return {**brief(), "inputs": [{"ref": path.name, "description": "Exact accepted source", + "sha256": artifact["sha256"], "delegation": {"operation_id": source["operation_id"], + "ref": artifact["ref"], "relation": "uses"}}]} + + middle_brief = linked_request(source, root / "analyst/corrected") + runner.start("middle", "middle-1", middle_brief) + middle = wait(runner, "middle-1") + consumer_brief = linked_request(middle, root / "reviewer/corrected") + runner.start("synthesis", "synthesis-1", consumer_brief) + consumer = wait(runner, "synthesis-1") + assert consumer["status"] == "accepted" + return consumer_brief + + +def test_transitive_current_use_withdraws_and_recovers_without_redispatch(service, monkeypatch): + """Real File/SQLite, workers and domain checks; historical output stays valid.""" + root, runner = service + consumer_brief = accepted_chain(root, runner) + historical = {operation: runner.path(operation).read_bytes() + for operation in ("analysis-1", "middle-1", "synthesis-1")} + middle_input = root / "analyst/corrected/declared-input.json" + saved = middle_input.read_bytes() + middle_input.write_text("{}") + reads = [] + current_read = runner._read_current + def counted_read(operation): + reads.append(operation) + return current_read(operation) + with monkeypatch.context() as spy: + spy.setattr(runner, "_read_current", counted_read) + unavailable = runner.read("synthesis-1") + assert sorted(reads) == ["analysis-1", "middle-1", "synthesis-1"], "Each source is checked once per admission" + assert unavailable["status"] == "accepted", "Historical terminal outcome must survive" + assert unavailable["dependencies"][0]["state"] == "unavailable" + assert unavailable["current_use"]["state"] == "unavailable" + assert unavailable["current_use"]["blocking_operation_id"] == "middle-1" + assert unavailable["current_use"]["reason"] == "input_unavailable" + with pytest.raises(ValueError, match="input version unavailable"): + runner.start("synthesis", "synthesis-2", consumer_brief) + assert not runner.path("synthesis-2").exists() + with pytest.raises(Exception, match="exact current uses input"): + runner.adopt_result("middle-1", "synthesis-1") + assert runner.start("synthesis", "synthesis-1", consumer_brief)["current_use"]["state"] == "unavailable" + middle_input.write_bytes(saved) + assert runner.read("synthesis-1")["current_use"]["state"] == "current" + assert runner.adopt_result("middle-1", "synthesis-1")["adoptions"][0]["state"] == "current" + for operation in ("analysis-1", "synthesis-1"): + assert runner.path(operation).read_bytes() == historical[operation] + for workspace in (root / "analyst/initial", root / "analyst/corrected", root / "reviewer/corrected"): + assert (workspace / "host-invocations").read_text() == "1" + + +def test_deadline_after_link_qualification_blocks_admission_and_adoption(tmp_path, monkeypatch): + from types import SimpleNamespace + from loopx.control_plane.collaboration import delegation_results as module + version = "a" * 64 + item = {"operation_id": "source", "ref": "out.json", "sha256": version, + "input_ref": "input.json", "input_available": True} + request = {"inputs": [{"delegation": {"operation_id": "source"}}]} + runner = SimpleNamespace(_read_current=lambda op: {"status": "accepted", "artifacts": [{"ref": "out.json", "sha256": version}]}, + path=lambda op: tmp_path/op, _bound=lambda row: {}) + monkeypatch.setattr(module._ResultUseRead, "inputs", lambda self, binding, brief: [item] if brief["inputs"] else []) + # Avoid a self edge; source IO has no ancestors, consumer does. + monkeypatch.setattr(module, "operation_brief", lambda service, row: request if row.get("consumer") else {"inputs": []}) + monkeypatch.setattr(module, "_read", lambda path: {"consumer": path.name == "consumer"}) + elapsed = [0] + monkeypatch.setattr(module, "time", SimpleNamespace(monotonic=lambda: elapsed[0])) + qualify = module._ResultUseRead.qualify + def expire_after_link(self, roots): + result = qualify(self, roots) + elapsed[0] = 16 + return result + monkeypatch.setattr(module._ResultUseRead, "qualify", expire_after_link) + links, aggregate = module._dependency_observation(runner, {}, request) + assert aggregate["reason"] == "verification_budget_exhausted" + assert links[0]["state"] == "unavailable" + elapsed[0] = 0 + with pytest.raises(ValueError, match="unavailable"): + module.require_dependencies(runner, {}, request) + elapsed[0] = 0 + owner = module.effect_runtime_result + captured = [] + def observe(method, params): + if method == "collaboration.delegation.adoption": + captured.append(params["inputs_current"]) + return {} + return owner(method, params) + monkeypatch.setattr(module, "effect_runtime_result", observe) + module.adoption_evidence(runner, "source", "consumer") + assert captured == [False]