From a99441b2e692a37606c92367185ed5b8a9dd6783 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:54:52 +0800 Subject: [PATCH] fix(collaboration): project workspace faults in delegation preflight Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/package.json | 1 + .../delegation-preflight-browser-smoke.mjs | 96 ++++++++++++ .../smoke/delegation-preflight-smoke.tsx | 16 +- .../src/data/delegation-preflight.ts | 8 +- .../delegation-preflight-status.tsx | 30 +++- docs/reference/local-delegation.md | 22 +++ loopx/collaboration_mcp.py | 26 ++- .../control_plane/collaboration/delegation.ts | 28 ++++ tests/control_plane_ts/delegation.test.ts | 33 ++++ tests/test_delegation_preflight.py | 148 +++++++++++++++++- 10 files changed, 392 insertions(+), 16 deletions(-) create mode 100644 apps/presentation/dashboard/smoke/delegation-preflight-browser-smoke.mjs diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 92a4b8d1be..87caa7e4aa 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -33,6 +33,7 @@ "smoke:home-browser": "node ../../../examples/dashboard-home-browser-smoke.mjs", "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", "smoke:delegation-preflight": "rm -rf node_modules/.cache/loopx-delegation-preflight-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --jsx react-jsx --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-delegation-preflight-smoke smoke/delegation-preflight-smoke.tsx src/data/delegation-preflight.ts src/features/personal-workspace/delegation-preflight-status.tsx && node node_modules/.cache/loopx-delegation-preflight-smoke/smoke/delegation-preflight-smoke.js", + "smoke:delegation-preflight-browser": "node --experimental-strip-types smoke/delegation-preflight-browser-smoke.mjs", "smoke:personal-workspace": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", diff --git a/apps/presentation/dashboard/smoke/delegation-preflight-browser-smoke.mjs b/apps/presentation/dashboard/smoke/delegation-preflight-browser-smoke.mjs new file mode 100644 index 0000000000..1eaf899559 --- /dev/null +++ b/apps/presentation/dashboard/smoke/delegation-preflight-browser-smoke.mjs @@ -0,0 +1,96 @@ +/** Exercise the existing packaged Team execution UI with the shared TS read model. */ +import assert from "node:assert/strict"; +import {mkdir} from "node:fs/promises"; +import {resolve} from "node:path"; +import {delegationPreflight} from "../../../../loopx/control_plane/collaboration/delegation.ts"; +import {launchBrowser, loadPlaywright, waitForHttp} from "../../../../examples/dashboard-browser-smoke-support.mjs"; +import {outputDir, packaged, port, startServer} from "../../../../examples/personal-workspace-browser/fixture.mjs"; +import {openWorkspacePage} from "../../../../examples/personal-workspace-browser/scenario-context.mjs"; + +await mkdir(outputDir, {recursive: true}); +const server = startServer(); +let browser; +try { + const url = `http://127.0.0.1:${port}/${packaged ? "chat/" : ""}?statusUrl=/status.json`; + await waitForHttp(url); + browser = await launchBrowser(loadPlaywright().chromium); + for (const zh of [true, false]) { + const context = await openWorkspacePage({newPage: options => browser.newPage({ + ...options, locale: zh ? "zh-CN" : "en-US", + })}, url); + const {api, page} = context; + let state = "legacy_error"; + const inspected = []; + try { + await page.route("**/api/chat/sessions/*/loopx", async route => { + const body = route.request().method() === "POST" ? route.request().postDataJSON() : {}; + if (body.operation !== "inspect" || state === "available") return route.fallback(); + inspected.push(body.binding_id); + if (state === "legacy_error") return route.fulfill({status: 409, + json: {error: "delegation workspace unavailable"}}); + const binding = {id: body.binding_id, agent_id: "fixture-worker", todo_id: "fixture-task"}; + const check = delegationPreflight({binding, workspace: {state}, authority: null, + preview: null, acceptance: null, validation_files_current: false}); + await route.fulfill({json: check}); + }); + await page.locator(".personal-goal-link", {hasText: "Product Release"}).click(); + await page.getByRole("navigation", {name: zh ? "Goal 视图" : "Goal view"}) + .getByRole("button", {name: zh ? "对话" : "Chat", exact: true}).click(); + await page.getByRole("button", {name: zh ? "开启 LoopX 模式" : "Enable LoopX", exact: true}).click(); + const settings = page.locator(".goal-loopx-mode-settings"); + await settings.getByLabel(zh ? "已注册的协调身份" : "Registered coordinator").selectOption("lead"); + await settings.getByLabel(zh ? "协调员总 token 额度" : "Coordinator total token allowance").fill("100000"); + assert.equal(await settings.getByLabel(zh ? "成员执行绑定文件(Goal 配置)" : "Member execution bindings (Goal configuration)").isEditable(), false); + await settings.getByRole("button", {name: zh ? "保存设置" : "Save settings", exact: true}).click(); + await page.getByRole("button", {name: zh ? "团队执行情况" : "Team execution", exact: true}).click(); + const team = page.getByRole("region", {name: zh ? "团队执行详情" : "Team execution details"}); + // Render the error response reproduced on the base, not a baseline UI build. + await team.getByRole("button", {name: zh ? "检查整个团队" : "Check whole team", exact: true}).click(); + await team.locator(".goal-team-bindings > li > p[role=alert]").nth(2).waitFor(); + for (const [size, viewport] of [["desktop", {width: 1512, height: 982}], ["mobile", {width: 390, height: 844}]]) { + await page.setViewportSize(viewport); + await page.screenshot({path: resolve(outputDir, `workspace-preflight-before-response-${zh ? "zh" : "en"}-${size}.png`), animations: "disabled"}); + } + assert.equal(context.errors.length, 3, "Only the three injected baseline HTTP refusals are expected"); + for (const error of context.errors) assert.match(error, + /^Failed to load resource: the server responded with a status of 409 \(Conflict\)$/); + context.errors.splice(0); // Keep subsequent recovery checks error-free. + for (const fault of ["missing", "not_directory", "unavailable"]) { + state = fault; + await team.getByRole("button", {name: zh ? "检查整个团队" : "Check whole team", exact: true}).click(); + await team.getByRole("button", {name: zh ? "检查整个团队" : "Check whole team", exact: true}).waitFor({state: "visible"}); + const expected = zh + ? {missing: "目录不存在", not_directory: "绑定位置不是目录", unavailable: "目录无法读取"}[fault] + : {missing: "Directory missing", not_directory: "Bound location is not a directory", unavailable: "Directory could not be read"}[fault]; + const observations = team.locator(".goal-team-bindings > li > p[role=status]"); + await observations.filter({hasText: expected}).nth(2).waitFor(); + assert.equal(await observations.count(), 3); + assert.equal(new Set(inspected.slice(-3)).size, 3); + const content = await observations.first().innerText(); + assert.match(content, zh ? /核对原执行配置的工作目录/ : /review the workspace in the original execution configuration/); + assert.match(content, zh ? /权限、验收与运行时尚未检查/ : /Authority, acceptance and runtime uninspected/); + assert.doesNotMatch(content, /晋级|promotion|Canonical authority unavailable|缺少规范权限状态/); + } + const dialog = page.getByRole("dialog", {name: zh ? "团队执行情况" : "Team execution", exact: true}); + for (const [size, viewport] of [["desktop", {width: 1512, height: 982}], ["mobile", {width: 390, height: 844}]]) { + await page.setViewportSize(viewport); + assert(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth), "Workspace recovery copy must not overflow the dialog"); + await page.screenshot({path: resolve(outputDir, `workspace-preflight-${zh ? "zh" : "en"}-${size}.png`), animations: "disabled"}); + } + const member = team.locator(".goal-team-bindings > li").filter({hasText: "local-analyst"}); + await member.locator("summary").click(); + state = "available"; + await member.getByRole("button", {name: zh ? "重新检查此成员" : "Recheck this member", exact: true}).click(); + await member.getByText(zh ? /运行时可用性尚未验证/ : /Runtime availability unverified/).waitFor(); + assert.equal(await member.getByText(zh ? /绑定工作目录不可用/ : /Bound workspace unavailable/).count(), 0); + assert.equal(api.turnRequests.length, 0, "Inspection/recovery must not start model work"); + assert.equal(context.errors.length, 0, context.errors.join("\n")); + console.log(`workspace preflight ${zh ? "Chinese" : "English"}: three faults, exact recheck and desktop/mobile passed`); + } finally { + await context.close(); + } + } +} finally { + await browser?.close(); + server.kill("SIGTERM"); +} diff --git a/apps/presentation/dashboard/smoke/delegation-preflight-smoke.tsx b/apps/presentation/dashboard/smoke/delegation-preflight-smoke.tsx index 09fad7d088..3cfee0e48f 100644 --- a/apps/presentation/dashboard/smoke/delegation-preflight-smoke.tsx +++ b/apps/presentation/dashboard/smoke/delegation-preflight-smoke.tsx @@ -36,4 +36,18 @@ for (const [zh, expectedLabel, expectedBoundary] of [ } if (Object.values(unavailable.effects).some(Boolean)) throw new Error("authority-unavailable response must report zero effects"); -console.log("delegation authority-unavailable preflight smoke passed"); +for (const workspace_state of ["missing", "not_directory", "unavailable"] as const) { + const check: DelegationPreflight = {...unavailable, state: "workspace_unavailable", + workspace_state, workspace_next_action: "review_operator_workspace_binding", + authority_ready: null, authority_reason: null, authority_state: "uninspected", + authority_next_action: "none"}; + for (const zh of [true, false]) { + const html = renderToStaticMarkup(); + if (!html.includes(zh ? "绑定工作目录不可用" : "Bound workspace unavailable")) throw new Error("missing workspace fault label"); + if (!html.includes(zh ? "核对原执行配置的工作目录" : "review the workspace in the original execution configuration")) throw new Error("missing workspace recovery action"); + if (!html.includes(zh ? "权限、验收与运行时尚未检查" : "Authority, acceptance and runtime uninspected")) throw new Error("workspace fault misreports inspection"); + if (/晋级|promotion|Canonical authority unavailable|缺少规范权限状态/.test(html)) throw new Error("workspace fault suggested authority repair/promotion"); + } +} + +console.log("delegation authority/workspace-unavailable preflight smoke passed"); diff --git a/apps/presentation/dashboard/src/data/delegation-preflight.ts b/apps/presentation/dashboard/src/data/delegation-preflight.ts index 6bd79d645f..5f18c34399 100644 --- a/apps/presentation/dashboard/src/data/delegation-preflight.ts +++ b/apps/presentation/dashboard/src/data/delegation-preflight.ts @@ -1,11 +1,13 @@ export type DelegationPreflight = { - state: "authority_unavailable" | "turn_blocked" | "acceptance_unavailable" | "runtime_unavailable" | "runtime_unverified" | "launchable"; + state: "workspace_unavailable" | "authority_unavailable" | "turn_blocked" | "acceptance_unavailable" | "runtime_unavailable" | "runtime_unverified" | "launchable"; + workspace_state?: "missing" | "not_directory" | "unavailable"; + workspace_next_action?: "review_operator_workspace_binding"; turn_eligible: boolean; acceptance_ready: boolean; turn_route: string | null; - authority_ready: boolean; + authority_ready: boolean | null; authority_reason: string | null; - authority_state: "promotion_required" | "unavailable" | "promoted"; + authority_state: "promotion_required" | "unavailable" | "promoted" | "uninspected"; authority_next_action: "preview_reviewed_goal_authority_promotion" | "repair_canonical_authority" | "none"; promotion_from_surface_allowed: false; executor: {host: string; available: boolean | null; reason: string | null; profile: string | null} | null; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delegation-preflight-status.tsx b/apps/presentation/dashboard/src/features/personal-workspace/delegation-preflight-status.tsx index 0d4de0b8dc..0541c8deef 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/delegation-preflight-status.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/delegation-preflight-status.tsx @@ -1,6 +1,7 @@ import type {DelegationPreflight} from "../../data/delegation-preflight.js"; const labels: Record = { + workspace_unavailable: {en: "Bound workspace unavailable", zh: "绑定工作目录不可用"}, authority_unavailable: {en: "Canonical authority unavailable", zh: "缺少规范权限状态"}, turn_blocked: {en: "Task admission blocked", zh: "当前任务未获准执行"}, acceptance_unavailable: {en: "Acceptance binding unavailable", zh: "缺少有效验收绑定"}, @@ -9,14 +10,27 @@ const labels: Record = { launchable: {en: "Local launch prerequisites met", zh: "本机启动条件已满足"}, }; +const workspaceDetails = { + missing: {en: "Directory missing", zh: "目录不存在"}, + not_directory: {en: "Bound location is not a directory", zh: "绑定位置不是目录"}, + unavailable: {en: "Directory could not be read", zh: "目录无法读取"}, +}; + export function DelegationPreflightStatus({check, zh}: {check: DelegationPreflight; zh: boolean}) { - const detail = check.executor - ? [check.executor.host, check.executor.reason].filter(Boolean).join(" · ") - : check.authority_reason; - const disclaimer = check.state === "authority_unavailable" - ? (zh ? "未检查或启动执行器" : "No executor was inspected or launched") - : (zh ? "不代表正在执行" : "Does not mean executing"); - const authorityAction = check.authority_next_action === "preview_reviewed_goal_authority_promotion" + const workspaceDetail = check.workspace_state ? workspaceDetails[check.workspace_state] : null; + const detail = check.state === "workspace_unavailable" + ? (workspaceDetail ? (zh ? workspaceDetail.zh : workspaceDetail.en) : null) + : check.executor + ? [check.executor.host, check.executor.reason].filter(Boolean).join(" · ") + : check.authority_reason; + const disclaimer = check.state === "workspace_unavailable" + ? (zh ? "权限、验收与运行时尚未检查;未启动成员" : "Authority, acceptance and runtime uninspected; no member launched") + : check.state === "authority_unavailable" + ? (zh ? "未检查或启动执行器" : "No executor was inspected or launched") + : (zh ? "不代表正在执行" : "Does not mean executing"); + const nextAction = check.workspace_next_action === "review_operator_workspace_binding" + ? (zh ? "下一步:核对原执行配置的工作目录,再重新检查" : "Next: review the workspace in the original execution configuration, then recheck") + : check.authority_next_action === "preview_reviewed_goal_authority_promotion" ? (zh ? "下一步:预览整 Goal 协调 Authority 晋级" : "Next: preview whole-Goal coordination-authority promotion") : check.authority_next_action === "repair_canonical_authority" ? (zh ? "下一步:修复规范 Authority 读回" : "Next: repair canonical authority readback") @@ -24,7 +38,7 @@ export function DelegationPreflightStatus({check, zh}: {check: DelegationPreflig return

{zh ? labels[check.state].zh : labels[check.state].en} {detail ? ` · ${detail}` : ""} - {authorityAction ? ` · ${authorityAction}` : ""} + {nextAction ? ` · ${nextAction}` : ""} {` · ${disclaimer}`}

; } diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index 22676fd28b..f5f1ae7d85 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -422,6 +422,28 @@ Normal start still reads current admission and independently validates output. If the existing Turn rejects preflight, inspection reports that error rather than manufacturing a launchable result; no request is created. +Workspace failures are observed before authority or Turn inspection, but after +the caller's binding grant and active Goal are checked. `workspace_unavailable` +retains a bounded `workspace_state`: `missing`, `not_directory`, or `unavailable` +(the filesystem could not be read). `authority_ready: null` and +`authority_state: uninspected` do not claim an authority failure or promotion +requirement. `workspace_next_action: review_operator_workspace_binding` asks the +operator to check the original execution configuration and its directory, then +repeat inspection. The same projection is rendered in **Team execution** and +returned by CLI/MCP/Goal Chat. Inspection creates no directory, retargets no +binding, launches no worker and exports no private path or filesystem error. +It is a point-in-time observation; a changed binding fails closed and start +still rechecks the existing work/authority boundaries. + +中文:工作目录故障在调用者授权与 Goal 活跃检查之后、Authority 与 Turn 检查之前 +返回 `workspace_unavailable`。`workspace_state` 区分 `missing`(不存在)、 +`not_directory`(不是目录)和 `unavailable`(文件系统无法读取)。 +`authority_ready: null`、`authority_state: uninspected` 表示尚未检查,不能解释为 +权限故障或需要晋级。`workspace_next_action: review_operator_workspace_binding` +要求核对原执行配置及绑定目录后重新检查;团队执行页面与 CLI/MCP/Goal Chat 消费 +同一投影。检查不创建目录、不改绑、不启动成员,也不暴露私人路径或文件系统错误。 +结果只是时点观察;配置变更仍拒绝,实际启动仍须重新通过原有工作与权限边界。 + `executor.runtime_probe` preserves the host's bounded probe scope: a DSH `probing_interpreter` result concerns module availability in the interpreter running this inspection, not every installation or remote host. A diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py index c38ab1486e..7b50dfddca 100644 --- a/loopx/collaboration_mcp.py +++ b/loopx/collaboration_mcp.py @@ -298,8 +298,28 @@ def operations(self, *, limit: int = 20, cursor: str | None = None) -> dict: def inspect(self, binding_id: str) -> dict: """Observe the real Turn preflight; never create a request or run a host.""" binding = self.binding(binding_id, require_active=True) - if not Path(binding["workspace"]).is_dir(): - raise ValueError("delegation workspace unavailable") + # Host filesystem facts only; the shared TS owner projects readiness. + # Do not expose a path/error body or probe authority in a missing cwd. + try: + workspace_state = ( + "available" if stat.S_ISDIR(Path(binding["workspace"]).stat().st_mode) + else "not_directory" + ) + except FileNotFoundError: + workspace_state = "missing" + except NotADirectoryError: + workspace_state = "not_directory" + except OSError: + workspace_state = "unavailable" + if workspace_state != "available": + if self.binding(binding_id, require_active=True) != binding: + raise ValueError("delegation preflight source changed; retry inspection") + return effect_runtime_result("collaboration.delegation.preflight", { + "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, + "workspace": {"state": workspace_state}, + "authority": None, "preview": None, "acceptance": None, + "validation_files_current": False, + }) try: acceptance = delegation_validation.capture(self, binding) except (OSError, ValueError) as exc: @@ -318,6 +338,7 @@ def inspect(self, binding_id: str) -> dict: promoted = True return effect_runtime_result("collaboration.delegation.preflight", { "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, + "workspace": {"state": workspace_state}, "authority": { "ready": False, "reason": str(exc), @@ -363,6 +384,7 @@ def inspect(self, binding_id: str) -> dict: raise ValueError("delegation preflight source changed; retry inspection") return effect_runtime_result("collaboration.delegation.preflight", { "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, + "workspace": {"state": workspace_state}, "authority": {"ready": True, "reason": None}, "preview": preview, "acceptance": acceptance["plan"], "validation_files_current": acceptance["files_current"], diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts index e8d610a740..c020759691 100644 --- a/loopx/control_plane/collaboration/delegation.ts +++ b/loopx/control_plane/collaboration/delegation.ts @@ -140,6 +140,34 @@ export function delegationRuntimeFacts(executor: JsonObject): JsonObject { export function delegationPreflight(params: JsonObject): JsonObject { const binding = requireJsonObject(params.binding, "binding identity"); requireThat([binding.id, binding.agent_id, binding.todo_id].every(text), "binding identities required"); + // Missing observations preserve older host compatibility. Filesystem facts + // cannot establish canonical authority or executor readiness. + if (params.workspace !== undefined) { + const workspace = requireJsonObject(params.workspace, "workspace observation"); + requireThat(typeof workspace.state === "string" + && ["available", "missing", "not_directory", "unavailable"].includes(workspace.state), + "invalid workspace observation"); + if (workspace.state !== "available") { + requireThat(params.authority === null && params.preview === null + && params.acceptance === null && params.validation_files_current === false, + "unavailable workspace cannot claim authority, Turn or task acceptance inspection"); + return { + schema_version: "loopx_delegation_preflight_v0", + binding: {id: binding.id, agent_id: binding.agent_id, todo_id: binding.todo_id}, + state: "workspace_unavailable", workspace_state: workspace.state, + workspace_next_action: "review_operator_workspace_binding", + turn_eligible: false, turn_route: null, acceptance_ready: false, + authority_ready: null, authority_reason: null, authority_state: "uninspected", + authority_next_action: "none", promotion_from_surface_allowed: false, + executor: null, + effects: {host_invoked: false, state_written: false, quota_spent: false, + scheduler_acknowledged: false}, + note: "Review the original operator-owned workspace binding, then retry inspection. " + + "Authority, acceptance and runtime were not inspected. No workspace is created, " + + "binding retargeted or worker launched.", + }; + } + } const authority = params.authority === undefined ? {ready: true, reason: null} : requireJsonObject(params.authority, "canonical authority readiness"); diff --git a/tests/control_plane_ts/delegation.test.ts b/tests/control_plane_ts/delegation.test.ts index cac137ae6d..7ae9fe4d41 100644 --- a/tests/control_plane_ts/delegation.test.ts +++ b/tests/control_plane_ts/delegation.test.ts @@ -271,6 +271,39 @@ test("preflight reports unavailable canonical authority without pretending to in preview: {}, acceptance: null, validation_files_current: false})); }); +test("workspace faults are bounded observations, not authority or launch permits", () => { + const input = {binding, authority: null, preview: null, acceptance: null, + validation_files_current: false}; + for (const state of ["missing", "not_directory", "unavailable"]) { + const result = delegationPreflight({...input, workspace: {state, path: "/private/workspace"}}); + assert.equal(result.state, "workspace_unavailable"); + assert.equal(result.workspace_state, state); + assert.equal(result.workspace_next_action, "review_operator_workspace_binding"); + assert.equal(result.authority_ready, null); + assert.equal(result.authority_state, "uninspected"); + assert.equal(result.authority_next_action, "none"); + assert.equal(result.authority_reason, null); + assert.equal(result.acceptance_ready, false); + assert.equal(result.turn_eligible, false); + assert.equal(result.executor, null); + assert.deepEqual(result.binding, {id: binding.id, agent_id: binding.agent_id, todo_id: binding.todo_id}); + assert.equal(result.promotion_from_surface_allowed, false); + assert.equal(Object.values(result.effects as Record).some(Boolean), false); + assert.equal(JSON.stringify(result).includes("/private"), false); + for (const patch of [{authority: {ready: true}}, {preview: {}}, + {acceptance: {todo_id: binding.todo_id, state: "ready"}}, {validation_files_current: true}]) { + assert.throws(() => delegationPreflight({...input, workspace: {state}, ...patch}), /cannot claim/); + } + } + for (const workspace of [null, false, {}, {state: ["missing"]}, {state: "unknown"}]) + assert.throws(() => delegationPreflight({...input, workspace}), /workspace observation/); + // A present directory does not bypass the existing authority or Turn owners. + assert.throws(() => delegationPreflight({...input, workspace: {state: "available"}}), /authority readiness/); + const unavailable = {...input, authority: {ready: false, reason: "canonical read failed"}}; + assert.deepEqual(delegationPreflight({...unavailable, workspace: {state: "available"}}), + delegationPreflight(unavailable)); +}); + test("Turn plan decision preserves a rejection and validates a successful transaction", () => { const rejected = delegationTurnPlanDecision({plan: {ok: false, error: "Requested Turn Todo is not accepted by canonical authority"}}); diff --git a/tests/test_delegation_preflight.py b/tests/test_delegation_preflight.py index 433f284392..e40ff78bb6 100644 --- a/tests/test_delegation_preflight.py +++ b/tests/test_delegation_preflight.py @@ -22,6 +22,138 @@ service = delegation_service +@pytest.mark.parametrize("workspace_state", ["missing", "not_directory"]) +def test_real_cli_workspace_fault_is_typed_without_authority_or_launch( + service, workspace_state +): + root, runner = service + config = json.loads(runner.config.read_text()) + unavailable = root / "unavailable-worker" + if workspace_state == "not_directory": + unavailable.write_text("not a workspace") + config["bindings"][0]["workspace"] = str(unavailable) + runner.config.write_text(json.dumps(config)) + before = runner.registry.read_bytes(), runner.config.read_bytes() + + status, result = cli(runner, "inspect", "--binding-id", "analysis") + + assert status == 0, result + assert result["state"] == "workspace_unavailable" + assert result["workspace_state"] == workspace_state + assert result["workspace_next_action"] == "review_operator_workspace_binding" + assert result["authority_ready"] is None + assert result["authority_state"] == "uninspected" + assert result["authority_next_action"] == "none" + assert not result["turn_eligible"] and not result["acceptance_ready"] + assert result["executor"] is None + assert not any(result["effects"].values()) + assert str(unavailable) not in json.dumps(result) + assert (runner.registry.read_bytes(), runner.config.read_bytes()) == before + assert not (root / "host-started").exists() + assert not list(runner.path("inventory").parent.glob("*.json")) + assert not list((root / "runtime" / "goals").glob("*/turns/*.json")) + if workspace_state == "not_directory": + assert unavailable.is_file() + else: + assert not unavailable.exists() + + +@pytest.mark.parametrize("fault", ["missing", "not_directory", "unavailable"]) +def test_workspace_fault_skips_authority_and_preserves_the_original_binding( + service, monkeypatch, fault +): + from loopx import collaboration_mcp as delegation + + _, runner = service + workspace = Path(runner.binding("analysis", require_active=True)["workspace"]) + original_stat = Path.stat + faults = {"missing": FileNotFoundError, "not_directory": NotADirectoryError, + "unavailable": PermissionError} + calls = [] + + def unavailable(path, *args, **kwargs): + if path == workspace: + raise faults[fault]("private filesystem details must not be exported") + return original_stat(path, *args, **kwargs) + + def no_inspection(*args, **kwargs): + calls.append((args, kwargs)) + raise AssertionError("unavailable cwd must not inspect authority or a Turn") + + with monkeypatch.context() as patch: + patch.setattr(Path, "stat", unavailable) + patch.setattr(delegation.delegation_validation, "capture", no_inspection) + patch.setattr(runner, "_cli", no_inspection) + result = runner.inspect("analysis") + assert result["state"] == "workspace_unavailable" + assert result["workspace_state"] == fault + assert "private filesystem" not in json.dumps(result) + assert calls == [] + # Restoring the original filesystem fact re-enters the ordinary preflight; + # no persisted fault, replacement operation or retargeted work is introduced. + restored = runner.inspect("analysis") + assert restored["state"] == "runtime_unverified" + assert restored["binding"] == result["binding"] + assert restored["turn_eligible"] and not any(restored["effects"].values()) + + +def test_workspace_fault_cannot_hide_denied_caller_or_changed_binding(service, monkeypatch): + from loopx.collaboration_mcp import Delegations + from loopx.control_plane.effect_runtime import EffectRuntimeRemoteError + + root, runner = service + config = json.loads(runner.config.read_text()) + config["bindings"][0]["workspace"] = str(root / "missing-worker") + runner.config.write_text(json.dumps(config)) + denied = Delegations(runner.root, runner.registry, runner.goal_id, "reviewer", runner.config) + with pytest.raises(EffectRuntimeRemoteError, match="no delegation grant"): + denied.inspect("analysis") + + original_binding = runner.binding + calls = 0 + + def changed_binding(binding_id, **kwargs): + nonlocal calls + calls += 1 + if calls == 2: + config["bindings"][0]["workspace"] = str(root / "another-missing-worker") + runner.config.write_text(json.dumps(config)) + return original_binding(binding_id, **kwargs) + + monkeypatch.setattr(runner, "binding", changed_binding) + with pytest.raises(ValueError, match="source changed"): + runner.inspect("analysis") + + +def test_mcp_workspace_fault_is_a_read_only_observation(service): + root, runner = service + config = json.loads(runner.config.read_text()) + config["bindings"][0]["workspace"] = str(root / "missing-worker") + runner.config.write_text(json.dumps(config)) + before = runner.registry.read_bytes(), runner.config.read_bytes() + + async def inspect_mcp(): + params = StdioServerParameters(command=sys.executable, 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"), + "--execution-config", str(runner.config), + ]) + async with stdio_client(params) as (reader, writer): + async with ClientSession(reader, writer) as session: + await session.initialize() + inspected = await session.call_tool("inspect_execution_binding", {"binding_id": "analysis"}) + assert not inspected.isError + return json.loads(inspected.content[0].text) + + result = asyncio.run(inspect_mcp()) + assert result["state"] == "workspace_unavailable" + assert result["authority_ready"] is None and result["executor"] is None + assert not any(result["effects"].values()) + assert (runner.registry.read_bytes(), runner.config.read_bytes()) == before + assert not list(runner.path("inventory").parent.glob("*.json")) + + @pytest.mark.parametrize("health_repair", [False, True]) def test_actual_workspace_scan_refusal_is_typed_and_effect_free(service, health_repair): root, runner = service @@ -553,7 +685,7 @@ def test_preflight_does_not_call_an_invalidated_acceptance_ready(service): assert result["state"] in {"turn_blocked", "acceptance_unavailable"} -@pytest.mark.parametrize("validation_basis", ["goal_acceptance", "independent"]) +@pytest.mark.parametrize("validation_basis", ["goal_acceptance", "independent", "missing_workspace"]) def test_http_team_readback_uses_original_scope_without_a_new_turn(service, validation_basis): import http.client import threading @@ -566,6 +698,10 @@ def test_http_team_readback_uses_original_scope_without_a_new_turn(service, vali from test_independent_delegation_validation import independent_binding independent_binding(service) + elif validation_basis == "missing_workspace": + binding_config = json.loads(runner.config.read_text()) + binding_config["bindings"][0]["workspace"] = str(root / "missing-worker") + runner.config.write_text(json.dumps(binding_config)) from loopx.agent_registry import load_goal_from_registry from pathlib import Path @@ -643,7 +779,15 @@ def test_http_team_readback_uses_original_scope_without_a_new_turn(service, vali conn.close() assert response.status == expected, result if body["operation"] == "inspect": - assert result["state"] == "runtime_unverified" + assert result["state"] == ( + "workspace_unavailable" if validation_basis == "missing_workspace" + else "runtime_unverified" + ) + assert not any(result["effects"].values()) + if validation_basis == "missing_workspace": + assert result["authority_ready"] is None + assert result["workspace_next_action"] == "review_operator_workspace_binding" + assert str(root / "missing-worker") not in json.dumps(result) assert store.load_session(session["session_id"]).get("active_turn_id") is None assert not (root / "host-started").exists() finally: