From b23257e1e8dd6644bbf2537632625144edd3161d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:06:49 +0800 Subject: [PATCH 01/55] feat(coordination): prepare source authority for planned transfer Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../coordination/legacy_writer_fence.ts | 34 ++++ .../coordination/local_authority_write.ts | 21 ++- .../planned_transfer_authority.ts | 122 ++++++++++++ .../control_plane/effect_runtime_handlers.ts | 1 + .../test_planned_transfer_source_boundary.py | 49 ++++- .../planned_transfer_authority.test.ts | 178 ++++++++++++++++++ 6 files changed, 398 insertions(+), 7 deletions(-) create mode 100644 loopx/control_plane/coordination/planned_transfer_authority.ts create mode 100644 tests/control_plane_ts/planned_transfer_authority.test.ts diff --git a/loopx/control_plane/coordination/legacy_writer_fence.ts b/loopx/control_plane/coordination/legacy_writer_fence.ts index d28826f7ff..86786ee59f 100644 --- a/loopx/control_plane/coordination/legacy_writer_fence.ts +++ b/loopx/control_plane/coordination/legacy_writer_fence.ts @@ -37,6 +37,9 @@ export { /** A cold import pins its own source and operation; it has no shadow revision. */ export const COLD_SOURCE_IMPORT_WRITER_FENCE_SCHEMA = "loopx_cold_source_import_writer_fence_v0"; +/** Source-side write exclusion. Older decoders reject this schema, so an older + * binary cannot mistake prepared history for an ordinary writable authority. */ +export const PLANNED_TRANSFER_WRITER_FENCE_SCHEMA = "loopx_planned_transfer_writer_fence_v0"; // Caller adapter: remediation is rendered here, never inside // checkLegacyCoordinationWriteAllowed, which owns only the stable typed reason @@ -110,6 +113,13 @@ export function legacyCoordinationWriterFencePath(root: string, goalId: string): export function decodeLegacyCoordinationWriterFence(value: unknown): JsonObject { const fence = canonicalAuthorityObject(value, "legacy coordination writer fence"); + if (fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) { + if (fence.state !== "engaged" || !hasExactAuthorityKeys(fence, + ["schema_version", "state", "goal_id", "fence_id", "operation_id", "source_identity_sha256"]) || + !BARE_SHA256_PATTERN.test(String(fence.source_identity_sha256))) throw new Error("Invalid planned transfer writer fence"); + for (const key of ["goal_id", "fence_id", "operation_id"]) requireAuthorityStoreId(fence[key], key); + return fence; + } if (fence.schema_version === COLD_SOURCE_IMPORT_WRITER_FENCE_SCHEMA) { if (fence.state !== "engaged" || !hasExactAuthorityKeys(fence, ["schema_version", "state", "goal_id", "fence_id", "import_operation_id", "import_plan_sha256"]) || @@ -185,6 +195,30 @@ export async function completeNewGoalWriterFenceUnderLocks(root: string, goalId: }); } +/** Caller holds the Goal maintenance lock and has retained the original fence + * and complete source identity. This transition never reopens a prepared source. + */ +export async function preparePlannedTransferWriterFenceUnderLock(root: string, goalId: string, + prior: JsonObject, prepared: JsonObject): Promise { + decodeLegacyCoordinationWriterFence(prior); + decodeLegacyCoordinationWriterFence(prepared); + if (prior.goal_id !== goalId || prepared.goal_id !== goalId || + prior.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA || + prepared.schema_version !== PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) throw new Error("Invalid transfer fence transition"); + const path = legacyCoordinationWriterFencePath(root, goalId); + await withFileMutationLock(path, async () => { + const current = await loadLegacyCoordinationWriterFence(root, goalId); + if (current.status !== "loaded") throw new Error("Original source writer fence is unavailable"); + if (canonicalAuthorityBytes(current.fence).equals(canonicalAuthorityBytes(prepared))) return; + if (!canonicalAuthorityBytes(current.fence).equals(canonicalAuthorityBytes(prior))) throw new Error("Source writer fence changed"); + await atomicWriteJson(path, prepared); + const after = await loadLegacyCoordinationWriterFence(root, goalId); + if (after.status !== "loaded" || !canonicalAuthorityBytes(after.fence).equals(canonicalAuthorityBytes(prepared))) { + throw new Error("Prepared source writer fence readback failed"); + } + }); +} + export async function loadLegacyCoordinationWriterFence( root: string, goalId: string, diff --git a/loopx/control_plane/coordination/local_authority_write.ts b/loopx/control_plane/coordination/local_authority_write.ts index 81333cc984..ebbd28f37a 100644 --- a/loopx/control_plane/coordination/local_authority_write.ts +++ b/loopx/control_plane/coordination/local_authority_write.ts @@ -1,6 +1,6 @@ import {withFileMutationLock} from "../effect_runtime_io.ts"; import type {JsonObject} from "../effect_program.ts"; -import {loadLegacyCoordinationWriterFence} from "./legacy_writer_fence.ts"; +import {loadLegacyCoordinationWriterFence, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; import {requireShadowPrimaryWriteAllowed, shadowMaintenanceLockPath} from "./shadow_management.ts"; // File-v0 still verifies and republishes its retained history on each write. @@ -21,10 +21,11 @@ export type CanonicalBusinessWrite = | "task_lease_renew" | "task_lease_transfer" | "task_lease_release" - | "goal_create"; + | "goal_create" + | "authority_maintenance"; export class CanonicalWriteRouteError extends Error { - readonly code: "canonical_authority_required" | "canonical_authority_fence_invalid"; + readonly code: "canonical_authority_required" | "canonical_authority_fence_invalid" | "canonical_authority_transfer_prepared"; readonly payload: JsonObject; constructor(code: CanonicalWriteRouteError["code"], goalId: string, operation: CanonicalBusinessWrite, reason: string) { @@ -40,7 +41,7 @@ export class CanonicalWriteRouteError extends Error { decision_read_from_provider: false, legacy_fallback_used: false, recovery: { - action: code === "canonical_authority_required" + action: code === "canonical_authority_transfer_prepared" ? "recover_planned_transfer" : code === "canonical_authority_required" ? "migrate_goal_authority" : "repair_canonical_authority_fence", backup_required: true, @@ -80,6 +81,10 @@ export async function requireCanonicalBusinessWrite( fence.reason, ); } + if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) { + throw new CanonicalWriteRouteError("canonical_authority_transfer_prepared", goalId, operation, + `Source authority is prepared for transfer ${fence.fence.operation_id}; recover that operation before retrying`); + } } /** Canonical command writers share the maintenance guard; provider CAS owns state. */ @@ -94,6 +99,14 @@ export async function withCanonicalWriter( await requireShadowPrimaryWriteAllowed(root, goalId); if (options.businessWrite !== undefined) { await requireCanonicalBusinessWrite(root, goalId, options.businessWrite); + } else { + // Administrative provider/policy paths share this guard too. They must + // not replace a prepared source while its product operation is pending. + const fence = await loadLegacyCoordinationWriterFence(root, goalId); + if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) { + throw new CanonicalWriteRouteError("canonical_authority_transfer_prepared", goalId, "authority_maintenance", + `Source authority is prepared for transfer ${fence.fence.operation_id}; recover that operation before retrying`); + } } return await write(); }, CANONICAL_WRITER_LOCK_TIMEOUT_MS); diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts new file mode 100644 index 0000000000..fddfc06fba --- /dev/null +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -0,0 +1,122 @@ +/** Authority preparation for the planned-transfer composition. + * + * This owner closes canonical writes and pins the complete native journal. It + * does not certify Host/effect drain, portable-artifact custody or destination + * admission. The product coordinator must obtain those owners' decisions before + * exposing a cutover. No public activation entry is registered here. + */ +import {mkdir, readFile} from "node:fs/promises"; +import {join} from "node:path"; +import type {JsonObject} from "../effect_program.ts"; +import {durableWriteJson} from "../effect_runtime_io.ts"; +import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys, requireAuthorityStoreId} from "./authority_store_codec.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; +import {exportAuthorityArchive, verifyAuthorityArchive, type AuthorityArchiveSummary} from "./authority_archive.ts"; +import {indexCoordinationProjection} from "./coordination_projection.ts"; +import {canonicalTaskLease} from "./task_lease_state.ts"; +import {openLocalAuthorityStoreHandle, requireLocalAuthorityRuntimeRoot} from "./local_authority_provider.ts"; +import {loadLegacyCoordinationWriterFence, preparePlannedTransferWriterFenceUnderLock, + PLANNED_TRANSFER_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; +import {readShadowManagementState, shadowManagementDirectory, withShadowMaintenanceLock} from "./shadow_management.ts"; + +const PREPARATION_SCHEMA = "loopx_planned_transfer_authority_preparation_v0"; + +function reject(message: string): never { throw new EffectRuntimeConflictError(message); } +function same(a: unknown, b: unknown): boolean { return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b)); } +async function optionalJson(path: string): Promise { + try { return requireJsonObject(JSON.parse(await readFile(path, "utf8")), "transfer preparation"); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } +} +async function retain(path: string, value: JsonObject): Promise { + const prior = await optionalJson(path); + if (prior) { + if (!same(prior, value)) reject("Transfer preparation belongs to different source facts; recover its original operation"); + return; + } + await durableWriteJson(path, value); + if (!same(await optionalJson(path), value)) reject("Transfer preparation readback failed"); +} + +/** The maintenance guard serializes preparation against every ordinary native + * writer. Expiration does not settle a lease: every retained token must have + * been released through its original owner before this operation can fence it. + * + * Retrying the same operation recovers the original capture after interruption. + * It never reopens the source or rewrites a receipt following later changes. + */ +export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id", "expected_provider_revision", + "expected_writer_fence_sha256", "execute"]) || value.execute !== true || + typeof value.expected_writer_fence_sha256 !== "string" || !BARE_SHA256_PATTERN.test(value.expected_writer_fence_sha256)) { + reject("Transfer authority preparation requires an explicit execution and the complete reviewed source identity"); + } + const root = requireLocalAuthorityRuntimeRoot(value.runtime_root); + const goal = requireAuthorityStoreId(value.goal_id, "transfer Goal id"); + const operation = requireAuthorityStoreId(value.operation_id, "transfer operation id"); + const expectedRevision = requireAuthorityStoreId(value.expected_provider_revision, "reviewed source revision"); + const request = {goal_id: goal, operation_id: operation, expected_provider_revision: expectedRevision, + expected_writer_fence_sha256: value.expected_writer_fence_sha256}; + const directory = join(shadowManagementDirectory(root, goal), "planned-transfers", canonicalAuthoritySha256(operation)); + return withShadowMaintenanceLock(root, goal, async () => { + const managed = await readShadowManagementState(root, goal); + if (managed && managed.status !== "inactive") reject("Source capture requires disposition before planned transfer"); + const current = await loadLegacyCoordinationWriterFence(root, goal); + if (current.status !== "loaded") reject("Transfer requires the original verified canonical writer fence"); + const opened = await openLocalAuthorityStoreHandle(root, goal, {}, {existingOnly: true}); + if (opened.provider !== "file" && opened.provider !== "sqlite") reject("Planned transfer requires a qualified local File or SQLite source"); + const head = await opened.store.loadAuthority(), identity = await opened.store.storeIdentity(); + if (head.status !== "loaded" || identity.status !== "available") reject("Transfer source authority is unavailable; restore it before preparing a move"); + if (head.provider_revision !== expectedRevision) reject("Source authority changed; review a fresh transfer preview"); + const index = indexCoordinationProjection(head.head, goal); + const leases = [...index.leases].map(([todo, lease]) => canonicalTaskLease(lease, goal, todo)); + if (leases.some(lease => lease.status !== "released")) { + reject("Source retains an unreleased lease; settle its original operation before planned transfer"); + } + const pendingPath = join(directory, "preparation.json"); + const pending = await optionalJson(pendingPath); + const facts = {schema_version: PREPARATION_SCHEMA, request, + source_provider: opened.provider, source_store_identity: identity.store_identity, + source_cursor: head.cursor, source_projection_sha256: canonicalAuthoritySha256(head.head)}; + let priorFence: JsonObject; + if (pending) { + priorFence = requireJsonObject(pending.original_writer_fence, "original source writer fence"); + if (!same(pending, {...facts, original_writer_fence: priorFence})) reject("Transfer source or original operation changed"); + // A carrier is written before the fence, so an interrupted first call may + // still find the exact original fence. Neither case permits other bytes. + const expectedFence = {schema_version: PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, state: "engaged", goal_id: goal, + fence_id: `planned-transfer:${operation}`, operation_id: operation, source_identity_sha256: canonicalAuthoritySha256(pending)}; + if (!same(current.fence, priorFence) && !same(current.fence, expectedFence)) reject("Source writer fence changed during transfer preparation"); + } else { + if (current.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) reject("Source is already prepared by another transfer operation"); + priorFence = current.fence; + } + const preparation = {...facts, original_writer_fence: priorFence}; + if (canonicalAuthoritySha256(priorFence) !== value.expected_writer_fence_sha256) reject("Source writer fence changed; review a fresh transfer preview"); + await mkdir(directory, {recursive: true, mode: 0o700}); + await retain(pendingPath, preparation); + await preparePlannedTransferWriterFenceUnderLock(root, goal, priorFence, { + schema_version: PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, state: "engaged", goal_id: goal, + fence_id: `planned-transfer:${operation}`, operation_id: operation, + source_identity_sha256: canonicalAuthoritySha256(preparation), + }); + // Export reuses the native retained-journal owner and its independent audit. + // The fence stays closed on every error, including interrupted publication. + const archive = join(directory, "authority.ndjson"); + let captured: AuthorityArchiveSummary; + try { captured = await verifyAuthorityArchive(archive); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + captured = await exportAuthorityArchive(opened.store, goal, archive); + } + if (captured.goal_id !== goal || captured.source_store_identity !== identity.store_identity || + captured.source_provider_revision !== head.provider_revision || captured.commits !== head.cursor || + captured.projection_sha256 !== facts.source_projection_sha256) reject("Transfer archive differs from its prepared source"); + const result: JsonObject = {schema_version: PREPARATION_SCHEMA, operation_id: operation, goal_id: goal, + authority_write_fenced: true, authority_archive: {...captured}, archive, + host_drain_qualified: false, portable_artifact_qualified: false, execution_authority_granted: false}; + await retain(join(directory, "receipt.json"), result); + return result; + }); +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index a055003a97..a1a9c24c4a 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -416,6 +416,7 @@ export function createEffectRuntimeHandlers( ["coordination.runtime_shadow.rollback", lazyHandler(() => Promise.all([import("./coordination/runtime_shadow.ts"), import("./coordination/source_transfer.ts")]), ([{rollbackCoordinationRuntimeShadow}, {withCoordinationSourceTransfer}]) => withCoordinationSourceTransfer("coordination.runtime_shadow.rollback", rollbackCoordinationRuntimeShadow))], ["coordination.local_authority.promote", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({promoteLocalCoordinationAuthority}) => promoteLocalCoordinationAuthority)], ["coordination.authority_archive.manage", lazyHandler(() => import("./coordination/local_authority_archive.ts"), ({manageLocalAuthorityArchive}) => manageLocalAuthorityArchive)], + ["coordination.planned_transfer.prepare_authority", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({prepareAuthorityForPlannedTransfer}) => prepareAuthorityForPlannedTransfer)], ["configuration.backup", lazyHandler(() => import("./configuration_backup.ts"), ({configurationBackupOperation}) => configurationBackupOperation)], ["runtime.checkpoint_artifacts", lazyHandler(() => import("./runtime/checkpoint_artifacts.ts"), ({checkpointArtifacts}) => checkpointArtifacts)], ["state.recovery.archive.verify", lazyHandler(() => import("./state_recovery.ts"), ({verifyStateRecoveryArchive}) => verifyStateRecoveryArchive)], diff --git a/tests/control_plane/test_planned_transfer_source_boundary.py b/tests/control_plane/test_planned_transfer_source_boundary.py index d9a58c777a..59cae3e08d 100644 --- a/tests/control_plane/test_planned_transfer_source_boundary.py +++ b/tests/control_plane/test_planned_transfer_source_boundary.py @@ -1,10 +1,12 @@ """Migration must compose admission closure and drain, not reinterpret Goal stop. -These are real CLI/provider characterizations of the existing owners. They do -not implement destination admission or qualify a complete Agent transfer. +These are real CLI/provider checks of the existing owners and the source +authority preparation under development. They do not implement destination +admission or qualify Host drain or a complete Agent transfer. """ from __future__ import annotations +import hashlib import json import subprocess import sys @@ -12,7 +14,9 @@ import pytest -from loopx.control_plane.effect_runtime import restart_effect_runtime +from loopx.control_plane.effect_runtime import effect_runtime_result, restart_effect_runtime +from loopx.control_plane.coordination.legacy_writer_fence import legacy_coordination_writer_fence_path +from loopx.control_plane.coordination.local_authority_shadow_projection import canonical_bytes from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime @@ -129,5 +133,44 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: assert update("released-proof-write", "Must not revive released work.", expected_code=1)["ok"] is False assert inspect()["provider_revision"] == released["provider_revision"] assert cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"]["note"] == retained["note"] + + # Establish the actual counterfactual: with registration retained, a + # new ordinary execution can still be admitted before preparation. + fresh_before = cli("task-lease", "acquire", "--goal-id", "report", "--todo-id", todo, + "--owner", "agent-a", "--idempotency-key", "fresh-before-preparation", + "--write-scope", "report.txt", "--write-worktree", str(worktree))["lease"] + cli("task-lease", "release", "--goal-id", "report", "--todo-id", todo, + "--owner", "agent-a", "--idempotency-key", fresh_before["idempotency_key"], + "--expected-version", str(fresh_before["version"])) + released = inspect() + + # Unlike temporary membership withdrawal, the authority preparation + # survives retained registration, fresh execution keys and a cold Host. + # It only fences the authority: no source Host drain or target admission + # is inferred from this real production-entrypoint check. + original_fence = json.loads(legacy_coordination_writer_fence_path( + runtime_root=runtime, goal_id="report").read_text()) + request = {"runtime_root": str(runtime), "goal_id": "report", "operation_id": "prepared-move", + "expected_provider_revision": released["provider_revision"], + "expected_writer_fence_sha256": hashlib.sha256(canonical_bytes(original_fence)).hexdigest(), + "execute": True} + prepared = effect_runtime_result("coordination.planned_transfer.prepare_authority", request, + timeout=45, retry_safe=False) + assert prepared["authority_write_fenced"] is True + assert prepared["execution_authority_granted"] is False + assert prepared["host_drain_qualified"] is False + for cold in (False, True): + if cold: + restart_effect_runtime(wait_for_exit=True) + assert effect_runtime_result("coordination.planned_transfer.prepare_authority", request, + timeout=45, retry_safe=False) == prepared + rejected = update(f"prepared-write-{cold}", "Must not overwrite prepared work.", expected_code=1) + assert rejected["reason_code"] == "canonical_authority_transfer_prepared" + acquired = cli("task-lease", "acquire", "--goal-id", "report", "--todo-id", todo, + "--owner", "agent-a", "--idempotency-key", f"fresh-after-preparation-{cold}", + "--write-scope", "report.txt", "--write-worktree", str(worktree), expected_code=1) + assert "prepared for transfer" in acquired["error"] + assert inspect()["provider_revision"] == released["provider_revision"] + assert cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"]["note"] == retained["note"] finally: restart_effect_runtime(wait_for_exit=True) diff --git a/tests/control_plane_ts/planned_transfer_authority.test.ts b/tests/control_plane_ts/planned_transfer_authority.test.ts new file mode 100644 index 0000000000..61f1da3a90 --- /dev/null +++ b/tests/control_plane_ts/planned_transfer_authority.test.ts @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import {mkdtemp, mkdir, readFile, readdir, rm, writeFile} from "node:fs/promises"; +import {join} from "node:path"; +import {tmpdir} from "node:os"; +import test, {type TestContext} from "node:test"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {prepareAuthorityForPlannedTransfer as prepare} from "../../loopx/control_plane/coordination/planned_transfer_authority.ts"; +import {selectLocalAuthorityTarget, openLocalAuthorityStoreHandle} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; +import {COLD_SOURCE_IMPORT_WRITER_FENCE_SCHEMA, legacyCoordinationWriterFencePath, + loadLegacyCoordinationWriterFence, checkLegacyCoordinationWriteAllowed, + LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; +import {CanonicalWriteRouteError, withCanonicalWriter, type CanonicalBusinessWrite} from "../../loopx/control_plane/coordination/local_authority_write.ts"; +import {projectCoordinationSource, SOURCE_PROJECTION_REQUEST_SCHEMA} from "../../loopx/control_plane/coordination/source_projection.ts"; +import {shadowManagementDirectory} from "../../loopx/control_plane/coordination/shadow_management.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {manageLocalAuthorityArchive} from "../../loopx/control_plane/coordination/local_authority_archive.ts"; +import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {todo} from "./shadow_file_fixture.ts"; + +async function fixture(t: TestContext, provider: "file" | "sqlite", leaseStatus = "released") { + const root = await mkdtemp(join(tmpdir(), "loopx-prepared-transfer-")); + t.after(() => rm(root, {recursive: true, force: true})); + await selectLocalAuthorityTarget(root, "goal-a", provider, true); + const opened = await openLocalAuthorityStoreHandle(root, "goal-a"); + const records = [{...todo("unfinished"), claimed_by: "agent-a", required_write_scopes: ["reports/**"], + required_capabilities: ["filesystem_read", "filesystem_write"], successor_todo_ids: ["accepted"], + text: "Read the required input and finish the report", note: "Original decision context"}, + {...todo("accepted", "done"), archive_state: "archive", source_section: "Agent Todo Archive", + evidence: "Independent result acceptance", text: "Original accepted work"}]; + const lease = {schema_version: "task_lease_v0", goal_id: "goal-a", todo_id: "unfinished", owner: "agent-a", + idempotency_key: "original-execution", status: leaseStatus, version: 3, lease_epoch: 2, + expires_at: "2000-01-01T00:00:00Z"}; + const projection = projectCoordinationSource({schema_version: SOURCE_PROJECTION_REQUEST_SCHEMA, kind: "snapshot", + goal_id: "goal-a", handoff_mode: "hard_lease", todos: records, leases: [lease], + read_model_schema: "loopx_todo_canonical_read_record_v0"}).projection as JsonObject; + let revision: string | null = null; + for (let n = 1; n <= 3; n++) { + const result = await opened.store.commitAuthority({expected_provider_revision: revision, operation_id: `original-${n}`, + next_projection: projection, events: [{kind: "retained-fact", n}], receipts: [{operation_id: `original-${n}`, independent_fact: n}]}); + assert.equal(result.status, "applied"); if (result.status !== "applied") throw new Error("fixture commit failed"); + revision = result.provider_revision; + } + const fence = {schema_version: COLD_SOURCE_IMPORT_WRITER_FENCE_SCHEMA, state: "engaged", goal_id: "goal-a", + fence_id: "original-import", import_operation_id: "original-import", import_plan_sha256: "a".repeat(64)}; + const fencePath = legacyCoordinationWriterFencePath(root, "goal-a"); + await mkdir(join(root, "authority-transition", "file-v0"), {recursive: true}); + await writeFile(fencePath, JSON.stringify(fence)); + const request = {runtime_root: root, goal_id: "goal-a", operation_id: "move-original", expected_provider_revision: revision, + expected_writer_fence_sha256: canonicalAuthoritySha256(fence), execute: true}; + const directory = join(shadowManagementDirectory(root, "goal-a"), "planned-transfers", canonicalAuthoritySha256("move-original")); + return {root, store: opened.store, projection, request, directory, fencePath, fence}; +} + +for (const provider of ["file", "sqlite"] as const) { + test(`${provider}: preparation fences native/legacy writes and retains original history through the existing restore owner`, async t => { + const f = await fixture(t, provider); + for (const change of [{execute: false}, {unexpected: true}, {expected_writer_fence_sha256: "b".repeat(64)}]) { + await assert.rejects(prepare({...f.request, ...change}), /explicit execution|complete reviewed|writer fence changed/); + assert.deepEqual(JSON.parse(await readFile(f.fencePath, "utf8")), f.fence); + await assert.rejects(readFile(join(f.directory, "preparation.json")), {code: "ENOENT"}); + } + let calls = 0; + assert.equal(await withCanonicalWriter(f.root, "goal-a", false, async () => ++calls, {businessWrite: "todo_update"}), 1); + const before = await f.store.loadAuthority(); + const prepared = await prepare(f.request); + assert.equal(prepared.authority_write_fenced, true); + assert.equal(prepared.execution_authority_granted, false); + assert.equal(prepared.host_drain_qualified, false); + assert.equal(prepared.portable_artifact_qualified, false); + assert.deepEqual(await f.store.loadAuthority(), before, "preparation must not change native history or original receipts"); + const originalFence = await readFile(f.fencePath); + const originalReceipt = await readFile(join(f.directory, "receipt.json")); + assert.deepEqual(await prepare(f.request), prepared); + assert.deepEqual(await readFile(f.fencePath), originalFence); + assert.deepEqual(await readFile(join(f.directory, "receipt.json")), originalReceipt); + const writes: CanonicalBusinessWrite[] = ["todo_claim", "todo_create", "todo_update", "todo_terminal", "todo_archive", + "monitor_poll", "team_plan", "handoff_mode_set", "task_lease_acquire", "task_lease_renew", "task_lease_transfer", "task_lease_release", "goal_create"]; + for (const businessWrite of writes) { + await assert.rejects(withCanonicalWriter(f.root, "goal-a", false, async () => ++calls, {businessWrite}), + (error: unknown) => error instanceof CanonicalWriteRouteError && error.code === "canonical_authority_transfer_prepared"); + } + await assert.rejects(withCanonicalWriter(f.root, "goal-a", false, async () => ++calls), /prepared for transfer/); + assert.equal(calls, 1, "no protected callback runs after preparation"); + const legacy = await checkLegacyCoordinationWriteAllowed({schema_version: LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, + runtime_root: f.root, goal_id: "goal-a"}); + assert.equal(legacy.status, "blocked"); + const archive = prepared.authority_archive as JsonObject; + for (const targetProvider of ["file", "sqlite"] as const) { + const destination = join(f.root, `inert-${targetProvider}`); + const restored = await manageLocalAuthorityArchive({schema_version: "loopx_authority_archive_admin_request_v0", + action: "restore", goal_id: "goal-a", provider: targetProvider, archive: prepared.archive, + archive_sha256: archive.archive_sha256, destination, execute: true}); + assert.equal(restored.status, "restored", JSON.stringify(restored)); + assert.equal(restored.execution_authority_granted, false); + const target = targetProvider === "file" ? new FileAuthorityStore(join(destination, "store"), "goal-a", {existingOnly: true}) + : new SqliteAuthorityStore(join(destination, "store"), "goal-a", {existingOnly: true}); + const after = await target.loadAuthority(); + assert.equal(after.status, "loaded"); if (after.status !== "loaded") throw new Error("missing target"); + assert.deepEqual(after.head, f.projection); + assert.notEqual(after.provider_revision, f.request.expected_provider_revision); + for (let n = 1; n <= 3; n++) { + const receipt = await target.readReceipt(`original-${n}`); + assert.equal(receipt.status, "found"); + if (receipt.status === "found") assert.deepEqual(receipt.receipts, [{operation_id: `original-${n}`, independent_fact: n}]); + } + } + await assert.rejects(prepare({...f.request, operation_id: "another-move"}), /another transfer operation/); + assert.deepEqual(await f.store.loadAuthority(), before); + }); + + test(`${provider}: expired active lease and changed preview reject before fencing`, async t => { + const f = await fixture(t, provider, "active"); + const original = await readFile(f.fencePath), head = await f.store.loadAuthority(); + await assert.rejects(prepare({...f.request, expected_provider_revision: "stale:revision"}), /changed/); + await assert.rejects(prepare(f.request), /unreleased lease/); + assert.deepEqual(await readFile(f.fencePath), original); + assert.deepEqual(await f.store.loadAuthority(), head); + await assert.rejects(readFile(join(f.directory, "preparation.json")), {code: "ENOENT"}); + }); + + test(`${provider}: interrupted capture stays fenced and same-operation retry recovers without reinitializing`, async t => { + const f = await fixture(t, provider); + // Actual filesystem publication failure after the fence, without replacing + // an AuthorityStore or inventing a successful owner receipt. + await mkdir(join(f.directory, "authority.ndjson"), {recursive: true}); + await assert.rejects(prepare(f.request)); + assert.equal((await loadLegacyCoordinationWriterFence(f.root, "goal-a")).status, "loaded"); + await assert.rejects(withCanonicalWriter(f.root, "goal-a", false, async () => assert.fail("writer ran"), + {businessWrite: "todo_update"}), /prepared for transfer/); + await assert.rejects(readFile(join(f.directory, "receipt.json")), {code: "ENOENT"}); + await rm(join(f.directory, "authority.ndjson"), {recursive: true}); + const recovered = await prepare(f.request); + assert.equal(recovered.authority_write_fenced, true); + assert.equal((recovered.authority_archive as JsonObject).commits, "3"); + const bytes = await readFile(String(recovered.archive)); + await writeFile(String(recovered.archive), Buffer.concat([bytes, Buffer.from("corrupt\n")])); + await assert.rejects(prepare(f.request), /terminal seal/); + await assert.rejects(withCanonicalWriter(f.root, "goal-a", false, async () => assert.fail("writer ran"), + {businessWrite: "task_lease_acquire"}), /prepared for transfer/); + }); + + test(`${provider}: unknown Goal and lost provider never create a replacement authority`, async t => { + const f = await fixture(t, provider); + const before = await readdir(join(f.root, "authority"), {recursive: true}); + await assert.rejects(prepare({...f.request, goal_id: "unregistered"}), /original verified canonical writer fence/); + assert.deepEqual(await readdir(join(f.root, "authority"), {recursive: true}), before); + const source = f.store; + assert.ok(source instanceof FileAuthorityStore || source instanceof SqliteAuthorityStore); + await rm(source.path); + await assert.rejects(prepare(f.request), /missing|unavailable|restore|open/i); + await assert.rejects(readFile(source.path), {code: "ENOENT"}); + assert.deepEqual(JSON.parse(await readFile(f.fencePath, "utf8")), f.fence); + await assert.rejects(readFile(join(f.directory, "preparation.json")), {code: "ENOENT"}); + }); + + test(`${provider}: an admitted writer racing the preview wins before preparation, which requires a fresh review`, async t => { + const f = await fixture(t, provider); + let entered!: () => void, resume!: () => void; + const inside = new Promise(resolve => {entered = resolve;}); + const release = new Promise(resolve => {resume = resolve;}); + const writer = withCanonicalWriter(f.root, "goal-a", false, async () => { + entered(); + await release; + return f.store.commitAuthority({expected_provider_revision: f.request.expected_provider_revision, + operation_id: "admitted-before-transfer", next_projection: {...f.projection, progress: "later source progress"}, + events: [{kind: "later-source-fact"}], receipts: [{source_progress_preserved: true}]}); + }, {businessWrite: "todo_update"}); + await inside; + const refused = assert.rejects(prepare(f.request), /Source authority changed/); + resume(); + assert.equal((await writer).status, "applied"); + await refused; + assert.deepEqual(JSON.parse(await readFile(f.fencePath, "utf8")), f.fence); + assert.equal((await f.store.readReceipt("admitted-before-transfer")).status, "found"); + await assert.rejects(readFile(join(f.directory, "preparation.json")), {code: "ENOENT"}); + }); +} From d73621aa4665518abd1c5769f553578d49252955 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:52:30 +0800 Subject: [PATCH 02/55] feat(coordination): stage original Goal history for planned transfer Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../coordination/legacy_writer_fence.ts | 21 +- .../coordination/local_authority_write.ts | 4 +- .../planned_transfer_authority.ts | 17 +- .../coordination/planned_transfer_target.ts | 150 ++++++++++++++ .../control_plane/effect_runtime_handlers.ts | 1 + loopx/control_plane/goals/planned_transfer.ts | 148 ++++++++++++++ .../goals/planned_transfer_io.py | 95 +++++++++ .../test_planned_transfer_source_boundary.py | 106 +++++++++- .../planned_transfer_authority.test.ts | 193 +++++++++++++++++- 9 files changed, 716 insertions(+), 19 deletions(-) create mode 100644 loopx/control_plane/coordination/planned_transfer_target.ts create mode 100644 loopx/control_plane/goals/planned_transfer.ts create mode 100644 loopx/control_plane/goals/planned_transfer_io.py diff --git a/loopx/control_plane/coordination/legacy_writer_fence.ts b/loopx/control_plane/coordination/legacy_writer_fence.ts index 86786ee59f..07cf33e6ea 100644 --- a/loopx/control_plane/coordination/legacy_writer_fence.ts +++ b/loopx/control_plane/coordination/legacy_writer_fence.ts @@ -37,9 +37,19 @@ export { /** A cold import pins its own source and operation; it has no shadow revision. */ export const COLD_SOURCE_IMPORT_WRITER_FENCE_SCHEMA = "loopx_cold_source_import_writer_fence_v0"; -/** Source-side write exclusion. Older decoders reject this schema, so an older - * binary cannot mistake prepared history for an ordinary writable authority. */ +/** Transfer write exclusion on a prepared source or an inert destination. + * Older decoders reject this schema rather than treating retained history as a + * fresh execution grant. The role is part of the exact persisted binding. */ export const PLANNED_TRANSFER_WRITER_FENCE_SCHEMA = "loopx_planned_transfer_writer_fence_v0"; +export interface PlannedTransferWriterFence extends JsonObject { + schema_version: typeof PLANNED_TRANSFER_WRITER_FENCE_SCHEMA; + state: "engaged"; + role: "source" | "target"; + goal_id: string; + fence_id: string; + operation_id: string; + source_identity_sha256: string; +} // Caller adapter: remediation is rendered here, never inside // checkLegacyCoordinationWriteAllowed, which owns only the stable typed reason @@ -115,7 +125,8 @@ export function decodeLegacyCoordinationWriterFence(value: unknown): JsonObject const fence = canonicalAuthorityObject(value, "legacy coordination writer fence"); if (fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) { if (fence.state !== "engaged" || !hasExactAuthorityKeys(fence, - ["schema_version", "state", "goal_id", "fence_id", "operation_id", "source_identity_sha256"]) || + ["schema_version", "state", "goal_id", "fence_id", "operation_id", "source_identity_sha256", "role"]) || + (fence.role !== "source" && fence.role !== "target") || !BARE_SHA256_PATTERN.test(String(fence.source_identity_sha256))) throw new Error("Invalid planned transfer writer fence"); for (const key of ["goal_id", "fence_id", "operation_id"]) requireAuthorityStoreId(fence[key], key); return fence; @@ -199,12 +210,12 @@ export async function completeNewGoalWriterFenceUnderLocks(root: string, goalId: * and complete source identity. This transition never reopens a prepared source. */ export async function preparePlannedTransferWriterFenceUnderLock(root: string, goalId: string, - prior: JsonObject, prepared: JsonObject): Promise { + prior: JsonObject, prepared: PlannedTransferWriterFence): Promise { decodeLegacyCoordinationWriterFence(prior); decodeLegacyCoordinationWriterFence(prepared); if (prior.goal_id !== goalId || prepared.goal_id !== goalId || prior.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA || - prepared.schema_version !== PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) throw new Error("Invalid transfer fence transition"); + prepared.schema_version !== PLANNED_TRANSFER_WRITER_FENCE_SCHEMA || prepared.role !== "source") throw new Error("Invalid transfer fence transition"); const path = legacyCoordinationWriterFencePath(root, goalId); await withFileMutationLock(path, async () => { const current = await loadLegacyCoordinationWriterFence(root, goalId); diff --git a/loopx/control_plane/coordination/local_authority_write.ts b/loopx/control_plane/coordination/local_authority_write.ts index ebbd28f37a..50c2e82576 100644 --- a/loopx/control_plane/coordination/local_authority_write.ts +++ b/loopx/control_plane/coordination/local_authority_write.ts @@ -83,7 +83,7 @@ export async function requireCanonicalBusinessWrite( } if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) { throw new CanonicalWriteRouteError("canonical_authority_transfer_prepared", goalId, operation, - `Source authority is prepared for transfer ${fence.fence.operation_id}; recover that operation before retrying`); + `Authority is prepared for transfer ${fence.fence.operation_id}; recover that operation before retrying`); } } @@ -105,7 +105,7 @@ export async function withCanonicalWriter( const fence = await loadLegacyCoordinationWriterFence(root, goalId); if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) { throw new CanonicalWriteRouteError("canonical_authority_transfer_prepared", goalId, "authority_maintenance", - `Source authority is prepared for transfer ${fence.fence.operation_id}; recover that operation before retrying`); + `Authority is prepared for transfer ${fence.fence.operation_id}; recover that operation before retrying`); } } return await write(); diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index fddfc06fba..a530a06cf8 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -20,8 +20,9 @@ import {openLocalAuthorityStoreHandle, requireLocalAuthorityRuntimeRoot} from ". import {loadLegacyCoordinationWriterFence, preparePlannedTransferWriterFenceUnderLock, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; import {readShadowManagementState, shadowManagementDirectory, withShadowMaintenanceLock} from "./shadow_management.ts"; +import {withVerifiedGoalTransferContext} from "../goals/planned_transfer.ts"; -const PREPARATION_SCHEMA = "loopx_planned_transfer_authority_preparation_v0"; +export const PREPARATION_SCHEMA = "loopx_planned_transfer_authority_preparation_v0"; function reject(message: string): never { throw new EffectRuntimeConflictError(message); } function same(a: unknown, b: unknown): boolean { return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b)); } @@ -47,8 +48,9 @@ async function retain(path: string, value: JsonObject): Promise { * It never reopens the source or rewrites a receipt following later changes. */ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Promise { + const contextBound = value.source_context !== undefined; if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id", "expected_provider_revision", - "expected_writer_fence_sha256", "execute"]) || value.execute !== true || + "expected_writer_fence_sha256", "execute", ...(contextBound ? ["source_context", "registry_witness"] : [])]) || value.execute !== true || typeof value.expected_writer_fence_sha256 !== "string" || !BARE_SHA256_PATTERN.test(value.expected_writer_fence_sha256)) { reject("Transfer authority preparation requires an explicit execution and the complete reviewed source identity"); } @@ -60,6 +62,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro expected_writer_fence_sha256: value.expected_writer_fence_sha256}; const directory = join(shadowManagementDirectory(root, goal), "planned-transfers", canonicalAuthoritySha256(operation)); return withShadowMaintenanceLock(root, goal, async () => { + const prepare = async (context?: JsonObject) => { const managed = await readShadowManagementState(root, goal); if (managed && managed.status !== "inactive") reject("Source capture requires disposition before planned transfer"); const current = await loadLegacyCoordinationWriterFence(root, goal); @@ -78,7 +81,8 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro const pending = await optionalJson(pendingPath); const facts = {schema_version: PREPARATION_SCHEMA, request, source_provider: opened.provider, source_store_identity: identity.store_identity, - source_cursor: head.cursor, source_projection_sha256: canonicalAuthoritySha256(head.head)}; + source_cursor: head.cursor, source_projection_sha256: canonicalAuthoritySha256(head.head), + ...(context ? {source_context: context, source_context_sha256: canonicalAuthoritySha256(context)} : {})}; let priorFence: JsonObject; if (pending) { priorFence = requireJsonObject(pending.original_writer_fence, "original source writer fence"); @@ -86,7 +90,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro // A carrier is written before the fence, so an interrupted first call may // still find the exact original fence. Neither case permits other bytes. const expectedFence = {schema_version: PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, state: "engaged", goal_id: goal, - fence_id: `planned-transfer:${operation}`, operation_id: operation, source_identity_sha256: canonicalAuthoritySha256(pending)}; + role: "source", fence_id: `planned-transfer:${operation}`, operation_id: operation, source_identity_sha256: canonicalAuthoritySha256(pending)}; if (!same(current.fence, priorFence) && !same(current.fence, expectedFence)) reject("Source writer fence changed during transfer preparation"); } else { if (current.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) reject("Source is already prepared by another transfer operation"); @@ -98,7 +102,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro await retain(pendingPath, preparation); await preparePlannedTransferWriterFenceUnderLock(root, goal, priorFence, { schema_version: PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, state: "engaged", goal_id: goal, - fence_id: `planned-transfer:${operation}`, operation_id: operation, + role: "source", fence_id: `planned-transfer:${operation}`, operation_id: operation, source_identity_sha256: canonicalAuthoritySha256(preparation), }); // Export reuses the native retained-journal owner and its independent audit. @@ -115,8 +119,11 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro captured.projection_sha256 !== facts.source_projection_sha256) reject("Transfer archive differs from its prepared source"); const result: JsonObject = {schema_version: PREPARATION_SCHEMA, operation_id: operation, goal_id: goal, authority_write_fenced: true, authority_archive: {...captured}, archive, + ...(context ? {source_context_sha256: canonicalAuthoritySha256(context)} : {}), host_drain_qualified: false, portable_artifact_qualified: false, execution_authority_granted: false}; await retain(join(directory, "receipt.json"), result); return result; + }; + return contextBound ? withVerifiedGoalTransferContext(value.source_context, value.registry_witness, goal, root, prepare) : prepare(); }); } diff --git a/loopx/control_plane/coordination/planned_transfer_target.ts b/loopx/control_plane/coordination/planned_transfer_target.ts new file mode 100644 index 0000000000..eca599e0eb --- /dev/null +++ b/loopx/control_plane/coordination/planned_transfer_target.ts @@ -0,0 +1,150 @@ +/** Restore original authority into a stopped, fenced destination Goal. + * No Host, credential, lease or effect admission is performed by this stage. */ +import {mkdir, readFile, realpath} from "node:fs/promises"; +import {dirname, join, parse} from "node:path"; +import {createHash} from "node:crypto"; +import type {JsonObject} from "../effect_program.ts"; +import {durableWriteJson, atomicWriteText, withFileMutationLock} from "../effect_runtime_io.ts"; +import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {planPreparedGoalStage, stagedGoalRegistry} from "../goals/planned_transfer.ts"; +import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys} from "./authority_store_codec.ts"; +import {verifyAuthorityArchive, restoreAuthorityArchive} from "./authority_archive.ts"; +import {auditAuthorityArchive} from "./authority_archive_audit.ts"; +import {FileAuthorityStore} from "./file_authority_store.ts"; +import {SqliteAuthorityStore} from "./sqlite_authority_store.ts"; +import {localAuthorityProviderPaths, publishLocalAuthoritySelection} from "./local_authority_provider.ts"; +import {engageLegacyCoordinationWriterFenceUnderLocks, loadLegacyCoordinationWriterFence, + PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, type PlannedTransferWriterFence} from "./legacy_writer_fence.ts"; +import {readShadowManagementState, shadowManagementDirectory, withShadowMaintenanceLock} from "./shadow_management.ts"; +import {PREPARATION_SCHEMA} from "./planned_transfer_authority.ts"; +import {legacyCoordinationTodoLockPath} from "./legacy_writer_lock_paths.ts"; + +function reject(message: string): never { throw new EffectRuntimeConflictError(message); } +function same(a: unknown, b: unknown): boolean { return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b)); } +async function optionalBytes(path: string): Promise { + try { return await readFile(path); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } +} +async function retainJson(path: string, value: JsonObject): Promise { + const prior = await optionalBytes(path); + if (prior) { if (!same(JSON.parse(prior.toString("utf8")), value)) reject("Original transfer staging identity changed"); return; } + await durableWriteJson(path, value); + if (!same(JSON.parse((await readFile(path)).toString("utf8")), value)) reject("Transfer staging readback failed"); +} +async function physicalAncestors(path: string): Promise { + let current = path; + for (;;) { + try { if (await realpath(current) !== current) reject("Transfer destination contains a linked ancestor"); return; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (current === parse(current).root) reject("Transfer destination has no physical parent"); + current = dirname(current); + } +} + +/** Maintenance/source/registry lock order matches the existing canonical owner. + * The adapter later commits only the returned registry payload through its + * codec CAS. Every effect is bound to the original operation; interrupted + * publication leaves a stopped destination that the same operation can recover. */ +export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["source_preparation", "source_context", "archive", "target", "destination_registry", "registry_witness", "execute"]) || + value.execute !== true || typeof value.archive !== "string") reject("Explicit complete Goal transfer staging is required"); + const prepared = requireJsonObject(value.source_preparation, "prepared original authority"); + if (prepared.schema_version !== PREPARATION_SCHEMA || prepared.authority_write_fenced !== true || + prepared.execution_authority_granted !== false) reject("An original source authority preparation is required"); + const summary = requireJsonObject(prepared.authority_archive, "prepared authority summary"); + const plan = planPreparedGoalStage({operation_id: prepared.operation_id, source_context: value.source_context, + archive_sha256: summary.archive_sha256, target: value.target, destination_registry: value.destination_registry}); + if (prepared.goal_id !== plan.goalId || prepared.source_context_sha256 !== canonicalAuthoritySha256(plan.context)) reject("Prepared source and original Goal context differ"); + const target = value.target as JsonObject, root = target.runtime_root as string; + const witness = requireJsonObject(value.registry_witness, "codec-validated destination witness"); + if (!hasExactAuthorityKeys(witness, ["path", "sha256"]) || witness.path !== target.registry_path || + (witness.sha256 !== null && typeof witness.sha256 !== "string")) reject("Invalid destination registry witness"); + const verifyRegistry = async () => { + const bytes = await optionalBytes(String(witness.path)); + const digest = bytes === null ? null : createHash("sha256").update(bytes).digest("hex"); + if (digest !== witness.sha256) reject("Destination registry changed; retry staging through its original codec"); + }; + await verifyRegistry(); + await physicalAncestors(root); await physicalAncestors(String(target.project)); + const archive = await verifyAuthorityArchive(value.archive); + if (!same({...archive}, summary) || archive.goal_id !== plan.goalId) reject("Transfer archive differs from the prepared original authority"); + return withShadowMaintenanceLock(root, plan.goalId, () => + withFileMutationLock(legacyCoordinationTodoLockPath(root, plan.goalId), () => + withFileMutationLock(plan.statePath, () => withFileMutationLock(String(witness.path), async () => { + await verifyRegistry(); + const managed = await readShadowManagementState(root, plan.goalId); + if (managed && managed.status !== "inactive") reject("Destination shadow capture requires its original owner"); + const directory = join(shadowManagementDirectory(root, plan.goalId), "planned-transfer-targets", canonicalAuthoritySha256(plan.operationId)); + await physicalAncestors(directory); await physicalAncestors(plan.statePath); await physicalAncestors(plan.contextPath); + const pendingPath = join(directory, "preparation.json"); + const pending = await optionalBytes(pendingPath); + if (pending && !same(JSON.parse(pending.toString("utf8")), plan.identity)) reject("Destination transfer operation belongs to different original facts"); + const paths = localAuthorityProviderPaths(root, plan.goalId); + const fence = {schema_version: PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, state: "engaged", goal_id: plan.goalId, + role: "target", fence_id: `planned-transfer-target:${plan.operationId}`, operation_id: plan.operationId, + source_identity_sha256: canonicalAuthoritySha256(plan.identity)} satisfies PlannedTransferWriterFence; + const before = await loadLegacyCoordinationWriterFence(root, plan.goalId); + if (before.status === "failed" || (before.status === "loaded" && !same(before.fence, fence))) reject("Destination authority is occupied or its fence changed"); + const file = new FileAuthorityStore(paths.file, plan.goalId, {existingOnly: true}); + const sqlite = new SqliteAuthorityStore(paths.sqlite, plan.goalId, {existingOnly: true}); + const selected = target.provider === "file" ? file : sqlite, other = target.provider === "file" ? sqlite : file; + const selectedHead = await selected.loadAuthority(), otherHead = await other.loadAuthority(); + const selectedIdentity = await selected.storeIdentity(), otherIdentity = await other.storeIdentity(); + if (otherHead.status !== "missing" || (!pending && (selectedHead.status !== "missing" || before.status !== "missing" || await optionalBytes(paths.marker)))) { + reject("Destination already contains authority; staging cannot replace it"); + } + // File's identity belongs to the shared provider directory; other Goals + // may legitimately use it. SQLite's database/identity is per Goal. + if (!pending && ((target.provider === "sqlite" && selectedIdentity.status === "available") || + (target.provider === "file" && otherIdentity.status === "available"))) reject("Destination retains a Goal store lineage; restore it rather than adopting it as empty"); + if (selectedHead.status === "failed" || selectedHead.status === "unavailable") reject("Destination authority requires recovery, never reinitialization"); + const storeBindingPath = join(directory, "target-store.json"), binding = await optionalBytes(storeBindingPath); + if (binding) { + const observed = await selected.storeIdentity(); + if (observed.status !== "available" || !same(JSON.parse(binding.toString("utf8")), {store_identity: observed.store_identity})) { + reject("Destination store lineage is missing or changed; restore its complete state, never reinitialize it"); + } + } + const marker = await optionalBytes(paths.marker); + if (marker) { + const observed = await selected.storeIdentity(); + if (observed.status !== "available" || !same(JSON.parse(marker.toString("utf8")), { + schema_version: "loopx_local_authority_provider_v0", goal_id: plan.goalId, + provider: target.provider, store_identity: observed.store_identity})) reject("Destination provider selection changed; preserve later state"); + } + if (await optionalBytes(join(directory, "receipt.json"))) { + const priorAudit = await auditAuthorityArchive(value.archive as string, selected, archive.archive_sha256); + if (priorAudit.status !== "matched") reject("Completed destination authority changed or is missing; use its recovery owner"); + } + // State/context are immutable original input, not authority reconstructed + // from Markdown. No credential-bearing source config enters the live Goal. + const originalState = await optionalBytes(plan.statePath), originalContext = await optionalBytes(plan.contextPath); + if (!pending && (originalState || originalContext)) reject("Destination Goal material is occupied; retain its original owner"); + if ((originalState && !originalState.equals(Buffer.from(String(plan.context.state_text)))) || + (originalContext && !same(JSON.parse(originalContext.toString("utf8")), plan.context))) reject("Destination Goal material is occupied or changed"); + await mkdir(directory, {recursive: true, mode: 0o700}); + await retainJson(pendingPath, plan.identity); + const engaged = await engageLegacyCoordinationWriterFenceUnderLocks(root, plan.goalId, plan.statePath, fence); + if (engaged.status !== "applied" && engaged.status !== "replayed") reject("Destination transfer fence readback failed"); + await mkdir(dirname(plan.statePath), {recursive: true, mode: 0o700}); + if (!originalState) await atomicWriteText(plan.statePath, String(plan.context.state_text)); + await retainJson(plan.contextPath, plan.context); + const Store = target.provider === "file" ? FileAuthorityStore : SqliteAuthorityStore; + const store = new Store(paths[target.provider as "file" | "sqlite"], plan.goalId); + const identity = await store.storeIdentity(); + if (identity.status !== "available") reject("Restored destination identity is unavailable"); + await retainJson(storeBindingPath, {store_identity: identity.store_identity}); + await restoreAuthorityArchive(value.archive as string, store, archive.archive_sha256); + const audit = await auditAuthorityArchive(value.archive as string, store, archive.archive_sha256); + if (audit.status !== "matched") reject("Complete original authority/receipt readback did not match"); + await publishLocalAuthoritySelection(root, plan.goalId, target.provider as "file" | "sqlite", identity.store_identity); + const result = {schema_version: "loopx_planned_transfer_target_stage_v0", operation_id: plan.operationId, goal_id: plan.goalId, + identity_sha256: canonicalAuthoritySha256(plan.identity), original_authority: {...archive}, authority_readback: audit, + target_store_identity: identity.store_identity, target_provider: target.provider, + target_is_active: false, fresh_admission_required: true, execution_authority_granted: false}; + await retainJson(join(directory, "receipt.json"), result); + return {...result, destination_registry: stagedGoalRegistry(value.destination_registry as JsonObject, plan, root), + original_context_path: plan.contextPath}; + })))); +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index a1a9c24c4a..526c5bd133 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -417,6 +417,7 @@ export function createEffectRuntimeHandlers( ["coordination.local_authority.promote", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({promoteLocalCoordinationAuthority}) => promoteLocalCoordinationAuthority)], ["coordination.authority_archive.manage", lazyHandler(() => import("./coordination/local_authority_archive.ts"), ({manageLocalAuthorityArchive}) => manageLocalAuthorityArchive)], ["coordination.planned_transfer.prepare_authority", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({prepareAuthorityForPlannedTransfer}) => prepareAuthorityForPlannedTransfer)], + ["coordination.planned_transfer.stage_target", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({stageAuthorityForPlannedTransfer}) => stageAuthorityForPlannedTransfer)], ["configuration.backup", lazyHandler(() => import("./configuration_backup.ts"), ({configurationBackupOperation}) => configurationBackupOperation)], ["runtime.checkpoint_artifacts", lazyHandler(() => import("./runtime/checkpoint_artifacts.ts"), ({checkpointArtifacts}) => checkpointArtifacts)], ["state.recovery.archive.verify", lazyHandler(() => import("./state_recovery.ts"), ({verifyStateRecoveryArchive}) => verifyStateRecoveryArchive)], diff --git a/loopx/control_plane/goals/planned_transfer.ts b/loopx/control_plane/goals/planned_transfer.ts new file mode 100644 index 0000000000..ba0fea9c62 --- /dev/null +++ b/loopx/control_plane/goals/planned_transfer.ts @@ -0,0 +1,148 @@ +/** Goal-owned staging for a prepared transfer, deliberately before admission. + * + * Ordinary bootstrap owns fresh, empty creation. This owner retains the original + * Goal/Agent context and proposes a stopped destination registration over its + * original history. Historical policy, credentials, Host ids and memberships + * are review input; none is installed as a current destination grant here. + */ +import {isAbsolute, join, relative, resolve, sep} from "node:path"; +import {execFile} from "node:child_process"; +import {promisify} from "node:util"; +import {fileURLToPath} from "node:url"; +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys, + requireAuthorityStoreId} from "../coordination/authority_store_codec.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; +import {withFileMutationLock} from "../effect_runtime_io.ts"; +import {legacyCoordinationTodoLockPath} from "../coordination/legacy_writer_lock_paths.ts"; + +export const PLANNED_TRANSFER_GOAL_STAGE_SCHEMA = "loopx_planned_transfer_goal_stage_v0"; +const executeFile = promisify(execFile); + +function reject(message: string): never { throw new EffectRuntimeConflictError(message); } +function absolute(value: unknown, label: string): string { + const path = requireAuthorityStoreId(value, label); + if (!isAbsolute(path) || resolve(path) !== path) reject(`${label} must be an absolute normalized path`); + return path; +} +function overlaps(a: string, b: string): boolean { + const below = (root: string, path: string) => { + const suffix = relative(root, path); + return suffix === "" || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)); + }; + return below(a, b) || below(b, a); +} + +export interface PreparedGoalStage { + goalId: string; + operationId: string; + context: JsonObject; + goal: JsonObject; + statePath: string; + contextPath: string; + identity: JsonObject; +} + +/** Capture Goal intent through its actual registered source while the caller + * holds maintenance. Read the registered Goal through the existing Python + * registry codec using the source-selected interpreter, never another registry + * parser. Python owns codec/byte IO; Goal identity and profile decisions remain + * here. The lifecycle-only profile cannot become a generic runtime grant. */ +export async function withVerifiedGoalTransferContext(raw: unknown, rawWitness: unknown, + goalId: string, root: string, operation: (context: JsonObject) => Promise): Promise { + const context = requireJsonObject(raw, "original Goal context"); + if (!hasExactAuthorityKeys(context, ["goal", "state_text", "runtime_root", "registry_path"]) || + context.runtime_root !== root || typeof context.state_text !== "string" || !context.state_text.trim()) reject("Complete registered Goal context is required"); + const goal = requireJsonObject(context.goal, "original Goal"); + if (goal.id !== goalId || goal.goal_instance_id !== undefined) reject("Original Goal identity requires its owning lifecycle"); + const project = absolute(goal.repo, "source project"), registry = absolute(context.registry_path, "source registry"); + const state = resolve(project, requireAuthorityStoreId(goal.state_file, "registered Goal state")); + const witness = requireJsonObject(rawWitness, "codec-validated source witness"); + if (!hasExactAuthorityKeys(witness, ["path", "sha256"]) || witness.path !== registry || + typeof witness.sha256 !== "string" || !BARE_SHA256_PATTERN.test(witness.sha256) || state === registry) reject("Invalid registered Goal context witness"); + const verify = async () => { + const python = process.env.LOOPX_EFFECT_RUNTIME_PYTHON; + if (!python || !isAbsolute(python) || python.includes("\0")) reject("Registered Goal context requires the source-selected Python codec adapter"); + let observed: JsonObject; + try { + const result = await executeFile(python, ["-m", "loopx.control_plane.goals.planned_transfer_io", registry, goalId], { + env: {...process.env, PYTHONPATH: fileURLToPath(new URL("../../../", import.meta.url))}, + timeout: 30_000, maxBuffer: 16 * 1024 * 1024, + }); + observed = requireJsonObject(JSON.parse(result.stdout), "registered context codec readback"); + } catch { reject("Registered Goal context codec readback is unavailable; preserve the original operation and retry through its installed source"); } + if (observed.registry_profile !== null) reject("This source context profile requires the normal project registry; lifecycle-only adoption remains held"); + if (!canonicalAuthorityBytes(observed.registry_witness).equals(canonicalAuthorityBytes(witness))) reject("Registered Goal context changed; review its original transfer"); + if (!canonicalAuthorityBytes(observed.source_context).equals(canonicalAuthorityBytes(context))) reject("Registered Goal intent differs from its reviewed transfer context"); + }; + return withFileMutationLock(legacyCoordinationTodoLockPath(root, goalId), () => + withFileMutationLock(state, () => withFileMutationLock(registry, async () => { + await verify(); + const result = await operation(context); + await verify(); + return result; + }))); +} + +/** Codec-decoded registry facts are supplied by the transaction-owning adapter. + * This decision never serializes or mutates a project registry itself. */ +export function planPreparedGoalStage(value: JsonObject): PreparedGoalStage { + if (!hasExactAuthorityKeys(value, ["operation_id", "source_context", "archive_sha256", "target", "destination_registry"])) { + reject("Goal transfer staging requires the complete original context and destination observation"); + } + const operation = requireAuthorityStoreId(value.operation_id, "original transfer operation"); + if (typeof value.archive_sha256 !== "string" || !BARE_SHA256_PATTERN.test(value.archive_sha256)) reject("Invalid transfer archive digest"); + const context = requireJsonObject(value.source_context, "original Goal context"); + if (!hasExactAuthorityKeys(context, ["goal", "state_text", "runtime_root", "registry_path"]) || + typeof context.state_text !== "string" || !context.state_text.trim()) reject("Original complete Goal context is required"); + const original = requireJsonObject(context.goal, "original Goal"); + const goalId = requireAuthorityStoreId(original.id, "original Goal id"); + if (/[\\/]/.test(goalId) || goalId === "." || goalId === "..") reject("This local transfer profile requires a single-segment Goal id"); + if (original.goal_instance_id !== undefined) reject("This staged local profile does not adopt a Goal lifetime; use its owning lifecycle"); + const sourceProject = absolute(original.repo, "source project"); + const sourceRoot = absolute(context.runtime_root, "source runtime"); + absolute(context.registry_path, "source registry"); + const target = requireJsonObject(value.target, "transfer destination"); + if (!hasExactAuthorityKeys(target, ["runtime_root", "project", "registry_path", "provider"]) || + (target.provider !== "file" && target.provider !== "sqlite")) reject("Transfer destination requires the declared local File/SQLite profile"); + const root = absolute(target.runtime_root, "destination runtime"), project = absolute(target.project, "destination project"); + const registryPath = absolute(target.registry_path, "destination registry"); + if (registryPath !== join(project, ".loopx", "registry.json")) reject("This profile requires the destination project's canonical registry route"); + if ([root, project].some(path => [sourceRoot, sourceProject].some(source => overlaps(source, path)))) { + reject("Transfer staging must not overlap the original source project or runtime"); + } + const directory = join(project, ".loopx", "goals", goalId); + const statePath = join(directory, "ACTIVE_GOAL_STATE.md"), contextPath = join(directory, "TRANSFER_CONTEXT.json"); + const identity = {schema_version: PLANNED_TRANSFER_GOAL_STAGE_SCHEMA, goal_id: goalId, operation_id: operation, + source_context_sha256: canonicalAuthoritySha256(context), archive_sha256: value.archive_sha256, target}; + // Copy no adapter credentials, Host/session identity, machine capability + // configuration, creation receipt or live membership into this registration. + const goal: JsonObject = {id: goalId, status: "active", role: "controller", domain: "project-goal-control-plane", + display_name: typeof original.display_name === "string" ? original.display_name : goalId, + repo: project, state_file: relative(project, statePath), + activation: {schema_version: "loopx_goal_activation_v1", state: "stopped", reason: "Planned transfer requires fresh destination admission"}, + adapter: {kind: "generic_project_goal_v0", status: "connected"}, + spawn_policy: {mode: "default", allowed: false, max_children: 3, allowed_domains: []}, + coordination: {registered_agents: [], write_scope: [], requires_parent_approval: ["write", "publish", "production-action"]}, + authority_sources: [{kind: "migration_report", path: contextPath, role: "original_transfer_context"}], + planned_transfer: {schema_version: PLANNED_TRANSFER_GOAL_STAGE_SCHEMA, operation_id: operation, + identity_sha256: canonicalAuthoritySha256(identity), state: "staged", identity_continuity: "same_goal_read_only", + fresh_admission_required: true}}; + const registry = requireJsonObject(value.destination_registry, "destination registry"); + if (registry.profile_id !== undefined) reject("Destination registry profile requires its own lifetime admission"); + if (registry.common_runtime_root !== undefined && registry.common_runtime_root !== root) reject("Destination registry belongs to a different runtime"); + if (registry.goals !== undefined && !Array.isArray(registry.goals)) reject("Invalid destination Goal inventory"); + const matches = ((registry.goals ?? []) as unknown[]).map(item => requireJsonObject(item, "registered destination Goal")) + .filter(item => item.id === goalId); + if (matches.length > 1 || (matches.length === 1 && !canonicalAuthorityBytes(matches[0]).equals(canonicalAuthorityBytes(goal)))) { + reject("Destination Goal is occupied or its staged registration changed; preserve it and recover the original transfer"); + } + return {goalId, operationId: operation, context, goal, statePath, contextPath, identity}; +} + +export function stagedGoalRegistry(registry: JsonObject, plan: PreparedGoalStage, runtime: string): JsonObject { + const others = ((registry.goals ?? []) as JsonObject[]).filter(goal => goal.id !== plan.goalId); + return {...registry, schema_version: registry.schema_version ?? "0.1", common_runtime_root: runtime, goals: [...others, plan.goal]}; +} diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py new file mode 100644 index 0000000000..8ae95f4447 --- /dev/null +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -0,0 +1,95 @@ +"""Codec-owned destination registry IO for the typed prepared-transfer stage. + +This is the internal composition seam for a verified transfer input, not a +portable-artifact verifier or an activation API. It never invokes fresh Goal +bootstrap on populated history. The source context is retained as private review +input; the typed owner installs only a stopped, unbound destination read model. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ..effect_runtime import effect_runtime_result +from ..projects.registry_codec import load_project_registry, project_registry_transaction + + +def _read_goal_transfer_snapshot(*, registry_path: Path, goal_id: str) -> dict[str, Any]: + """Codec/byte IO only; the native Goal owner decides supported admission.""" + from ...registry import find_registry_goal, resolve_state_file + from ..coordination.authority_source_capture import authority_registry_source + + registry_path = registry_path.expanduser().resolve() + with authority_registry_source(registry_path) as witness: + registry = load_project_registry(registry_path) + goal = find_registry_goal(registry, goal_id) + if goal is None: + raise ValueError("Original Goal is not registered") + state = resolve_state_file(Path(goal["repo"]), goal["state_file"]) + if state is None: + raise ValueError("Original Goal has no state route") + return {"source_context": {"goal": goal, "state_text": state.read_bytes().decode("utf-8"), + "runtime_root": registry["common_runtime_root"], "registry_path": str(registry_path)}, + "registry_witness": witness, "registry_profile": registry.get("profile_id")} + + +def read_goal_transfer_context(*, registry_path: Path, goal_id: str) -> dict[str, Any]: + """Read codec-validated source facts; only the native owner can pin them.""" + snapshot = _read_goal_transfer_snapshot(registry_path=registry_path, goal_id=goal_id) + return {key: snapshot[key] for key in ("source_context", "registry_witness")} + + +def stage_prepared_goal_transfer( + *, + source_preparation: dict[str, Any], + source_context: dict[str, Any], + archive: Path, + project: Path, + runtime_root: Path, + provider: str, +) -> dict[str, Any]: + """Stage the original stopped Goal and read back its exact registration. + + Native staging takes maintenance before its source/registry locks. Registry + publication follows in a separate codec CAS, avoiding a registry/maintenance + lock inversion with canonical writers. Interruption leaves the destination + fenced; retry the same operation. No preimage is restored over new work. + """ + project, runtime_root = project.expanduser().absolute(), runtime_root.expanduser().absolute() + registry_path = project / ".loopx" / "registry.json" + # Mechanical filesystem validation precedes even the registry lock's IO. + # Do not follow a caller's linked destination into another project. + for path in (registry_path, runtime_root): + if path.resolve() != path: + raise ValueError("Planned transfer destination must use a physical normalized path") + target = {"runtime_root": str(runtime_root), "project": str(project), + "registry_path": str(registry_path), "provider": provider} + with project_registry_transaction(registry_path, operation="planned_transfer_stage_read", create=dict) as transaction: + registry = transaction.payload_copy() + witness = transaction.source_read_witness() if registry_path.exists() else {"path": str(registry_path), "sha256": None} + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.stage_target", { + "source_preparation": source_preparation, "source_context": source_context, + "archive": str(archive.expanduser().absolute()), "target": target, + "destination_registry": registry, "registry_witness": witness, "execute": True, + }, timeout=300.0, retry_safe=False) + # The typed Goal owner owns construction and conflict policy. The codec + # alone owns serialization, locking, compensation and independent readback. + proposed = result.pop("destination_registry") + with project_registry_transaction(registry_path, operation="planned_transfer_stage_publish", create=dict) as transaction: + if transaction.payload_copy() != registry: + raise ValueError("Destination registry changed before publication; retry the original transfer without reverting later work") + transaction.commit(proposed) + if load_project_registry(registry_path) != proposed: + raise ValueError("Planned transfer registry readback changed; recover the original operation") + return {**result, "registry_readback_verified": True} + + +if __name__ == "__main__": + # Fixed native codec reader, with no write action or interpreter override. + # The invoking native owner holds the maintenance/state/registry locks. + import json + import sys + + if len(sys.argv) != 3: + raise ValueError("Registered Goal context reader requires a registry and Goal id") + print(json.dumps(_read_goal_transfer_snapshot(registry_path=Path(sys.argv[1]), goal_id=sys.argv[2]), ensure_ascii=False)) diff --git a/tests/control_plane/test_planned_transfer_source_boundary.py b/tests/control_plane/test_planned_transfer_source_boundary.py index 59cae3e08d..b88c962d3d 100644 --- a/tests/control_plane/test_planned_transfer_source_boundary.py +++ b/tests/control_plane/test_planned_transfer_source_boundary.py @@ -1,8 +1,9 @@ """Migration must compose admission closure and drain, not reinterpret Goal stop. -These are real CLI/provider checks of the existing owners and the source -authority preparation under development. They do not implement destination -admission or qualify Host drain or a complete Agent transfer. +These are real CLI/provider checks of the source preparation and registered, +stopped destination staging under development. Original Task/history readback +must work without fresh creation or execution grants. Destination admission, +Host drain and the complete packaged Agent journey remain separate acceptance. """ from __future__ import annotations @@ -17,7 +18,10 @@ from loopx.control_plane.effect_runtime import effect_runtime_result, restart_effect_runtime from loopx.control_plane.coordination.legacy_writer_fence import legacy_coordination_writer_fence_path from loopx.control_plane.coordination.local_authority_shadow_projection import canonical_bytes +from loopx.control_plane.goals.planned_transfer_io import read_goal_transfer_context, stage_prepared_goal_transfer +from loopx.control_plane.projects.registry_codec import load_project_registry, project_registry_transaction from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime +from tests.control_plane.test_project_registry_codec import _strict @pytest.mark.parametrize("provider", ["file", "sqlite"]) @@ -71,6 +75,11 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: "--objective", "Produce report.txt from input.txt", "--no-global-sync") cli("configure-goal", "--goal-id", "report", "--registered-agent", "agent-a", "--write-scope", "report.txt", "--execute") + accepted = cli("todo", "add", "--goal-id", "report", "--role", "user", "--task-class", "user_action", + "--text", "Keep the input and original reporting constraints", "--operation-id", "accepted-context")["todo_id"] + cli("todo", "complete", "--goal-id", "report", "--role", "user", "--todo-id", accepted, + "--agent-id", "agent-a", "--note", "Accepted original context", "--evidence", "input.txt") + cli("todo", "archive-completed", "--goal-id", "report", "--role", "user", "--max-active-done", "0", "--execute") created = cli("todo", "add", "--goal-id", "report", "--role", "agent", "--claimed-by", "agent-a", "--text", "Write the input sum to report.txt", "--task-repository", "git:example.org/fixtures/agent-transfer", @@ -150,10 +159,15 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: # is inferred from this real production-entrypoint check. original_fence = json.loads(legacy_coordination_writer_fence_path( runtime_root=runtime, goal_id="report").read_text()) + if provider == "sqlite": + # The existing registry codec owns this ordinary strict v1 fixture. + # Goal transfer must preserve its semantics without adopting the + # separate lifecycle-only v2 source-session profile. + registry.write_text(json.dumps(_strict(load_project_registry(registry)))) request = {"runtime_root": str(runtime), "goal_id": "report", "operation_id": "prepared-move", "expected_provider_revision": released["provider_revision"], "expected_writer_fence_sha256": hashlib.sha256(canonical_bytes(original_fence)).hexdigest(), - "execute": True} + "execute": True, **read_goal_transfer_context(registry_path=registry, goal_id="report")} prepared = effect_runtime_result("coordination.planned_transfer.prepare_authority", request, timeout=45, retry_safe=False) assert prepared["authority_write_fenced"] is True @@ -170,6 +184,90 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: "--owner", "agent-a", "--idempotency-key", f"fresh-after-preparation-{cold}", "--write-scope", "report.txt", "--write-worktree", str(worktree), expected_code=1) assert "prepared for transfer" in acquired["error"] + + # Populated history enters the destination through its Goal staging + # owner, never a new-Goal bootstrap. Ordinary target reads must see the + # original pending Task, archived work and unchanged historical receipts. + original_goal = load_project_registry(registry)["goals"][0] + source_state = project / original_goal["state_file"] + context = request["source_context"] + for target_provider in ("file", "sqlite"): + destination = tmp_path / f"target-{target_provider}" + target_project, target_runtime = destination / "project", destination / "runtime" + target_registry = target_project / ".loopx/registry.json" + if target_provider == "sqlite": + target_registry.parent.mkdir(parents=True) + target_registry.write_text(json.dumps(_strict({"schema_version": "0.1", "common_runtime_root": str(target_runtime), "goals": []}))) + request = dict(source_preparation=prepared, source_context=context, + archive=Path(prepared["archive"]), project=target_project, runtime_root=target_runtime, provider=target_provider) + if target_provider == "file": + # Inject a real registry edit after native staging but before + # codec publication. The owning CAS must preserve it, and the + # same operation must recover without another source export. + from loopx.control_plane.goals import planned_transfer_io + native = planned_transfer_io.effect_runtime_result + + def concurrent_registry_edit(method, payload, **kwargs): + result = native(method, payload, **kwargs) + with project_registry_transaction(target_registry, operation="synthetic_concurrent_edit", create=dict) as tx: + changed = tx.payload_copy() + changed["retained_local_note"] = "An unrelated edit must survive transfer recovery" + tx.commit(changed) + return result + + with monkeypatch.context() as interrupted: + interrupted.setattr(planned_transfer_io, "effect_runtime_result", concurrent_registry_edit) + with pytest.raises(ValueError, match="registry changed before publication"): + stage_prepared_goal_transfer(**request) + concurrent = load_project_registry(target_registry) + assert concurrent["retained_local_note"] == "An unrelated edit must survive transfer recovery" + assert concurrent.get("goals", []) == [] + staged = stage_prepared_goal_transfer(**request) + assert staged["execution_authority_granted"] is False + assert staged["target_is_active"] is False and staged["fresh_admission_required"] is True + assert staged["authority_readback"]["status"] == "matched" + target_goal = load_project_registry(target_registry)["goals"][0] + if target_provider == "sqlite": + assert json.loads(target_registry.read_text())[0]["minimum_writer_protocol"] == "goal_instance_v1" + if target_provider == "file": + assert load_project_registry(target_registry)["retained_local_note"] == concurrent["retained_local_note"] + assert target_goal["activation"]["state"] == "stopped" + assert target_goal["coordination"]["registered_agents"] == [] + assert "creation_operation_id" not in target_goal + assert "storage_target" not in target_goal["coordination"] + assert json.loads(Path(staged["original_context_path"]).read_text()) == context + assert (target_project / target_goal["state_file"]).read_bytes() == source_state.read_bytes() + + def target_cli(*arguments: str, expected_code: int = 0) -> dict: + result = subprocess.run([sys.executable, "-I", "-m", "loopx.cli", "--format", "json", + "--registry", str(target_registry), "--runtime-root", str(target_runtime), *arguments], + cwd=target_project, capture_output=True, text=True, timeout=60) + assert result.returncode == expected_code, result.stdout + result.stderr + return json.loads(result.stdout) + + target_task = target_cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"] + assert target_task == cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"] + # The existing ordinary reconnect may read this registered Goal; + # it must not mint a creation receipt or reset imported work to an + # empty new Goal, even if a caller supplies creation-style inputs. + before_reconnect = load_project_registry(target_registry)["goals"][0] + target_cli("bootstrap", "--project", str(target_project), "--goal-id", "report", + "--objective", "Reconnect the existing transferred Goal", "--no-global-sync") + assert load_project_registry(target_registry)["goals"][0] == before_reconnect + assert target_cli("task-lease", "inspect", "--goal-id", "report", "--todo-id", todo)["provider_revision"] == staged["authority_readback"]["captured_target_provider_revision"] + assert target_cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"] == target_task + archived = target_cli("todo", "list", "--goal-id", "report", "--role", "user", "--todo-id", accepted)["todo"] + assert archived["archive_state"] == "archive" and archived["status"] == "done" + assert archived["note"] == "Accepted original context" + assert target_cli("task-lease", "inspect", "--goal-id", "report", "--todo-id", todo)["lease"] == released["lease"] + assert target_cli("task-lease", "acquire", "--goal-id", "report", "--todo-id", todo, + "--owner", "agent-a", "--idempotency-key", "must-not-admit-staged-history", + "--write-scope", "report.txt", "--write-worktree", str(worktree), expected_code=1)["ok"] is False + restart_effect_runtime(wait_for_exit=True) + original_registry = target_registry.read_bytes() + assert stage_prepared_goal_transfer(**request) == staged + assert target_registry.read_bytes() == original_registry + assert target_cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"] == target_task assert inspect()["provider_revision"] == released["provider_revision"] assert cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"]["note"] == retained["note"] finally: diff --git a/tests/control_plane_ts/planned_transfer_authority.test.ts b/tests/control_plane_ts/planned_transfer_authority.test.ts index 61f1da3a90..8bd4afaee6 100644 --- a/tests/control_plane_ts/planned_transfer_authority.test.ts +++ b/tests/control_plane_ts/planned_transfer_authority.test.ts @@ -1,11 +1,14 @@ import assert from "node:assert/strict"; -import {mkdtemp, mkdir, readFile, readdir, rm, writeFile} from "node:fs/promises"; +import {mkdtemp, mkdir, readFile, readdir, realpath, rename, rm, writeFile} from "node:fs/promises"; +import {createHash} from "node:crypto"; import {join} from "node:path"; import {tmpdir} from "node:os"; import test, {type TestContext} from "node:test"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {prepareAuthorityForPlannedTransfer as prepare} from "../../loopx/control_plane/coordination/planned_transfer_authority.ts"; -import {selectLocalAuthorityTarget, openLocalAuthorityStoreHandle} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; +import {stageAuthorityForPlannedTransfer as stage} from "../../loopx/control_plane/coordination/planned_transfer_target.ts"; +import {localAuthorityProviderPaths, selectLocalAuthorityTarget, openLocalAuthorityStoreHandle} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; +import {withFileMutationLock} from "../../loopx/control_plane/effect_runtime_io.ts"; import {COLD_SOURCE_IMPORT_WRITER_FENCE_SCHEMA, legacyCoordinationWriterFencePath, loadLegacyCoordinationWriterFence, checkLegacyCoordinationWriteAllowed, LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; @@ -17,9 +20,14 @@ import {manageLocalAuthorityArchive} from "../../loopx/control_plane/coordinatio import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; import {todo} from "./shadow_file_fixture.ts"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; + +// Mirror the ordinary Python entrypoint's fixed daemon context. Requests cannot +// choose an interpreter; the real codec is used in every context-binding test. +process.env.LOOPX_EFFECT_RUNTIME_PYTHON = resolveTestPython(); async function fixture(t: TestContext, provider: "file" | "sqlite", leaseStatus = "released") { - const root = await mkdtemp(join(tmpdir(), "loopx-prepared-transfer-")); + const root = await realpath(await mkdtemp(join(tmpdir(), "loopx-prepared-transfer-"))); t.after(() => rm(root, {recursive: true, force: true})); await selectLocalAuthorityTarget(root, "goal-a", provider, true); const opened = await openLocalAuthorityStoreHandle(root, "goal-a"); @@ -52,6 +60,185 @@ async function fixture(t: TestContext, provider: "file" | "sqlite", leaseStatus return {root, store: opened.store, projection, request, directory, fencePath, fence}; } +async function destination(t: TestContext, f: Awaited>, provider: "file" | "sqlite") { + const directory = await realpath(await mkdtemp(join(tmpdir(), "loopx-transfer-destination-"))); + t.after(() => rm(directory, {recursive: true, force: true})); + const project = join(directory, "project"), root = join(directory, "runtime"), registry = join(project, ".loopx", "registry.json"); + const context = {goal: {id: "goal-a", repo: join(f.root, "project"), state_file: ".loopx/goals/goal-a/ACTIVE_GOAL_STATE.md", + creation_operation_id: "original-creation-only", display_name: "Continue the original report", + coordination: {registered_agents: ["agent-a"], agent_profiles: {"agent-a": {role: "report author", credential_ref: "source-only"}}}, + capabilities: {example: {credential_ref: "source-only"}}, + authority_sources: [{kind: "goal_doc", path: "requirements.md", role: "primary_goal_document"}]}, + state_text: "# Original Goal\n\nKeep every reporting constraint and the original Agent decision context.\n", + runtime_root: f.root, registry_path: join(f.root, "project", ".loopx", "registry.json")}; + const sourceRegistry = context.registry_path; + const sourceState = join(String(context.goal.repo), context.goal.state_file); + await mkdir(join(String(context.goal.repo), ".loopx/goals/goal-a"), {recursive: true}); + await writeFile(sourceState, context.state_text); + const sourceBytes = JSON.stringify({schema_version: "0.1", common_runtime_root: f.root, goals: [context.goal]}); + await writeFile(sourceRegistry, sourceBytes); + const prepared = await prepare({...f.request, source_context: context, + registry_witness: {path: sourceRegistry, sha256: createHash("sha256").update(sourceBytes).digest("hex")}}); + const target = {runtime_root: root, project, registry_path: registry, provider}; + return {root, project, registry, context, prepared, + request: {source_preparation: prepared, source_context: context, archive: prepared.archive, target, + destination_registry: {}, registry_witness: {path: registry, sha256: null}, execute: true} as JsonObject}; +} + +for (const sourceProvider of ["file", "sqlite"] as const) { + test(`${sourceProvider}: staged destinations retain the original Goal/Task history without copying a fresh grant`, async t => { + const f = await fixture(t, sourceProvider), original = await f.store.loadAuthority(); + for (const targetProvider of ["file", "sqlite"] as const) { + const d = await destination(t, f, targetProvider); + const result = await stage(d.request), registry = result.destination_registry as JsonObject; + const goal = (registry.goals as JsonObject[])[0]; + assert.equal(result.target_is_active, false); assert.equal(result.execution_authority_granted, false); + assert.equal(result.fresh_admission_required, true); + assert.equal(goal.id, "goal-a"); assert.equal((goal.activation as JsonObject).state, "stopped"); + assert.deepEqual((goal.coordination as JsonObject).registered_agents, []); + assert.equal(goal.creation_operation_id, undefined); assert.equal(goal.capabilities, undefined); + assert.equal((goal.coordination as JsonObject).agent_profiles, undefined); + assert.equal((goal.coordination as JsonObject).storage_target, undefined); + assert.deepEqual(JSON.parse(await readFile(String(result.original_context_path), "utf8")), d.context); + assert.equal(await readFile(join(d.project, String(goal.state_file)), "utf8"), d.context.state_text); + const target = await openLocalAuthorityStoreHandle(d.root, "goal-a"), head = await target.store.loadAuthority(); + assert.equal(head.status, "loaded"); if (head.status !== "loaded") throw new Error("missing original Tasks"); + assert.deepEqual(head.head, f.projection); + assert.notEqual(head.provider_revision, f.request.expected_provider_revision); + for (let n = 1; n <= 3; n++) { + const receipt = await target.store.readReceipt(`original-${n}`); + assert.equal(receipt.status, "found"); + if (receipt.status === "found") assert.deepEqual(receipt.receipts, [{operation_id: `original-${n}`, independent_fact: n}]); + } + const fence = await loadLegacyCoordinationWriterFence(d.root, "goal-a"); + assert.equal(fence.status, "loaded"); if (fence.status === "loaded") assert.equal(fence.fence.role, "target"); + let calls = 0; + for (const businessWrite of ["todo_update", "task_lease_acquire", "task_lease_renew", "authority_maintenance"] as const) { + await assert.rejects(withCanonicalWriter(d.root, "goal-a", false, async () => ++calls, {businessWrite}), /prepared for transfer/); + } + assert.equal(calls, 0); + // Model the codec adapter's exact publication, then retry with its fresh + // byte witness. Unrelated registry revisions are not an operation identity. + const registryBytes = JSON.stringify(registry); + await writeFile(d.registry, registryBytes); + const retry = {...d.request, destination_registry: registry, + registry_witness: {path: d.registry, sha256: createHash("sha256").update(registryBytes).digest("hex")}}; + assert.deepEqual(await stage(retry), result); + assert.deepEqual(await target.store.loadAuthority(), head); + await assert.rejects(stage({...retry, source_context: {...d.context, state_text: "different requirements"}}), /context differ|occupied|original facts/); + await assert.rejects(stage({...retry, registry_witness: {path: d.registry, sha256: "0".repeat(64)}}), /registry changed/); + } + assert.deepEqual(await f.store.loadAuthority(), original, "destination staging cannot mutate original history"); + }); + + test(`${sourceProvider}: invalid or occupied destinations never receive imported history`, async t => { + const f = await fixture(t, sourceProvider), d = await destination(t, f, "sqlite"); + for (const change of [{execute: false}, {unexpected: true}]) await assert.rejects(stage({...d.request, ...change}), /Explicit complete/); + await assert.rejects(stage({...d.request, source_context: {...d.context, goal: {...d.context.goal, goal_instance_id: "not-an-admission"}}}), /owning lifecycle/); + await assert.rejects(stage({...d.request, target: {...d.request.target as JsonObject, runtime_root: f.root}}), /overlap/); + const damaged = join(d.project, "damaged.ndjson"); + await mkdir(d.project, {recursive: true}); await writeFile(damaged, "damaged original authority"); + await assert.rejects(stage({...d.request, archive: damaged})); + await assert.rejects(readFile(legacyCoordinationWriterFencePath(d.root, "goal-a")), {code: "ENOENT"}); + await selectLocalAuthorityTarget(d.root, "goal-a", "sqlite", true); + const occupied = await openLocalAuthorityStoreHandle(d.root, "goal-a"); + const committed = await occupied.store.commitAuthority({expected_provider_revision: null, operation_id: "destination-work", + events: [], receipts: [], next_projection: f.projection}); + assert.equal(committed.status, "applied"); const before = await occupied.store.loadAuthority(); + await assert.rejects(stage(d.request), /already contains authority/); + assert.deepEqual(await occupied.store.loadAuthority(), before); + await assert.rejects(readFile(join(d.project, ".loopx/goals/goal-a/ACTIVE_GOAL_STATE.md")), {code: "ENOENT"}); + }); + + test(`${sourceProvider}: source Goal context must match its real registry and state before fencing`, async t => { + const f = await fixture(t, sourceProvider), project = join(f.root, "source-project"); + await mkdir(project); + const goal = {id: "goal-a", repo: project, state_file: "GOAL.md", role: "controller"}; + const state = "# Original Goal\n\nPreserve the original accepted constraints.\n"; + const registry = join(project, "registry.json"); + const context = {goal, state_text: state, runtime_root: f.root, registry_path: registry}; + const bytes = JSON.stringify({common_runtime_root: f.root, goals: [goal]}); + await writeFile(registry, bytes); await writeFile(join(project, "GOAL.md"), state); + const request = {...f.request, source_context: context, + registry_witness: {path: registry, sha256: createHash("sha256").update(bytes).digest("hex")}}; + for (const change of [ + {source_context: {...context, state_text: "Different requirements"}}, + {source_context: {...context, goal: {...goal, role: "different original role"}}}, + {registry_witness: {path: registry, sha256: "0".repeat(64)}}, + ]) { + await assert.rejects(prepare({...request, ...change}), /context changed|intent differs/); + assert.deepEqual(JSON.parse(await readFile(f.fencePath, "utf8")), f.fence); + await assert.rejects(readFile(join(f.directory, "preparation.json")), {code: "ENOENT"}); + } + const interpreter = process.env.LOOPX_EFFECT_RUNTIME_PYTHON; + delete process.env.LOOPX_EFFECT_RUNTIME_PYTHON; + try { await assert.rejects(prepare(request), /source-selected Python codec adapter/); } + finally { process.env.LOOPX_EFFECT_RUNTIME_PYTHON = interpreter; } + assert.deepEqual(JSON.parse(await readFile(f.fencePath, "utf8")), f.fence); + const lifetimeWire = JSON.stringify([{schema_version: "loopx_project_registry_envelope_v2", minimum_writer_protocol: "goal_instance_v2"}, + {profile_id: "source_session_v1", common_runtime_root: f.root, goals: [goal]}]); + await writeFile(registry, lifetimeWire); + await assert.rejects(prepare({...request, registry_witness: { + path: registry, sha256: createHash("sha256").update(lifetimeWire).digest("hex")}}), /codec readback is unavailable|lifecycle-only adoption remains held/); + assert.deepEqual(JSON.parse(await readFile(f.fencePath, "utf8")), f.fence); + await writeFile(registry, bytes); + const prepared = await prepare(request); + assert.equal(prepared.source_context_sha256, canonicalAuthoritySha256(context)); + assert.deepEqual(await prepare(request), prepared); + assert.deepEqual(JSON.parse(await readFile(join(f.directory, "preparation.json"), "utf8")).source_context, context); + }); + + test(`${sourceProvider}: completed staging never restores over missing or later destination work`, async t => { + const f = await fixture(t, sourceProvider); + for (const provider of ["file", "sqlite"] as const) { + const d = await destination(t, f, provider); + await stage(d.request); + const opened = await openLocalAuthorityStoreHandle(d.root, "goal-a"); + const head = await opened.store.loadAuthority(); + if (head.status !== "loaded") throw new Error("fixture missing"); + // A synthetic provider-level append simulates a later accepted phase. + // The stage cannot rewind it or rewrite its original receipt. + assert.equal((await opened.store.commitAuthority({expected_provider_revision: head.provider_revision, + operation_id: "later-original-work", events: [], receipts: [{result: "retain later result"}], + next_projection: head.head})).status, "applied"); + const later = await opened.store.loadAuthority(); + await assert.rejects(stage(d.request), /Completed destination authority changed/); + assert.deepEqual(await opened.store.loadAuthority(), later); + const path = (opened.store as FileAuthorityStore | SqliteAuthorityStore).path; + await rename(path, `${path}.retained-for-recovery`); + await assert.rejects(stage(d.request), /lineage is missing|Completed destination authority changed/); + await assert.rejects(readFile(path), {code: "ENOENT"}); + } + }); +} + +test("target staging recovers an interrupted real File archive write under the original closed fence", async t => { + const f = await fixture(t, "sqlite"), d = await destination(t, f, "file"); + const paths = localAuthorityProviderPaths(d.root, "goal-a"); + const target = new FileAuthorityStore(paths.file, "goal-a"); + await withFileMutationLock(target.path, async () => { + await assert.rejects(stage(d.request), /archive_target_missing|archive_target_unavailable|archive_history/); + }); + const held = await loadLegacyCoordinationWriterFence(d.root, "goal-a"); + assert.equal(held.status, "loaded"); if (held.status === "loaded") assert.equal(held.fence.role, "target"); + assert.equal((await target.loadAuthority()).status, "missing"); + const result = await stage(d.request); + assert.equal(result.execution_authority_granted, false); + assert.equal((await target.loadAuthority()).status, "loaded"); + assert.deepEqual(await stage(d.request), result); +}); + +test("staging a File Goal preserves unrelated Goals sharing that provider directory", async t => { + const f = await fixture(t, "sqlite"), d = await destination(t, f, "file"); + const paths = localAuthorityProviderPaths(d.root, "other-goal"); + const other = new FileAuthorityStore(paths.file, "other-goal"); + assert.equal((await other.commitAuthority({expected_provider_revision: null, operation_id: "unrelated-original", + events: [], receipts: [{result: "keep"}], next_projection: {...f.projection, goal_id: "other-goal"}})).status, "applied"); + const before = await other.loadAuthority(); + await stage(d.request); + assert.deepEqual(await other.loadAuthority(), before); +}); + for (const provider of ["file", "sqlite"] as const) { test(`${provider}: preparation fences native/legacy writes and retains original history through the existing restore owner`, async t => { const f = await fixture(t, provider); From 26f75b5f0e6fe9abc73f04ba7cb4bcdd83e21bb1 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:21:47 +0800 Subject: [PATCH 03/55] feat(coordination): bind planned transfer to the original registered source Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../coordination/new_goal_initialization.ts | 29 ++++--- .../planned_transfer_authority.ts | 81 ++++++++++++++++++- .../control_plane/effect_runtime_handlers.ts | 1 + loopx/control_plane/goals/planned_transfer.ts | 1 + .../goals/planned_transfer_io.py | 45 ++++++++++- .../test_planned_transfer_source_boundary.py | 72 +++++++++++++++-- 6 files changed, 210 insertions(+), 19 deletions(-) diff --git a/loopx/control_plane/coordination/new_goal_initialization.ts b/loopx/control_plane/coordination/new_goal_initialization.ts index a40abc2b83..ad044847b3 100644 --- a/loopx/control_plane/coordination/new_goal_initialization.ts +++ b/loopx/control_plane/coordination/new_goal_initialization.ts @@ -17,26 +17,35 @@ import {commitPromotionAndReadBack, readPromotionReceipt} from "./promotion_rece import {requireJsonObject} from "../runtime_decode.ts"; import {parseExactGoalRef} from "../goals/goal_instance_identity.ts"; -export async function initializeNewGoalAuthority(raw: JsonObject): Promise { - const root = requireLocalAuthorityRuntimeRoot(raw.runtime_root); - const goalId = requireAuthorityStoreId(raw.goal_id, "goal id"); - const operationId = requireAuthorityStoreId(raw.creation_operation_id, "creation operation id"); - const target = requireJsonObject(raw.target, "creation target"); - const snapshot = requireJsonObject(raw.source_snapshot, "creation source snapshot"); +/** The original registration binding is also used when moving populated + * history. Reusing it proves provenance; it does not rerun empty creation. */ +export function newGoalCreationBinding(goalId: string, operationId: string, target: JsonObject, + statePath: unknown, registryPath: unknown) { const projection = projectCoordinationSource({schema_version: SOURCE_PROJECTION_REQUEST_SCHEMA, kind: "snapshot", goal_id: goalId, handoff_mode: target.handoff_mode, todos: [], leases: [], read_model_schema: TODO_DOMAIN_READ_RECORD_SCHEMA}).projection as JsonObject; - const capturedSource = raw.projection !== undefined; - const request: ShadowRequest = {runtime_root: root, goal_id: goalId, - projection: capturedSource ? requireJsonObject(raw.projection, "creation source") : projection, source_snapshot: snapshot}; const identitySha = canonicalAuthoritySha256({goal_id: goalId, creation_operation_id: operationId, - target, state_path: snapshot.state_path, registry_path: (snapshot.registry_source as JsonObject)?.path}); + target, state_path: statePath, registry_path: registryPath}); const identity = {operation_id: operationId, projection_sha256: canonicalAuthoritySha256(projection), receipt: {schema_version: "loopx_new_goal_authority_creation_receipt_v0", operation_id: operationId, goal_id: goalId, creation_identity_sha256: identitySha}}; const fence = {schema_version: NEW_GOAL_WRITER_FENCE_SCHEMA, state: "engaged", goal_id: goalId, fence_id: `new-goal:${operationId}`, creation_operation_id: operationId, creation_identity_sha256: identitySha, creation_completed: false}; + return {projection, identity, fence}; +} + +export async function initializeNewGoalAuthority(raw: JsonObject): Promise { + const root = requireLocalAuthorityRuntimeRoot(raw.runtime_root); + const goalId = requireAuthorityStoreId(raw.goal_id, "goal id"); + const operationId = requireAuthorityStoreId(raw.creation_operation_id, "creation operation id"); + const target = requireJsonObject(raw.target, "creation target"); + const snapshot = requireJsonObject(raw.source_snapshot, "creation source snapshot"); + const {projection, identity, fence} = newGoalCreationBinding(goalId, operationId, target, + snapshot.state_path, (snapshot.registry_source as JsonObject)?.path); + const capturedSource = raw.projection !== undefined; + const request: ShadowRequest = {runtime_root: root, goal_id: goalId, + projection: capturedSource ? requireJsonObject(raw.projection, "creation source") : projection, source_snapshot: snapshot}; const validateRegistration = async () => { // The trusted source adapter validated the existing registry codec. Bind // that decoded observation to its complete bytes before inspecting fields; diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index a530a06cf8..abe82ecbc9 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -6,7 +6,7 @@ * exposing a cutover. No public activation entry is registered here. */ import {mkdir, readFile} from "node:fs/promises"; -import {join} from "node:path"; +import {join, resolve} from "node:path"; import type {JsonObject} from "../effect_program.ts"; import {durableWriteJson} from "../effect_runtime_io.ts"; import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; @@ -21,8 +21,13 @@ import {loadLegacyCoordinationWriterFence, preparePlannedTransferWriterFenceUnde PLANNED_TRANSFER_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; import {readShadowManagementState, shadowManagementDirectory, withShadowMaintenanceLock} from "./shadow_management.ts"; import {withVerifiedGoalTransferContext} from "../goals/planned_transfer.ts"; +import type {AuthorityStore} from "./authority_store.ts"; +import {newGoalCreationBinding} from "./new_goal_initialization.ts"; +import {readPromotionReceipt} from "./promotion_receipt.ts"; +import {NEW_GOAL_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; export const PREPARATION_SCHEMA = "loopx_planned_transfer_authority_preparation_v0"; +const REGISTERED_SOURCE_PREVIEW_SCHEMA = "loopx_planned_transfer_source_preview_v0"; function reject(message: string): never { throw new EffectRuntimeConflictError(message); } function same(a: unknown, b: unknown): boolean { return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b)); } @@ -40,6 +45,70 @@ async function retain(path: string, value: JsonObject): Promise { if (!same(await optionalJson(path), value)) reject("Transfer preparation readback failed"); } +/** First product source profile: an ordinary registered local Goal whose + * original creation is independently readable after subsequent native writes. + * A retained creation receipt is provenance here, never a target creation grant. + * Imported/legacy origins need their own original-registration qualification; + * the lower-level archive/preparation owners remain available without claiming it. + */ +async function registeredSourcePreview(context: JsonObject, originalFence: JsonObject, + store: AuthorityStore, provider: string, head: {head: JsonObject; cursor: string; provider_revision: string}, + storeIdentity: string, goalId: string, operationId: string): Promise { + const goal = requireJsonObject(context.goal, "original registered Goal"); + if (goal.status !== "active") reject("Transfer requires the current registered Goal; recover its original lifecycle before preparing a move"); + const coordination = requireJsonObject(goal.coordination, "original Goal coordination"); + const target = requireJsonObject(coordination.storage_target, "original Goal storage target"); + if (originalFence.schema_version !== NEW_GOAL_WRITER_FENCE_SCHEMA || originalFence.creation_completed !== true || + typeof goal.creation_operation_id !== "string" || target.schema_version !== "loopx_new_goal_storage_target_v1") { + reject("This transfer source requires its qualified original registration; imported origins need their owning provenance check"); + } + const binding = newGoalCreationBinding(goalId, goal.creation_operation_id, target, + resolve(String(goal.repo), String(goal.state_file)), context.registry_path); + if (!same(originalFence, {...binding.fence, creation_completed: true}) || + !(await readPromotionReceipt(store, binding.identity)).matched) { + reject("Transfer source differs from its original registered creation and retained receipt; repair that source without recreating it"); + } + const index = indexCoordinationProjection(head.head, goalId); + const retainedLeases = [...index.leases].map(([todo, lease]) => canonicalTaskLease(lease, goalId, todo)) + .filter(lease => lease.status !== "released"); + const facts = {schema_version: REGISTERED_SOURCE_PREVIEW_SCHEMA, goal_id: goalId, operation_id: operationId, + source_context_sha256: canonicalAuthoritySha256(context), original_registration_verified: true, + original_writer_fence_sha256: canonicalAuthoritySha256(originalFence), provider, + provider_revision: head.provider_revision, cursor: head.cursor, source_store_identity: storeIdentity, + retained_leases: retainedLeases, ready_for_authority_preparation: retainedLeases.length === 0, + host_drain_qualified: false, portable_artifact_qualified: false, execution_authority_granted: false}; + return {...facts, preview_sha256: canonicalAuthoritySha256(facts)}; +} + +export async function previewRegisteredAuthorityForPlannedTransfer(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id", "source_context", "registry_witness"])) { + reject("Registered source preview requires its complete original Goal context"); + } + const root = requireLocalAuthorityRuntimeRoot(value.runtime_root); + const goalId = requireAuthorityStoreId(value.goal_id, "source Goal id"); + const operationId = requireAuthorityStoreId(value.operation_id, "transfer operation id"); + return withShadowMaintenanceLock(root, goalId, () => withVerifiedGoalTransferContext( + value.source_context, value.registry_witness, goalId, root, async context => { + const managed = await readShadowManagementState(root, goalId); + if (managed && managed.status !== "inactive") reject("Source capture requires disposition before transfer preview"); + const current = await loadLegacyCoordinationWriterFence(root, goalId); + if (current.status !== "loaded") reject("Original source writer fence is unavailable"); + let original = current.fence; + if (original.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) { + const pending = await optionalJson(join(shadowManagementDirectory(root, goalId), "planned-transfers", + canonicalAuthoritySha256(operationId), "preparation.json")); + if (!pending || original.role !== "source" || original.operation_id !== operationId || + original.source_identity_sha256 !== canonicalAuthoritySha256(pending)) reject("Source belongs to another prepared transfer; recover its original operation"); + original = requireJsonObject(pending.original_writer_fence, "original source fence"); + } + const opened = await openLocalAuthorityStoreHandle(root, goalId, {}, {existingOnly: true}); + if (opened.provider !== "file" && opened.provider !== "sqlite") reject("Transfer preview requires a qualified local File/SQLite source"); + const head = await opened.store.loadAuthority(), identity = await opened.store.storeIdentity(); + if (head.status !== "loaded" || identity.status !== "available") reject("Original source history is unavailable; restore it before transfer"); + return registeredSourcePreview(context, original, opened.store, opened.provider, head, identity.store_identity, goalId, operationId); + })); +} + /** The maintenance guard serializes preparation against every ordinary native * writer. Expiration does not settle a lease: every retained token must have * been released through its original owner before this operation can fence it. @@ -49,8 +118,10 @@ async function retain(path: string, value: JsonObject): Promise { */ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Promise { const contextBound = value.source_context !== undefined; + const registered = value.registered_source_preview_sha256 !== undefined; if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id", "expected_provider_revision", - "expected_writer_fence_sha256", "execute", ...(contextBound ? ["source_context", "registry_witness"] : [])]) || value.execute !== true || + "expected_writer_fence_sha256", "execute", ...(contextBound ? ["source_context", "registry_witness"] : []), + ...(registered ? ["registered_source_preview_sha256"] : [])]) || value.execute !== true || (registered && !contextBound) || typeof value.expected_writer_fence_sha256 !== "string" || !BARE_SHA256_PATTERN.test(value.expected_writer_fence_sha256)) { reject("Transfer authority preparation requires an explicit execution and the complete reviewed source identity"); } @@ -98,6 +169,11 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro } const preparation = {...facts, original_writer_fence: priorFence}; if (canonicalAuthoritySha256(priorFence) !== value.expected_writer_fence_sha256) reject("Source writer fence changed; review a fresh transfer preview"); + if (registered) { + const preview = await registeredSourcePreview(context!, priorFence, opened.store, opened.provider, + head, identity.store_identity, goal, operation); + if (value.registered_source_preview_sha256 !== preview.preview_sha256) reject("Reviewed transfer source preview changed; review the current source without executing it"); + } await mkdir(directory, {recursive: true, mode: 0o700}); await retain(pendingPath, preparation); await preparePlannedTransferWriterFenceUnderLock(root, goal, priorFence, { @@ -120,6 +196,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro const result: JsonObject = {schema_version: PREPARATION_SCHEMA, operation_id: operation, goal_id: goal, authority_write_fenced: true, authority_archive: {...captured}, archive, ...(context ? {source_context_sha256: canonicalAuthoritySha256(context)} : {}), + ...(registered ? {original_registration_verified: true} : {}), host_drain_qualified: false, portable_artifact_qualified: false, execution_authority_granted: false}; await retain(join(directory, "receipt.json"), result); return result; diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 526c5bd133..3bac7bbe71 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -416,6 +416,7 @@ export function createEffectRuntimeHandlers( ["coordination.runtime_shadow.rollback", lazyHandler(() => Promise.all([import("./coordination/runtime_shadow.ts"), import("./coordination/source_transfer.ts")]), ([{rollbackCoordinationRuntimeShadow}, {withCoordinationSourceTransfer}]) => withCoordinationSourceTransfer("coordination.runtime_shadow.rollback", rollbackCoordinationRuntimeShadow))], ["coordination.local_authority.promote", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({promoteLocalCoordinationAuthority}) => promoteLocalCoordinationAuthority)], ["coordination.authority_archive.manage", lazyHandler(() => import("./coordination/local_authority_archive.ts"), ({manageLocalAuthorityArchive}) => manageLocalAuthorityArchive)], + ["coordination.planned_transfer.preview_source", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({previewRegisteredAuthorityForPlannedTransfer}) => previewRegisteredAuthorityForPlannedTransfer)], ["coordination.planned_transfer.prepare_authority", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({prepareAuthorityForPlannedTransfer}) => prepareAuthorityForPlannedTransfer)], ["coordination.planned_transfer.stage_target", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({stageAuthorityForPlannedTransfer}) => stageAuthorityForPlannedTransfer)], ["configuration.backup", lazyHandler(() => import("./configuration_backup.ts"), ({configurationBackupOperation}) => configurationBackupOperation)], diff --git a/loopx/control_plane/goals/planned_transfer.ts b/loopx/control_plane/goals/planned_transfer.ts index ba0fea9c62..962a930065 100644 --- a/loopx/control_plane/goals/planned_transfer.ts +++ b/loopx/control_plane/goals/planned_transfer.ts @@ -74,6 +74,7 @@ export async function withVerifiedGoalTransferContext(raw: unknown, rawWitnes observed = requireJsonObject(JSON.parse(result.stdout), "registered context codec readback"); } catch { reject("Registered Goal context codec readback is unavailable; preserve the original operation and retry through its installed source"); } if (observed.registry_profile !== null) reject("This source context profile requires the normal project registry; lifecycle-only adoption remains held"); + if (observed.registry_role === "global-local") reject("A global registry is a read model; resolve the original project registration before transfer"); if (!canonicalAuthorityBytes(observed.registry_witness).equals(canonicalAuthorityBytes(witness))) reject("Registered Goal context changed; review its original transfer"); if (!canonicalAuthorityBytes(observed.source_context).equals(canonicalAuthorityBytes(context))) reject("Registered Goal intent differs from its reviewed transfer context"); }; diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index 8ae95f4447..106be1c287 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -30,15 +30,56 @@ def _read_goal_transfer_snapshot(*, registry_path: Path, goal_id: str) -> dict[s raise ValueError("Original Goal has no state route") return {"source_context": {"goal": goal, "state_text": state.read_bytes().decode("utf-8"), "runtime_root": registry["common_runtime_root"], "registry_path": str(registry_path)}, - "registry_witness": witness, "registry_profile": registry.get("profile_id")} + "registry_witness": witness, "registry_profile": registry.get("profile_id"), + "registry_role": registry.get("registry_role")} def read_goal_transfer_context(*, registry_path: Path, goal_id: str) -> dict[str, Any]: """Read codec-validated source facts; only the native owner can pin them.""" - snapshot = _read_goal_transfer_snapshot(registry_path=registry_path, goal_id=goal_id) + from ..runtime.runtime_projection_route import resolve_goal_source_runtime_route + + route = resolve_goal_source_runtime_route(registry_path=registry_path, goal_id=goal_id) + snapshot = _read_goal_transfer_snapshot(registry_path=Path(route["source_registry"]), goal_id=goal_id) return {key: snapshot[key] for key in ("source_context", "registry_witness")} +def preview_registered_goal_transfer(*, registry_path: Path, goal_id: str, operation_id: str) -> dict[str, Any]: + """Resolve the normal App/CLI source and let the native owner review it. + + Do not use a runtime override to turn a global projection into source + authority. The exact original registration and creation receipt, current + provider revision, context and retained leases are checked by TypeScript. + """ + context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.preview_source", { + "runtime_root": context["source_context"]["runtime_root"], "goal_id": goal_id, + "operation_id": operation_id, **context, + }, timeout=45.0, retry_safe=True) + return result + + +def prepare_registered_goal_transfer( + *, registry_path: Path, goal_id: str, operation_id: str, expected_preview_sha256: str, +) -> dict[str, Any]: + """Compose one reviewed source authority preparation, without user plumbing. + + This is still pre-cutover: Host/effect/material custody and destination + admission are not established by a source authority fence. The complete + product coordinator must keep those qualifications separate. + """ + context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) + preview = preview_registered_goal_transfer(registry_path=registry_path, goal_id=goal_id, operation_id=operation_id) + # The native execution compares this reviewed digest again under its actual + # maintenance/source locks; a preview read here cannot waive stale inputs. + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.prepare_authority", { + "runtime_root": context["source_context"]["runtime_root"], "goal_id": goal_id, + "operation_id": operation_id, "expected_provider_revision": preview["provider_revision"], + "expected_writer_fence_sha256": preview["original_writer_fence_sha256"], + "registered_source_preview_sha256": expected_preview_sha256, "execute": True, **context, + }, timeout=300.0, retry_safe=False) + return result + + def stage_prepared_goal_transfer( *, source_preparation: dict[str, Any], diff --git a/tests/control_plane/test_planned_transfer_source_boundary.py b/tests/control_plane/test_planned_transfer_source_boundary.py index b88c962d3d..1ae70fe5c6 100644 --- a/tests/control_plane/test_planned_transfer_source_boundary.py +++ b/tests/control_plane/test_planned_transfer_source_boundary.py @@ -15,13 +15,18 @@ import pytest -from loopx.control_plane.effect_runtime import effect_runtime_result, restart_effect_runtime +from loopx.control_plane.effect_runtime import EffectRuntimeRemoteError, effect_runtime_result, restart_effect_runtime from loopx.control_plane.coordination.legacy_writer_fence import legacy_coordination_writer_fence_path from loopx.control_plane.coordination.local_authority_shadow_projection import canonical_bytes -from loopx.control_plane.goals.planned_transfer_io import read_goal_transfer_context, stage_prepared_goal_transfer +from loopx.control_plane.goals.planned_transfer_io import ( + prepare_registered_goal_transfer, preview_registered_goal_transfer, + read_goal_transfer_context, stage_prepared_goal_transfer, +) from loopx.control_plane.projects.registry_codec import load_project_registry, project_registry_transaction from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime from tests.control_plane.test_project_registry_codec import _strict +from loopx.global_registry import sync_project_registry_to_global +from loopx.paths import global_registry_path @pytest.mark.parametrize("provider", ["file", "sqlite"]) @@ -91,6 +96,12 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: lease = acquired["lease"] assert acquired["source_authority"] == f"{provider}_v0" assert inspect()["active"] is True + source_preview = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move") + assert source_preview["original_registration_verified"] is True + assert source_preview["ready_for_authority_preparation"] is False + assert source_preview["retained_leases"] == [lease] + assert source_preview["host_drain_qualified"] is False and source_preview["execution_authority_granted"] is False + assert inspect()["lease"] == lease # The accepted stop operation controls automatic advancement. It must # not silently revoke an admitted executor's lease or discard its work. @@ -164,18 +175,57 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: # Goal transfer must preserve its semantics without adopting the # separate lifecycle-only v2 source-session profile. registry.write_text(json.dumps(_strict(load_project_registry(registry)))) + # The product caller can start at the normal shared App/CLI registry. + # It resolves the original project through the existing route owner; + # stale/sanitized global fields and the global runtime are not authority. + sync_project_registry_to_global(registry_path=registry, runtime_root_override=str(runtime), goal_id="report") + global_registry = global_registry_path(runtime) + global_bytes = global_registry.read_bytes() + projected = load_project_registry(global_registry) + projected["common_runtime_root"] = str(tmp_path / "unrelated-shared-runtime") + projected["goals"][0]["display_name"] = "Stale shared read model" + global_registry.write_text(json.dumps(projected)) + assert read_goal_transfer_context(registry_path=global_registry, goal_id="report") == read_goal_transfer_context(registry_path=registry, goal_id="report") + preview = preview_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move") + assert preview["ready_for_authority_preparation"] is True + assert preview["provider_revision"] == released["provider_revision"] + assert preview["original_writer_fence_sha256"] == hashlib.sha256(canonical_bytes(original_fence)).hexdigest() + + # Same alias, same runtime and copied Goal metadata do not bind another + # registration to the original creation receipt. Source fencing must not + # occur on either this refusal or a stale reviewed source context. + copied_registry = tmp_path / "copied-registration.json" + copied_registry.write_bytes(registry.read_bytes()) + with pytest.raises(EffectRuntimeRemoteError, match="original registered creation"): + preview_registered_goal_transfer(registry_path=copied_registry, goal_id="report", operation_id="prepared-move") + with project_registry_transaction(registry, operation="update_source_before_transfer") as tx: + changed = tx.payload_copy() + changed["goals"][0]["display_name"] = "Current original Goal" + tx.commit(changed) + with pytest.raises(EffectRuntimeRemoteError, match="preview changed"): + prepare_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move", + expected_preview_sha256=preview["preview_sha256"]) + assert json.loads(legacy_coordination_writer_fence_path(runtime_root=runtime, goal_id="report").read_text()) == original_fence + assert inspect()["provider_revision"] == released["provider_revision"] + preview = preview_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move") + assert preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move") == preview request = {"runtime_root": str(runtime), "goal_id": "report", "operation_id": "prepared-move", "expected_provider_revision": released["provider_revision"], "expected_writer_fence_sha256": hashlib.sha256(canonical_bytes(original_fence)).hexdigest(), - "execute": True, **read_goal_transfer_context(registry_path=registry, goal_id="report")} - prepared = effect_runtime_result("coordination.planned_transfer.prepare_authority", request, - timeout=45, retry_safe=False) + "registered_source_preview_sha256": preview["preview_sha256"], + "execute": True, **read_goal_transfer_context(registry_path=global_registry, goal_id="report")} + prepared = prepare_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move", + expected_preview_sha256=preview["preview_sha256"]) assert prepared["authority_write_fenced"] is True + assert prepared["original_registration_verified"] is True assert prepared["execution_authority_granted"] is False assert prepared["host_drain_qualified"] is False for cold in (False, True): if cold: restart_effect_runtime(wait_for_exit=True) + assert preview_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move") == preview + assert prepare_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move", + expected_preview_sha256=preview["preview_sha256"]) == prepared assert effect_runtime_result("coordination.planned_transfer.prepare_authority", request, timeout=45, retry_safe=False) == prepared rejected = update(f"prepared-write-{cold}", "Must not overwrite prepared work.", expected_code=1) @@ -184,6 +234,18 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: "--owner", "agent-a", "--idempotency-key", f"fresh-after-preparation-{cold}", "--write-scope", "report.txt", "--write-worktree", str(worktree), expected_code=1) assert "prepared for transfer" in acquired["error"] + # Broken global source routes fail instead of falling back to a shared + # empty store. Restore only this synthetic read model, never source data. + broken_projection = load_project_registry(global_registry) + broken_projection["goals"][0]["source_registry"] = str(tmp_path / "missing-source-registry.json") + global_registry.write_text(json.dumps(broken_projection)) + with pytest.raises(ValueError, match="source_registry is missing"): + preview_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move") + del broken_projection["goals"][0]["source_registry"] + global_registry.write_text(json.dumps(broken_projection)) + with pytest.raises(EffectRuntimeRemoteError, match="global registry is a read model"): + preview_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move") + global_registry.write_bytes(global_bytes) # Populated history enters the destination through its Goal staging # owner, never a new-Goal bootstrap. Ordinary target reads must see the From ffc0f389ee54256a27384c0c847fa1d0e4dafaa3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:51:12 +0800 Subject: [PATCH 04/55] feat(goals): stage recipient-encrypted transfer material Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../planned_transfer_authority.ts | 14 +- .../coordination/planned_transfer_target.ts | 88 ++++- .../control_plane/effect_runtime_handlers.ts | 3 + loopx/control_plane/goals/planned_transfer.ts | 50 ++- .../goals/planned_transfer_container.ts | 320 ++++++++++++++++++ .../goals/planned_transfer_io.py | 79 ++++- .../goals/planned_transfer_payload.ts | 112 ++++++ .../test_planned_transfer_source_boundary.py | 90 ++++- .../planned_transfer_container.test.ts | 142 ++++++++ 9 files changed, 869 insertions(+), 29 deletions(-) create mode 100644 loopx/control_plane/goals/planned_transfer_container.ts create mode 100644 loopx/control_plane/goals/planned_transfer_payload.ts create mode 100644 tests/control_plane_ts/planned_transfer_container.test.ts diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index abe82ecbc9..5b56ae3bd0 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -20,13 +20,14 @@ import {openLocalAuthorityStoreHandle, requireLocalAuthorityRuntimeRoot} from ". import {loadLegacyCoordinationWriterFence, preparePlannedTransferWriterFenceUnderLock, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; import {readShadowManagementState, shadowManagementDirectory, withShadowMaintenanceLock} from "./shadow_management.ts"; -import {withVerifiedGoalTransferContext} from "../goals/planned_transfer.ts"; +import {PREPARATION_SCHEMA, withVerifiedGoalTransferContext} from "../goals/planned_transfer.ts"; +import {reviewProtectedGoalTransferInputs, writeProtectedGoalTransfer} from "../goals/planned_transfer_payload.ts"; import type {AuthorityStore} from "./authority_store.ts"; import {newGoalCreationBinding} from "./new_goal_initialization.ts"; import {readPromotionReceipt} from "./promotion_receipt.ts"; import {NEW_GOAL_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; -export const PREPARATION_SCHEMA = "loopx_planned_transfer_authority_preparation_v0"; +export {PREPARATION_SCHEMA} from "../goals/planned_transfer.ts"; const REGISTERED_SOURCE_PREVIEW_SCHEMA = "loopx_planned_transfer_source_preview_v0"; function reject(message: string): never { throw new EffectRuntimeConflictError(message); } @@ -119,9 +120,12 @@ export async function previewRegisteredAuthorityForPlannedTransfer(value: JsonOb export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Promise { const contextBound = value.source_context !== undefined; const registered = value.registered_source_preview_sha256 !== undefined; + const protectedExport = value.protected_transport !== undefined; if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id", "expected_provider_revision", "expected_writer_fence_sha256", "execute", ...(contextBound ? ["source_context", "registry_witness"] : []), - ...(registered ? ["registered_source_preview_sha256"] : [])]) || value.execute !== true || (registered && !contextBound) || + ...(registered ? ["registered_source_preview_sha256"] : []), + ...(protectedExport ? ["protected_transport", "source_package_version"] : [])]) || value.execute !== true || (registered && !contextBound) || + (protectedExport && !registered) || typeof value.expected_writer_fence_sha256 !== "string" || !BARE_SHA256_PATTERN.test(value.expected_writer_fence_sha256)) { reject("Transfer authority preparation requires an explicit execution and the complete reviewed source identity"); } @@ -174,6 +178,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro head, identity.store_identity, goal, operation); if (value.registered_source_preview_sha256 !== preview.preview_sha256) reject("Reviewed transfer source preview changed; review the current source without executing it"); } + const protectedInputs = protectedExport ? await reviewProtectedGoalTransferInputs(value.protected_transport, context!, value.source_package_version) : undefined; await mkdir(directory, {recursive: true, mode: 0o700}); await retain(pendingPath, preparation); await preparePlannedTransferWriterFenceUnderLock(root, goal, priorFence, { @@ -199,7 +204,8 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro ...(registered ? {original_registration_verified: true} : {}), host_drain_qualified: false, portable_artifact_qualified: false, execution_authority_granted: false}; await retain(join(directory, "receipt.json"), result); - return result; + return protectedInputs ? {...result, protected_artifact: await writeProtectedGoalTransfer(protectedInputs, + result, context!, join(directory, "encrypted-export-key.json"))} : result; }; return contextBound ? withVerifiedGoalTransferContext(value.source_context, value.registry_witness, goal, root, prepare) : prepare(); }); diff --git a/loopx/control_plane/coordination/planned_transfer_target.ts b/loopx/control_plane/coordination/planned_transfer_target.ts index eca599e0eb..aff4f9ed3c 100644 --- a/loopx/control_plane/coordination/planned_transfer_target.ts +++ b/loopx/control_plane/coordination/planned_transfer_target.ts @@ -1,13 +1,16 @@ /** Restore original authority into a stopped, fenced destination Goal. * No Host, credential, lease or effect admission is performed by this stage. */ -import {mkdir, readFile, realpath} from "node:fs/promises"; +import {copyFile, lstat, mkdir, open, readFile, readdir, realpath} from "node:fs/promises"; +import {constants} from "node:fs"; import {dirname, join, parse} from "node:path"; import {createHash} from "node:crypto"; import type {JsonObject} from "../effect_program.ts"; import {durableWriteJson, atomicWriteText, withFileMutationLock} from "../effect_runtime_io.ts"; import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; import {requireJsonObject} from "../runtime_decode.ts"; -import {planPreparedGoalStage, stagedGoalRegistry} from "../goals/planned_transfer.ts"; +import {planPreparedGoalStage, stagedGoalRegistry, type PreparedGoalStage} from "../goals/planned_transfer.ts"; +import {withVerifiedGoalTransferPayload} from "../goals/planned_transfer_payload.ts"; +import {inspectTransferFile, requirePhysicalTransferPath} from "../goals/planned_transfer_container.ts"; import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys} from "./authority_store_codec.ts"; import {verifyAuthorityArchive, restoreAuthorityArchive} from "./authority_archive.ts"; import {auditAuthorityArchive} from "./authority_archive_audit.ts"; @@ -42,19 +45,86 @@ async function physicalAncestors(path: string): Promise { } } +async function retainProtectedMaterials(plan: PreparedGoalStage, pending: boolean, completed: boolean, execute: boolean): Promise { + const artifact = plan.protectedArtifact; + if (!artifact) return; + const directory = join(dirname(plan.statePath), "transfer-materials"); + await physicalAncestors(directory); await physicalAncestors(artifact.manifestPath); + try { + if (!(await lstat(directory)).isDirectory() || !pending) reject("Destination material directory is occupied"); + const expected = new Set(artifact.materials.map(row => row.id)); + if ((await readdir(directory)).some(name => !expected.has(name))) reject("Destination retains unexpected material; preserve it"); + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const originalManifest = await optionalBytes(artifact.manifestPath); + if ((!pending && originalManifest) || (originalManifest && !same(JSON.parse(originalManifest.toString("utf8")), artifact.manifest))) reject("Destination material manifest is occupied or changed"); + if (completed && !originalManifest) reject("Completed destination materials are missing; recover the original stage"); + for (const row of artifact.materials) { + const target = join(directory, row.id); + await requirePhysicalTransferPath(target); + let existing: Awaited> | undefined; + try { existing = await inspectTransferFile(target); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if ((!pending && existing) || (existing && (existing.sha256 !== row.sha256 || existing.bytes !== row.bytes))) reject("Destination material is occupied or changed; preserve it"); + if (completed && !existing) reject("Completed destination material is missing; use its recovery owner"); + if (!existing) { + const input = await inspectTransferFile(row.path); + if (input.sha256 !== row.sha256 || input.bytes !== row.bytes) reject("Authenticated material changed before staging"); + if (!execute) continue; + await mkdir(directory, {recursive: true, mode: 0o700}); + await copyFile(row.path, target, constants.COPYFILE_EXCL); + const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW); + try { await handle.sync(); } finally { await handle.close(); } + const observed = await inspectTransferFile(target); + if (observed.sha256 !== row.sha256 || observed.bytes !== row.bytes) reject("Original material readback failed"); + } + } + if (execute) { + if (artifact.materials.length) { + const handle = await open(directory, constants.O_RDONLY); + try { await handle.sync(); } finally { await handle.close(); } + } + await retainJson(artifact.manifestPath, artifact.manifest); + } +} + +export async function previewProtectedGoalTransfer(value: JsonObject): Promise { + return withVerifiedGoalTransferPayload(value, async payload => { + const goal = requireJsonObject(payload.context.goal, "original Goal"); + return {schema_version: "loopx_planned_transfer_protected_preview_v0", artifact_sha256: payload.artifactSha256, + goal_id: goal.id, display_name: goal.display_name ?? goal.id, source_package_version: payload.sourceVersion, + original_authority: payload.preparation.authority_archive, + materials: payload.materials.map(({path: _path, ...row}) => row), + protected_transport_verified: true, privacy_certified: false, host_drain_qualified: false, + portable_migration_qualified: false, execution_authority_granted: false, credential_adoption_performed: false}; + }); +} + +export async function stageProtectedGoalTransfer(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["protected_input", "target", "destination_registry", "registry_witness", "execute"]) || + value.execute !== true) reject("Protected staging requires an explicit destination operation"); + return withVerifiedGoalTransferPayload(requireJsonObject(value.protected_input, "protected transfer input"), async payload => + stageAuthorityForPlannedTransfer({source_preparation: payload.preparation, source_context: payload.context, + archive: payload.archive, target: value.target, destination_registry: value.destination_registry, + registry_witness: value.registry_witness, execute: true, + protected_transport: {artifact_sha256: payload.artifactSha256, materials: payload.materials}})); +} + /** Maintenance/source/registry lock order matches the existing canonical owner. * The adapter later commits only the returned registry payload through its * codec CAS. Every effect is bound to the original operation; interrupted * publication leaves a stopped destination that the same operation can recover. */ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promise { - if (!hasExactAuthorityKeys(value, ["source_preparation", "source_context", "archive", "target", "destination_registry", "registry_witness", "execute"]) || + const protectedInput = value.protected_transport; + if (!hasExactAuthorityKeys(value, ["source_preparation", "source_context", "archive", "target", "destination_registry", "registry_witness", "execute", + ...(protectedInput === undefined ? [] : ["protected_transport"])]) || value.execute !== true || typeof value.archive !== "string") reject("Explicit complete Goal transfer staging is required"); const prepared = requireJsonObject(value.source_preparation, "prepared original authority"); if (prepared.schema_version !== PREPARATION_SCHEMA || prepared.authority_write_fenced !== true || prepared.execution_authority_granted !== false) reject("An original source authority preparation is required"); const summary = requireJsonObject(prepared.authority_archive, "prepared authority summary"); const plan = planPreparedGoalStage({operation_id: prepared.operation_id, source_context: value.source_context, - archive_sha256: summary.archive_sha256, target: value.target, destination_registry: value.destination_registry}); + archive_sha256: summary.archive_sha256, target: value.target, destination_registry: value.destination_registry, + ...(protectedInput === undefined ? {} : {protected_transport: protectedInput})}); if (prepared.goal_id !== plan.goalId || prepared.source_context_sha256 !== canonicalAuthoritySha256(plan.context)) reject("Prepared source and original Goal context differ"); const target = value.target as JsonObject, root = target.runtime_root as string; const witness = requireJsonObject(value.registry_witness, "codec-validated destination witness"); @@ -113,7 +183,8 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi schema_version: "loopx_local_authority_provider_v0", goal_id: plan.goalId, provider: target.provider, store_identity: observed.store_identity})) reject("Destination provider selection changed; preserve later state"); } - if (await optionalBytes(join(directory, "receipt.json"))) { + const completed = await optionalBytes(join(directory, "receipt.json")); + if (completed) { const priorAudit = await auditAuthorityArchive(value.archive as string, selected, archive.archive_sha256); if (priorAudit.status !== "matched") reject("Completed destination authority changed or is missing; use its recovery owner"); } @@ -123,6 +194,7 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi if (!pending && (originalState || originalContext)) reject("Destination Goal material is occupied; retain its original owner"); if ((originalState && !originalState.equals(Buffer.from(String(plan.context.state_text)))) || (originalContext && !same(JSON.parse(originalContext.toString("utf8")), plan.context))) reject("Destination Goal material is occupied or changed"); + await retainProtectedMaterials(plan, pending !== null, completed !== null, false); await mkdir(directory, {recursive: true, mode: 0o700}); await retainJson(pendingPath, plan.identity); const engaged = await engageLegacyCoordinationWriterFenceUnderLocks(root, plan.goalId, plan.statePath, fence); @@ -130,6 +202,7 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi await mkdir(dirname(plan.statePath), {recursive: true, mode: 0o700}); if (!originalState) await atomicWriteText(plan.statePath, String(plan.context.state_text)); await retainJson(plan.contextPath, plan.context); + await retainProtectedMaterials(plan, pending !== null, completed !== null, true); const Store = target.provider === "file" ? FileAuthorityStore : SqliteAuthorityStore; const store = new Store(paths[target.provider as "file" | "sqlite"], plan.goalId); const identity = await store.storeIdentity(); @@ -142,7 +215,10 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi const result = {schema_version: "loopx_planned_transfer_target_stage_v0", operation_id: plan.operationId, goal_id: plan.goalId, identity_sha256: canonicalAuthoritySha256(plan.identity), original_authority: {...archive}, authority_readback: audit, target_store_identity: identity.store_identity, target_provider: target.provider, - target_is_active: false, fresh_admission_required: true, execution_authority_granted: false}; + target_is_active: false, fresh_admission_required: true, execution_authority_granted: false, + ...(plan.protectedArtifact ? {artifact_sha256: plan.protectedArtifact.sha256, + original_material_count: plan.protectedArtifact.materials.length, material_readback_verified: true, + original_material_manifest: plan.protectedArtifact.manifestPath} : {})}; await retainJson(join(directory, "receipt.json"), result); return {...result, destination_registry: stagedGoalRegistry(value.destination_registry as JsonObject, plan, root), original_context_path: plan.contextPath}; diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 3bac7bbe71..60b81fd429 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -419,6 +419,9 @@ export function createEffectRuntimeHandlers( ["coordination.planned_transfer.preview_source", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({previewRegisteredAuthorityForPlannedTransfer}) => previewRegisteredAuthorityForPlannedTransfer)], ["coordination.planned_transfer.prepare_authority", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({prepareAuthorityForPlannedTransfer}) => prepareAuthorityForPlannedTransfer)], ["coordination.planned_transfer.stage_target", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({stageAuthorityForPlannedTransfer}) => stageAuthorityForPlannedTransfer)], + ["coordination.planned_transfer.create_recipient", lazyHandler(() => import("./goals/planned_transfer_container.ts"), ({createPlannedTransferRecipient}) => createPlannedTransferRecipient)], + ["coordination.planned_transfer.preview_protected", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({previewProtectedGoalTransfer}) => previewProtectedGoalTransfer)], + ["coordination.planned_transfer.stage_protected", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({stageProtectedGoalTransfer}) => stageProtectedGoalTransfer)], ["configuration.backup", lazyHandler(() => import("./configuration_backup.ts"), ({configurationBackupOperation}) => configurationBackupOperation)], ["runtime.checkpoint_artifacts", lazyHandler(() => import("./runtime/checkpoint_artifacts.ts"), ({checkpointArtifacts}) => checkpointArtifacts)], ["state.recovery.archive.verify", lazyHandler(() => import("./state_recovery.ts"), ({verifyStateRecoveryArchive}) => verifyStateRecoveryArchive)], diff --git a/loopx/control_plane/goals/planned_transfer.ts b/loopx/control_plane/goals/planned_transfer.ts index 962a930065..46843b37c8 100644 --- a/loopx/control_plane/goals/planned_transfer.ts +++ b/loopx/control_plane/goals/planned_transfer.ts @@ -19,8 +19,27 @@ import {withFileMutationLock} from "../effect_runtime_io.ts"; import {legacyCoordinationTodoLockPath} from "../coordination/legacy_writer_lock_paths.ts"; export const PLANNED_TRANSFER_GOAL_STAGE_SCHEMA = "loopx_planned_transfer_goal_stage_v0"; +export const PREPARATION_SCHEMA = "loopx_planned_transfer_authority_preparation_v0"; const executeFile = promisify(execFile); +export interface PlannedTransferMaterialIdentity extends JsonObject { + id: string; source_relative_path: string; sha256: string; bytes: number; +} +export interface ProtectedGoalMaterial extends PlannedTransferMaterialIdentity {path: string} + +/** One Goal-owned identity contract shared by encrypted metadata and stopped + * staging. The relative name is historical context, never a target write path. + * The custody reader separately bounds and verifies the actual file bytes. */ +export function decodePlannedTransferMaterial(raw: unknown, index: number): PlannedTransferMaterialIdentity { + const row = requireJsonObject(raw, "original Goal material"); + if (!hasExactAuthorityKeys(row, ["id", "source_relative_path", "sha256", "bytes"]) || + row.id !== (index + 2).toString(16).padStart(8, "0") || !BARE_SHA256_PATTERN.test(String(row.sha256)) || + typeof row.bytes !== "number" || !Number.isSafeInteger(row.bytes) || row.bytes < 0 || + typeof row.source_relative_path !== "string" || !row.source_relative_path || row.source_relative_path.startsWith("/") || + row.source_relative_path.includes("\0") || row.source_relative_path.split("/").some(part => part === "" || part === "." || part === "..")) reject("Original Goal material identity is invalid"); + return row as PlannedTransferMaterialIdentity; +} + function reject(message: string): never { throw new EffectRuntimeConflictError(message); } function absolute(value: unknown, label: string): string { const path = requireAuthorityStoreId(value, label); @@ -43,6 +62,7 @@ export interface PreparedGoalStage { statePath: string; contextPath: string; identity: JsonObject; + protectedArtifact?: {sha256: string; materials: ProtectedGoalMaterial[]; manifest: JsonObject; manifestPath: string}; } /** Capture Goal intent through its actual registered source while the caller @@ -90,7 +110,9 @@ export async function withVerifiedGoalTransferContext(raw: unknown, rawWitnes /** Codec-decoded registry facts are supplied by the transaction-owning adapter. * This decision never serializes or mutates a project registry itself. */ export function planPreparedGoalStage(value: JsonObject): PreparedGoalStage { - if (!hasExactAuthorityKeys(value, ["operation_id", "source_context", "archive_sha256", "target", "destination_registry"])) { + const protectedInput = value.protected_transport === undefined ? undefined : requireJsonObject(value.protected_transport, "verified protected transport"); + if (!hasExactAuthorityKeys(value, ["operation_id", "source_context", "archive_sha256", "target", "destination_registry", + ...(protectedInput ? ["protected_transport"] : [])])) { reject("Goal transfer staging requires the complete original context and destination observation"); } const operation = requireAuthorityStoreId(value.operation_id, "original transfer operation"); @@ -116,8 +138,27 @@ export function planPreparedGoalStage(value: JsonObject): PreparedGoalStage { } const directory = join(project, ".loopx", "goals", goalId); const statePath = join(directory, "ACTIVE_GOAL_STATE.md"), contextPath = join(directory, "TRANSFER_CONTEXT.json"); + let protectedArtifact: PreparedGoalStage["protectedArtifact"]; + if (protectedInput) { + if (!hasExactAuthorityKeys(protectedInput, ["artifact_sha256", "materials"]) || + !BARE_SHA256_PATTERN.test(String(protectedInput.artifact_sha256)) || !Array.isArray(protectedInput.materials)) reject("Protected staging requires its authenticated material inventory"); + const seen = new Set(); + const materials = protectedInput.materials.map((raw, index) => { + const row = requireJsonObject(raw, "protected material"); + const {path, ...identity} = row; + const material = decodePlannedTransferMaterial(identity, index); + if (seen.has(material.source_relative_path)) reject("Protected staging material is duplicated"); + absolute(path, "verified material path"); seen.add(material.source_relative_path); + return {...material, path: String(path)}; + }); + const manifest = {artifact_sha256: protectedInput.artifact_sha256, + materials: materials.map(({path: _path, ...row}) => row)}; + protectedArtifact = {sha256: String(protectedInput.artifact_sha256), materials, manifest, + manifestPath: join(directory, "TRANSFER_MATERIALS.json")}; + } const identity = {schema_version: PLANNED_TRANSFER_GOAL_STAGE_SCHEMA, goal_id: goalId, operation_id: operation, - source_context_sha256: canonicalAuthoritySha256(context), archive_sha256: value.archive_sha256, target}; + source_context_sha256: canonicalAuthoritySha256(context), archive_sha256: value.archive_sha256, target, + ...(protectedArtifact ? {protected_transport: protectedArtifact.manifest} : {})}; // Copy no adapter credentials, Host/session identity, machine capability // configuration, creation receipt or live membership into this registration. const goal: JsonObject = {id: goalId, status: "active", role: "controller", domain: "project-goal-control-plane", @@ -127,7 +168,8 @@ export function planPreparedGoalStage(value: JsonObject): PreparedGoalStage { adapter: {kind: "generic_project_goal_v0", status: "connected"}, spawn_policy: {mode: "default", allowed: false, max_children: 3, allowed_domains: []}, coordination: {registered_agents: [], write_scope: [], requires_parent_approval: ["write", "publish", "production-action"]}, - authority_sources: [{kind: "migration_report", path: contextPath, role: "original_transfer_context"}], + authority_sources: [{kind: "migration_report", path: contextPath, role: "original_transfer_context"}, + ...(protectedArtifact ? [{kind: "migration_report", path: protectedArtifact.manifestPath, role: "original_transfer_materials"}] : [])], planned_transfer: {schema_version: PLANNED_TRANSFER_GOAL_STAGE_SCHEMA, operation_id: operation, identity_sha256: canonicalAuthoritySha256(identity), state: "staged", identity_continuity: "same_goal_read_only", fresh_admission_required: true}}; @@ -140,7 +182,7 @@ export function planPreparedGoalStage(value: JsonObject): PreparedGoalStage { if (matches.length > 1 || (matches.length === 1 && !canonicalAuthorityBytes(matches[0]).equals(canonicalAuthorityBytes(goal)))) { reject("Destination Goal is occupied or its staged registration changed; preserve it and recover the original transfer"); } - return {goalId, operationId: operation, context, goal, statePath, contextPath, identity}; + return {goalId, operationId: operation, context, goal, statePath, contextPath, identity, protectedArtifact}; } export function stagedGoalRegistry(registry: JsonObject, plan: PreparedGoalStage, runtime: string): JsonObject { diff --git a/loopx/control_plane/goals/planned_transfer_container.ts b/loopx/control_plane/goals/planned_transfer_container.ts new file mode 100644 index 0000000000..2027693ee2 --- /dev/null +++ b/loopx/control_plane/goals/planned_transfer_container.ts @@ -0,0 +1,320 @@ +/** Draft, explicit encrypted transport for the planned-transfer composition. + * Native authority archives still own history. This directory envelope only + * protects selected bytes for a recipient; it grants no execution or credential + * adoption. Security/release review of the profile remains required before it + * is advertised as portable recovery (complete-state recovery RFC, section 12). + */ +import {constants} from "node:fs"; +import {lstat, mkdir, mkdtemp, open, readdir, realpath, rm, type FileHandle} from "node:fs/promises"; +import {createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, generateKeyPair, + privateDecrypt, publicEncrypt, randomBytes, constants as cryptoConstants, type KeyObject} from "node:crypto"; +import {dirname, isAbsolute, join, resolve} from "node:path"; +import {tmpdir} from "node:os"; +import {Readable} from "node:stream"; +import {promisify} from "node:util"; +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; +import {durableWriteJson, publishNewDirectory, withFileMutationLock} from "../effect_runtime_io.ts"; +import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys} from "../coordination/authority_store_codec.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; + +const RECIPIENT_SCHEMA = "loopx_planned_transfer_recipient_v0"; +const CONTAINER_SCHEMA = "loopx_planned_transfer_encrypted_v0"; +const CONTENT_KEY_SCHEMA = "loopx_planned_transfer_content_key_v0"; +const SUITE = "RSA-OAEP-SHA256/AES-256-GCM"; +// Resource bounds match the existing local recovery reader. They neither +// promote that profile nor certify this draft transport's maximum-load behavior. +const MAX_MEMBER_BYTES = 128 * 1024 * 1024, MAX_TOTAL_BYTES = 512 * 1024 * 1024; +const MAX_METADATA_BYTES = 16 * 1024 * 1024, MAX_MEMBERS = 20_000; +const generatePair = promisify(generateKeyPair); +function reject(message: string): never { throw new EffectRuntimeConflictError(message); } +function hash(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } +function exact(value: JsonObject, keys: string[]): void { + if (!hasExactAuthorityKeys(value, keys)) reject("Encrypted transfer fields are invalid or unsupported"); +} +function hex(value: unknown, bytes: number): Buffer { + if (typeof value !== "string" || !new RegExp(`^[a-f0-9]{${bytes * 2}}$`).test(value)) reject("Encrypted transfer encoding is invalid"); + return Buffer.from(value, "hex"); +} +function number(value: unknown, limit: number): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > limit) reject("Encrypted transfer exceeds its local capacity"); + return value; +} +export async function requirePhysicalTransferPath(raw: unknown): Promise { + if (typeof raw !== "string" || !isAbsolute(raw) || resolve(raw) !== raw || raw.includes("\0")) reject("Transfer requires a physical normalized path"); + for (let path = raw; ; path = dirname(path)) { + try { if (await realpath(path) !== path) reject("Transfer path contains a linked ancestor"); break; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (dirname(path) === path) reject("Transfer path has no physical parent"); + } + return raw; +} +async function regular(path: string, limit: number, secret = false): Promise { + await requirePhysicalTransferPath(path); + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const info = await handle.stat(); + if (!info.isFile() || info.size > limit || (secret && ((info.mode & 0o077) !== 0 || info.uid !== process.getuid?.()))) { + reject("Transfer input must be a bounded regular file; secret keys require private ownership and permissions"); + } + return handle; + } catch (error) { await handle.close(); throw error; } +} +async function jsonFile(path: string, limit = MAX_METADATA_BYTES, secret = false): Promise { + const handle = await regular(path, limit, secret); + try { + const bytes = await boundedBytes(handle, limit); + return requireJsonObject(JSON.parse(bytes.toString("utf8")), "transfer metadata"); + } finally { await handle.close(); } +} +async function boundedBytes(handle: FileHandle, limit: number): Promise { + const chunks: Buffer[] = []; let bytes = 0; + for await (const chunk of handle.createReadStream({autoClose: false, emitClose: false})) { + bytes += chunk.length; + if (bytes > limit) reject("Transfer metadata grew beyond its local capacity"); + chunks.push(chunk); + } + return Buffer.concat(chunks, bytes); +} +export async function inspectTransferFile(path: string): Promise<{sha256: string; bytes: number}> { + const handle = await regular(path, MAX_MEMBER_BYTES); + const digest = createHash("sha256"); let size = 0; + try { + for await (const chunk of handle.createReadStream({autoClose: false, emitClose: false})) { + size += chunk.length; number(size, MAX_MEMBER_BYTES); digest.update(chunk); + } + return {sha256: digest.digest("hex"), bytes: size}; + } finally { await handle.close(); } +} +function recipient(raw: unknown): {descriptor: JsonObject; key: KeyObject} { + const descriptor = requireJsonObject(raw, "transfer recipient"); + exact(descriptor, ["schema_version", "key_id", "public_key_pem"]); + if (descriptor.schema_version !== RECIPIENT_SCHEMA || typeof descriptor.public_key_pem !== "string" || + descriptor.public_key_pem.length > 8192) reject("Unsupported transfer recipient"); + let key: KeyObject; + try { key = createPublicKey(descriptor.public_key_pem); } catch { reject("Invalid transfer recipient public key"); } + if (key.asymmetricKeyType !== "rsa" || key.asymmetricKeyDetails?.modulusLength !== 3072 || + key.export({format: "pem", type: "spki"}).toString() !== descriptor.public_key_pem || + hash(key.export({format: "der", type: "spki"})) !== descriptor.key_id) reject("Transfer recipient identity or key format changed"); + return {descriptor, key}; +} +export function validatePlannedTransferRecipient(raw: unknown): JsonObject { return recipient(raw).descriptor; } +export async function createPlannedTransferRecipient(value: JsonObject): Promise { + exact(value, ["destination", "execute"]); + if (process.platform === "win32" || value.execute !== true) reject("Explicit recipient creation requires the local POSIX profile"); + const destination = await requirePhysicalTransferPath(value.destination); + return withFileMutationLock(destination, async () => { + try { + await lstat(destination); + const descriptor = await jsonFile(join(destination, "recipient.json")); + const privateHandle = await regular(join(destination, "private-key.pem"), 16 * 1024, true); + try { + const privateKey = createPrivateKey(await boundedBytes(privateHandle, 16 * 1024)); + if (hash(createPublicKey(privateKey).export({format: "der", type: "spki"})) !== recipient(descriptor).descriptor.key_id) reject("Recipient key readback changed"); + } finally { await privateHandle.close(); } + return descriptor; + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + // An occupied incomplete directory is never repaired by issuing new keys. + try { await lstat(destination); reject("Recipient is incomplete; recover its original private key"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + await mkdir(dirname(destination), {recursive: true, mode: 0o700}); + const staging = await mkdtemp(join(dirname(destination), ".loopx-recipient-")); + try { + const pair = await generatePair("rsa", {modulusLength: 3072}); + const descriptor = {schema_version: RECIPIENT_SCHEMA, key_id: hash(pair.publicKey.export({format: "der", type: "spki"})), + public_key_pem: pair.publicKey.export({format: "pem", type: "spki"}).toString()}; + const handle = await open(join(staging, "private-key.pem"), "wx", 0o600); + try { await handle.writeFile(pair.privateKey.export({format: "pem", type: "pkcs8"})); await handle.sync(); } + finally { await handle.close(); } + await durableWriteJson(join(staging, "recipient.json"), descriptor); + await publishNewDirectory(staging, destination); + return descriptor; + } finally { await rm(staging, {recursive: true, force: true}); } + }); +} + +interface Member extends JsonObject {id: string; sha256: string; bytes: number} +export interface TransferContainerFile {path: string; sha256: string; bytes: number} +export interface VerifiedTransferContainer { + metadata: JsonObject; files: ReadonlyMap; members: readonly Member[]; envelopeSha256: string; +} +function base(envelope: JsonObject): JsonObject { + return {schema_version: envelope.schema_version, suite: envelope.suite, artifact_id: envelope.artifact_id, + recipient_id: envelope.recipient_id}; +} +function aad(envelope: JsonObject, id: string): Buffer { + return canonicalAuthorityBytes({...base(envelope), wrapped_key_base64: envelope.wrapped_key_base64, member_id: id}); +} +async function encryptMember(input: Readable, output: string, envelope: JsonObject, id: string, + key: Buffer, expected: {sha256: string; bytes: number}): Promise { + const nonce = randomBytes(12), cipher = createCipheriv("aes-256-gcm", key, nonce, {authTagLength: 16}); + cipher.setAAD(aad(envelope, id)); + const plainHash = createHash("sha256"), cipherHash = createHash("sha256"), plain = {bytes: 0}, encrypted = {bytes: 0}; + let handle: FileHandle | undefined; + try { + handle = await open(output, "wx", 0o600); + const write = async (chunk: Buffer) => { + encrypted.bytes += chunk.length; number(encrypted.bytes, expected.bytes); + cipherHash.update(chunk); await handle!.writeFile(chunk); + }; + for await (const chunk of input) { + plain.bytes += chunk.length; number(plain.bytes, expected.bytes); plainHash.update(chunk); + await write(cipher.update(chunk)); + } + await write(cipher.final()); + await handle.sync(); + } finally { input.destroy(); await handle?.close(); } + if (plain.bytes !== expected.bytes || plainHash.digest("hex") !== expected.sha256) reject("Reviewed transfer material changed during export"); + return {id, nonce: nonce.toString("hex"), tag: cipher.getAuthTag().toString("hex"), + sha256: cipherHash.digest("hex"), bytes: encrypted.bytes}; +} +async function withContainer(directory: string, expectedSha: unknown, + keyFor: (envelope: JsonObject) => Promise, consume: (verified: VerifiedTransferContainer) => Promise): Promise { + if (process.platform === "win32") reject("Encrypted transfer requires the local POSIX profile"); + await requirePhysicalTransferPath(directory); + const envelope = await jsonFile(join(directory, "envelope.json")); + exact(envelope, ["schema_version", "suite", "artifact_id", "recipient_id", "wrapped_key_base64", "entries"]); + const envelopeSha = canonicalAuthoritySha256(envelope); + if (envelope.schema_version !== CONTAINER_SCHEMA || envelope.suite !== SUITE || + (expectedSha !== undefined && expectedSha !== envelopeSha) || !Array.isArray(envelope.entries) || + envelope.entries.length < 2 || envelope.entries.length > MAX_MEMBERS) reject("Transfer format or reviewed container changed"); + hex(envelope.artifact_id, 32); hex(envelope.recipient_id, 32); + if (typeof envelope.wrapped_key_base64 !== "string" || Buffer.from(envelope.wrapped_key_base64, "base64").length !== 384 || + Buffer.from(envelope.wrapped_key_base64, "base64").toString("base64") !== envelope.wrapped_key_base64) reject("Invalid transfer key envelope"); + const entries = envelope.entries.map((raw, index) => { + const row = requireJsonObject(raw, "encrypted member"); exact(row, ["id", "nonce", "tag", "sha256", "bytes"]); + if (row.id !== index.toString(16).padStart(8, "0")) reject("Transfer members are missing, duplicated or reordered"); + hex(row.nonce, 12); hex(row.tag, 16); hex(row.sha256, 32); + number(row.bytes, index === 0 ? MAX_METADATA_BYTES : MAX_MEMBER_BYTES); + return row; + }); + number(entries.reduce((total, row) => total + Number(row.bytes), 0), MAX_TOTAL_BYTES); + if (new Set(entries.map(row => row.nonce)).size !== entries.length) reject("Transfer member nonce is reused"); + const expectedNames = ["envelope.json", ...entries.map(row => `${row.id}.enc`)].sort(); + if (!canonicalAuthorityBytes((await readdir(directory)).sort()).equals(canonicalAuthorityBytes(expectedNames))) reject("Transfer contains missing or unexpected members"); + const temporary = await realpath(await mkdtemp(join(tmpdir(), "loopx-private-transfer-"))); + let key: Buffer | undefined; + try { + key = await keyFor(envelope); + const files = new Map(); + for (const row of entries) { + const id = String(row.id), source = await regular(join(directory, `${id}.enc`), Number(row.bytes)); + const output = join(temporary, id); let handle: FileHandle | undefined; + try { + handle = await open(output, "wx", 0o600); + const decipher = createDecipheriv("aes-256-gcm", key, hex(row.nonce, 12), {authTagLength: 16}); + decipher.setAAD(aad(envelope, id)); decipher.setAuthTag(hex(row.tag, 16)); + const encrypted = {bytes: 0}, cipherHash = createHash("sha256"); + for await (const chunk of source.createReadStream({autoClose: false, emitClose: false})) { + encrypted.bytes += chunk.length; number(encrypted.bytes, Number(row.bytes)); cipherHash.update(chunk); + await handle.writeFile(decipher.update(chunk)); + } + await handle.writeFile(decipher.final()); + if (encrypted.bytes !== row.bytes || cipherHash.digest("hex") !== row.sha256) reject("Encrypted transfer member changed"); + } catch { reject("Transfer authentication failed; no destination authority was admitted"); } + finally { await source.close(); await handle?.close(); } + files.set(id, output); + } + const document = await jsonFile(files.get("00000000")!); + exact(document, ["metadata", "members"]); + if (!Array.isArray(document.members) || document.members.length !== entries.length - 1) reject("Transfer payload inventory changed"); + const members: Member[] = []; + for (const [index, raw] of document.members.entries()) { + const row = requireJsonObject(raw, "transfer member"); exact(row, ["id", "sha256", "bytes"]); + if (row.id !== (index + 1).toString(16).padStart(8, "0") || !BARE_SHA256_PATTERN.test(String(row.sha256))) reject("Invalid transfer payload identity"); + const observed = await inspectTransferFile(files.get(String(row.id))!); + if (observed.sha256 !== row.sha256 || observed.bytes !== row.bytes) reject("Authenticated transfer payload differs from its original bytes"); + members.push(row as Member); + } + // No caller sees even a plaintext path until every member authenticated. + return await consume({metadata: requireJsonObject(document.metadata, "transfer payload metadata"), files, + members, envelopeSha256: envelopeSha}); + } finally { key?.fill(0); await rm(temporary, {recursive: true, force: true}); } +} +export async function withVerifiedTransferContainer(directory: string, privateKeyPath: string, expectedSha: unknown, + consume: (verified: VerifiedTransferContainer) => Promise): Promise { + return withContainer(directory, expectedSha, async envelope => { + const handle = await regular(privateKeyPath, 16 * 1024, true); + try { + const key = createPrivateKey(await boundedBytes(handle, 16 * 1024)); + if (key.asymmetricKeyType !== "rsa" || key.asymmetricKeyDetails?.modulusLength !== 3072) reject("Unsupported transfer recipient key"); + if (hash(createPublicKey(key).export({format: "der", type: "spki"})) !== envelope.recipient_id) reject("This transfer belongs to a different recipient"); + const contentKey = privateDecrypt({key, padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", oaepLabel: canonicalAuthorityBytes(base(envelope))}, Buffer.from(String(envelope.wrapped_key_base64), "base64")); + if (contentKey.length !== 32) { contentKey.fill(0); reject("Invalid transfer content key"); } + return contentKey; + } finally { await handle.close(); } + }, consume); +} + +/** The source operation keeps its content key privately for exact-operation + * retry. That secret record and the recipient private key are outside the + * transported directory and are never returned in an RPC result. */ +export async function writeTransferContainer(destination: string, recipientValue: unknown, metadata: JsonObject, + inputs: TransferContainerFile[], keyRecordPath: string): Promise { + if (process.platform === "win32") reject("Encrypted transfer requires the local POSIX profile"); + const receiver = recipient(recipientValue); + await requirePhysicalTransferPath(destination); await requirePhysicalTransferPath(keyRecordPath); + if (inputs.length < 1 || inputs.length + 1 > MAX_MEMBERS) reject("Transfer member capacity exceeded"); + const members = inputs.map((item, index) => ({id: (index + 1).toString(16).padStart(8, "0"), + sha256: item.sha256, bytes: number(item.bytes, MAX_MEMBER_BYTES)})); + for (const row of members) hex(row.sha256, 32); + const document = {metadata, members}, documentBytes = canonicalAuthorityBytes(document); + number(documentBytes.length, MAX_METADATA_BYTES); + number(documentBytes.length + members.reduce((sum, row) => sum + row.bytes, 0), MAX_TOTAL_BYTES); + const identity = canonicalAuthoritySha256({destination, recipient: receiver.descriptor, document}); + let keyRecord: JsonObject; + try { keyRecord = await jsonFile(keyRecordPath, 16 * 1024, true); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + try { await lstat(destination); reject("Original transfer content key is missing; recover it without replacing the exported artifact"); } + catch (occupied) { if ((occupied as NodeJS.ErrnoException).code !== "ENOENT") throw occupied; } + keyRecord = {schema_version: CONTENT_KEY_SCHEMA, identity_sha256: identity, + artifact_id: randomBytes(32).toString("hex"), content_key_hex: randomBytes(32).toString("hex")}; + await mkdir(dirname(keyRecordPath), {recursive: true, mode: 0o700}); + await durableWriteJson(keyRecordPath, keyRecord); + } + exact(keyRecord, ["schema_version", "identity_sha256", "artifact_id", "content_key_hex"]); + if (keyRecord.schema_version !== CONTENT_KEY_SCHEMA || keyRecord.identity_sha256 !== identity) reject("Transfer export belongs to different reviewed inputs; recover the original operation"); + const key = hex(keyRecord.content_key_hex, 32); hex(keyRecord.artifact_id, 32); + const summarize = (verified: VerifiedTransferContainer): JsonObject => { + if (!canonicalAuthorityBytes(verified.metadata).equals(canonicalAuthorityBytes(metadata)) || + !canonicalAuthorityBytes(verified.members).equals(canonicalAuthorityBytes(members))) reject("Existing transfer export differs from the original operation"); + return {schema_version: CONTAINER_SCHEMA, artifact_sha256: verified.envelopeSha256, + recipient_id: receiver.descriptor.key_id, encrypted_member_count: members.length + 1, + protected_transport_verified: true, execution_authority_granted: false, credential_adoption_performed: false}; + }; + try { + let occupied = false; + try { await lstat(destination); occupied = true; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (occupied) return await withContainer(destination, undefined, async envelope => { + if (envelope.artifact_id !== keyRecord.artifact_id || envelope.recipient_id !== receiver.descriptor.key_id) reject("Transfer destination is occupied"); + return Buffer.from(key); + }, async verified => summarize(verified)); + await mkdir(dirname(destination), {recursive: true, mode: 0o700}); + const staging = await mkdtemp(join(dirname(destination), ".loopx-encrypted-transfer-")); + try { + const header: JsonObject = {schema_version: CONTAINER_SCHEMA, suite: SUITE, + artifact_id: keyRecord.artifact_id, recipient_id: receiver.descriptor.key_id}; + header.wrapped_key_base64 = publicEncrypt({key: receiver.key, padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", oaepLabel: canonicalAuthorityBytes(base(header))}, key).toString("base64"); + const entries = [await encryptMember(Readable.from([documentBytes]), join(staging, "00000000.enc"), + header, "00000000", key, {bytes: documentBytes.length, sha256: hash(documentBytes)})]; + for (const [index, input] of inputs.entries()) { + const handle = await regular(input.path, MAX_MEMBER_BYTES); + try { entries.push(await encryptMember(handle.createReadStream({autoClose: false, emitClose: false}), + join(staging, `${members[index]!.id}.enc`), header, members[index]!.id, key, members[index]!)); } + finally { await handle.close(); } + } + header.entries = entries; + await durableWriteJson(join(staging, "envelope.json"), header); + await withContainer(staging, canonicalAuthoritySha256(header), async () => Buffer.from(key), async verified => summarize(verified)); + await publishNewDirectory(staging, destination); + return {schema_version: CONTAINER_SCHEMA, artifact_sha256: canonicalAuthoritySha256(header), + recipient_id: receiver.descriptor.key_id, encrypted_member_count: members.length + 1, + protected_transport_verified: true, execution_authority_granted: false, credential_adoption_performed: false}; + } finally { await rm(staging, {recursive: true, force: true}); } + } finally { key.fill(0); } +} diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index 106be1c287..f56bd950cc 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Any +from ... import __version__ from ..effect_runtime import effect_runtime_result from ..projects.registry_codec import load_project_registry, project_registry_transaction @@ -67,16 +68,66 @@ def prepare_registered_goal_transfer( admission are not established by a source authority fence. The complete product coordinator must keep those qualifications separate. """ + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.prepare_authority", + _registered_preparation_request(registry_path, goal_id, operation_id, expected_preview_sha256), + timeout=300.0, retry_safe=False) + return result + + +def _registered_preparation_request( + registry_path: Path, goal_id: str, operation_id: str, expected_preview_sha256: str, +) -> dict[str, Any]: context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) preview = preview_registered_goal_transfer(registry_path=registry_path, goal_id=goal_id, operation_id=operation_id) # The native execution compares this reviewed digest again under its actual # maintenance/source locks; a preview read here cannot waive stale inputs. - result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.prepare_authority", { + return { "runtime_root": context["source_context"]["runtime_root"], "goal_id": goal_id, "operation_id": operation_id, "expected_provider_revision": preview["provider_revision"], "expected_writer_fence_sha256": preview["original_writer_fence_sha256"], "registered_source_preview_sha256": expected_preview_sha256, "execute": True, **context, - }, timeout=300.0, retry_safe=False) + } + + +def create_planned_transfer_recipient(*, destination: Path) -> dict[str, Any]: + """Keep the private key at the recipient; only its public descriptor returns.""" + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.create_recipient", { + "destination": str(destination.expanduser().absolute()), "execute": True, + }, timeout=60.0, retry_safe=False) + return result + + +def export_registered_goal_transfer( + *, registry_path: Path, goal_id: str, operation_id: str, expected_preview_sha256: str, + recipient: dict[str, Any], destination: Path, materials: list[tuple[Path, str]], +) -> dict[str, Any]: + """Pin original authority and selected bytes into a recipient-bound draft. + + This internal composition still does not qualify source Host/effect drain, + material completeness, security review or destination admission. It avoids + exporting a raw runtime tree or treating source config as a current grant. + """ + request = _registered_preparation_request(registry_path, goal_id, operation_id, expected_preview_sha256) + request.update(source_package_version=__version__, protected_transport={ + "destination": str(destination.expanduser().absolute()), "recipient": recipient, + "materials": [{"path": str(path.expanduser().absolute()), "sha256": digest} for path, digest in materials], + }) + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.prepare_authority", request, + timeout=300.0, retry_safe=False) + return result + + +def _protected_input(directory: Path, private_key: Path, expected_artifact_sha256: str) -> dict[str, Any]: + return {"directory": str(directory.expanduser().absolute()), + "private_key_path": str(private_key.expanduser().absolute()), + "expected_artifact_sha256": expected_artifact_sha256, "target_package_version": __version__} + + +def preview_protected_goal_transfer( + *, directory: Path, private_key: Path, expected_artifact_sha256: str, +) -> dict[str, Any]: + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.preview_protected", + _protected_input(directory, private_key, expected_artifact_sha256), timeout=300.0, retry_safe=True) return result @@ -96,6 +147,25 @@ def stage_prepared_goal_transfer( lock inversion with canonical writers. Interruption leaves the destination fenced; retry the same operation. No preimage is restored over new work. """ + return _stage_goal_transfer("coordination.planned_transfer.stage_target", { + "source_preparation": source_preparation, "source_context": source_context, + "archive": str(archive.expanduser().absolute()), + }, project, runtime_root, provider) + + +def stage_protected_goal_transfer( + *, directory: Path, private_key: Path, expected_artifact_sha256: str, + project: Path, runtime_root: Path, provider: str, +) -> dict[str, Any]: + """Consume only destination-local protected input, remaining stopped.""" + return _stage_goal_transfer("coordination.planned_transfer.stage_protected", { + "protected_input": _protected_input(directory, private_key, expected_artifact_sha256), + }, project, runtime_root, provider) + + +def _stage_goal_transfer( + method: str, payload: dict[str, Any], project: Path, runtime_root: Path, provider: str, +) -> dict[str, Any]: project, runtime_root = project.expanduser().absolute(), runtime_root.expanduser().absolute() registry_path = project / ".loopx" / "registry.json" # Mechanical filesystem validation precedes even the registry lock's IO. @@ -108,9 +178,8 @@ def stage_prepared_goal_transfer( with project_registry_transaction(registry_path, operation="planned_transfer_stage_read", create=dict) as transaction: registry = transaction.payload_copy() witness = transaction.source_read_witness() if registry_path.exists() else {"path": str(registry_path), "sha256": None} - result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.stage_target", { - "source_preparation": source_preparation, "source_context": source_context, - "archive": str(archive.expanduser().absolute()), "target": target, + result: dict[str, Any] = effect_runtime_result(method, { + **payload, "target": target, "destination_registry": registry, "registry_witness": witness, "execute": True, }, timeout=300.0, retry_safe=False) # The typed Goal owner owns construction and conflict policy. The codec diff --git a/loopx/control_plane/goals/planned_transfer_payload.ts b/loopx/control_plane/goals/planned_transfer_payload.ts new file mode 100644 index 0000000000..82706889ab --- /dev/null +++ b/loopx/control_plane/goals/planned_transfer_payload.ts @@ -0,0 +1,112 @@ +/** Selected Goal bytes inside the draft encrypted custody profile. The native + * archive still owns Task/history semantics; this owner never recreates them. + * Decryption grants no admission, Host authority or credential adoption. */ +import {lstat} from "node:fs/promises"; +import {isAbsolute, join, relative, sep} from "node:path"; +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; +import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys} from "../coordination/authority_store_codec.ts"; +import {verifyAuthorityArchive} from "../coordination/authority_archive.ts"; +import {PREPARATION_SCHEMA, decodePlannedTransferMaterial, type ProtectedGoalMaterial} from "./planned_transfer.ts"; +import {inspectTransferFile, requirePhysicalTransferPath, withVerifiedTransferContainer, + validatePlannedTransferRecipient, writeTransferContainer, type TransferContainerFile} from "./planned_transfer_container.ts"; + +const PAYLOAD_SCHEMA = "loopx_planned_transfer_payload_v0"; +function reject(message: string): never { throw new EffectRuntimeConflictError(message); } +function same(a: unknown, b: unknown): boolean { return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b)); } +function beneath(root: string, path: string): boolean { + const suffix = relative(root, path); + return suffix === "" || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)); +} +function version(value: unknown): string { + if (typeof value !== "string" || !value.trim() || value.length > 128) reject("Transfer package version is unavailable"); + return value; +} +export interface VerifiedGoalTransferPayload { + preparation: JsonObject; context: JsonObject; archive: string; artifactSha256: string; + materials: ProtectedGoalMaterial[]; sourceVersion: string; +} + +/** Called only within source preparation's maintenance/context locks, after its + * original registry, native revision, complete archive and source fence check. + * Materials are an explicit reviewed selection, not a completeness/privacy + * certification. No machine root, registry inventory or Git metadata is swept. */ +export async function reviewProtectedGoalTransferInputs(raw: unknown, context: JsonObject, packageVersion: unknown) { + const transport = requireJsonObject(raw, "protected transfer request"); + if (!hasExactAuthorityKeys(transport, ["destination", "recipient", "materials"]) || !Array.isArray(transport.materials)) { + reject("Protected transfer requires an explicit recipient, destination and reviewed materials"); + } + const recipient = validatePlannedTransferRecipient(transport.recipient); + const destination = await requirePhysicalTransferPath(transport.destination), sourceVersion = version(packageVersion); + const goal = requireJsonObject(context.goal, "original Goal"); + const project = await requirePhysicalTransferPath(goal.repo), runtime = await requirePhysicalTransferPath(context.runtime_root); + const registry = await requirePhysicalTransferPath(context.registry_path); + const files: TransferContainerFile[] = []; + const materials: JsonObject[] = [], seen = new Set(); + for (const [index, rawMaterial] of transport.materials.entries()) { + const material = requireJsonObject(rawMaterial, "reviewed Goal material"); + if (!hasExactAuthorityKeys(material, ["path", "sha256"]) || !BARE_SHA256_PATTERN.test(String(material.sha256))) reject("Goal material requires its reviewed content digest"); + const path = await requirePhysicalTransferPath(material.path), suffix = relative(project, path); + if (!beneath(project, path) || path === project || beneath(runtime, path) || path === registry || + beneath(join(project, ".git"), path) || seen.has(path)) reject("Transfer material must be unique workspace input outside runtime, registry and Git metadata"); + if ((await lstat(path)).nlink !== 1) reject("Transfer material must not alias another file through a hard link"); + const facts = await inspectTransferFile(path); + if (facts.sha256 !== material.sha256) reject("Reviewed Goal material changed; review its current bytes before exporting"); + seen.add(path); files.push({path, ...facts}); + materials.push({id: (index + 2).toString(16).padStart(8, "0"), source_relative_path: suffix, + ...facts}); + } + return {recipient, destination, sourceVersion, files, materials}; +} + +export async function writeProtectedGoalTransfer(reviewed: Awaited>, + preparation: JsonObject, context: JsonObject, keyRecordPath: string): Promise { + if (preparation.original_registration_verified !== true) reject("Protected transfer requires the qualified original registered source"); + const archive = String(preparation.archive); + const metadata = {schema_version: PAYLOAD_SCHEMA, source_package_version: reviewed.sourceVersion, + source_preparation: preparation, source_context: context, materials: reviewed.materials}; + const result = await writeTransferContainer(reviewed.destination, reviewed.recipient, metadata, + [{path: archive, ...await inspectTransferFile(archive)}, ...reviewed.files], keyRecordPath); + return {...result, material_count: reviewed.materials.length, privacy_certified: false, + host_drain_qualified: false, portable_migration_qualified: false}; +} + +/** Authenticate every byte before inspecting the native archive or exposing + * temporary material paths to the stopped-stage owner. The caller must await + * this scope; plaintext is removed when it exits, including refusal/retry. */ +export async function withVerifiedGoalTransferPayload(value: JsonObject, + consume: (payload: VerifiedGoalTransferPayload) => Promise): Promise { + if (!hasExactAuthorityKeys(value, ["directory", "private_key_path", "expected_artifact_sha256", "target_package_version"]) || + !BARE_SHA256_PATTERN.test(String(value.expected_artifact_sha256))) reject("Protected transfer requires its reviewed artifact digest"); + const targetVersion = version(value.target_package_version); + const directory = await requirePhysicalTransferPath(value.directory), key = await requirePhysicalTransferPath(value.private_key_path); + return withVerifiedTransferContainer(directory, key, value.expected_artifact_sha256, async verified => { + const data = verified.metadata; + if (!hasExactAuthorityKeys(data, ["schema_version", "source_package_version", "source_preparation", "source_context", "materials"]) || + data.schema_version !== PAYLOAD_SCHEMA || version(data.source_package_version) !== targetVersion) { + reject("This draft transfer profile requires the same source and destination package version; use a qualified compatibility path"); + } + const preparation = requireJsonObject(data.source_preparation, "original transfer preparation"); + const context = requireJsonObject(data.source_context, "original transfer context"); + const goal = requireJsonObject(context.goal, "original Goal"); + if (preparation.schema_version !== PREPARATION_SCHEMA || preparation.original_registration_verified !== true || + preparation.authority_write_fenced !== true || preparation.execution_authority_granted !== false || + preparation.goal_id !== goal.id || preparation.source_context_sha256 !== canonicalAuthoritySha256(context)) reject("Original transfer source context is inconsistent"); + if (!hasExactAuthorityKeys(context, ["goal", "state_text", "runtime_root", "registry_path"]) || + typeof context.state_text !== "string" || !context.state_text.trim() || goal.goal_instance_id !== undefined) reject("Original Goal context requires its qualified lifecycle"); + const archive = verified.files.get("00000001")!; + const summary = await verifyAuthorityArchive(archive); + if (!same({...summary}, preparation.authority_archive) || summary.goal_id !== goal.id) reject("Protected transfer differs from its prepared original authority"); + if (!Array.isArray(data.materials) || data.materials.length !== verified.members.length - 1) reject("Protected transfer material inventory differs"); + const materials: ProtectedGoalMaterial[] = [], seen = new Set(); + for (const [index, rawMaterial] of data.materials.entries()) { + const material = decodePlannedTransferMaterial(rawMaterial, index), member = verified.members[index + 1]!; + if (seen.has(material.source_relative_path) || material.id !== member.id || material.sha256 !== member.sha256 || material.bytes !== member.bytes) reject("Protected material paths or original content identity changed"); + seen.add(material.source_relative_path); + materials.push({...material, path: verified.files.get(member.id)!}); + } + return consume({preparation, context, archive, materials, artifactSha256: verified.envelopeSha256, sourceVersion: targetVersion}); + }); +} diff --git a/tests/control_plane/test_planned_transfer_source_boundary.py b/tests/control_plane/test_planned_transfer_source_boundary.py index 1ae70fe5c6..23225c53d0 100644 --- a/tests/control_plane/test_planned_transfer_source_boundary.py +++ b/tests/control_plane/test_planned_transfer_source_boundary.py @@ -9,6 +9,7 @@ import hashlib import json +import shutil import subprocess import sys from pathlib import Path @@ -19,8 +20,9 @@ from loopx.control_plane.coordination.legacy_writer_fence import legacy_coordination_writer_fence_path from loopx.control_plane.coordination.local_authority_shadow_projection import canonical_bytes from loopx.control_plane.goals.planned_transfer_io import ( + create_planned_transfer_recipient, export_registered_goal_transfer, prepare_registered_goal_transfer, preview_registered_goal_transfer, - read_goal_transfer_context, stage_prepared_goal_transfer, + preview_protected_goal_transfer, read_goal_transfer_context, stage_protected_goal_transfer, ) from loopx.control_plane.projects.registry_codec import load_project_registry, project_registry_transaction from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime @@ -75,6 +77,7 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: "--task-lease-expected-version", str(lease["version"]), "--note", note, "--update-operation-id", operation, "--execute", expected_code=expected_code) + hidden_source: list[tuple[Path, Path]] = [] try: cli("bootstrap", "--project", str(project), "--goal-id", "report", "--objective", "Produce report.txt from input.txt", "--no-global-sync") @@ -214,6 +217,20 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: "expected_writer_fence_sha256": hashlib.sha256(canonical_bytes(original_fence)).hexdigest(), "registered_source_preview_sha256": preview["preview_sha256"], "execute": True, **read_goal_transfer_context(registry_path=global_registry, goal_id="report")} + recipient_root = tmp_path / "recipient" + recipient = create_planned_transfer_recipient(destination=recipient_root) + bundle = tmp_path / "source-export" + material_hash = hashlib.sha256((project / "input.txt").read_bytes()).hexdigest() + linked_input = project / "linked-input" + linked_input.symlink_to(project / "input.txt") + for invalid in [[(project / "input.txt", "0" * 64)], [(registry, material_hash)], + [(machine, material_hash)], [(project / ".git/config", material_hash)], + [(linked_input, material_hash)], [(project / "input.txt", material_hash)] * 2]: + with pytest.raises(EffectRuntimeRemoteError, match="material|linked ancestor"): + export_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move", + expected_preview_sha256=preview["preview_sha256"], recipient=recipient, destination=bundle, materials=invalid) + assert json.loads(legacy_coordination_writer_fence_path(runtime_root=runtime, goal_id="report").read_text()) == original_fence + assert not bundle.exists() prepared = prepare_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move", expected_preview_sha256=preview["preview_sha256"]) assert prepared["authority_write_fenced"] is True @@ -253,6 +270,40 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: original_goal = load_project_registry(registry)["goals"][0] source_state = project / original_goal["state_file"] context = request["source_context"] + exported = export_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move", + expected_preview_sha256=preview["preview_sha256"], recipient=recipient, destination=bundle, + materials=[(project / "input.txt", material_hash)]) + assert {key: value for key, value in exported.items() if key != "protected_artifact"} == prepared + protected = exported["protected_artifact"] + assert protected["material_count"] == 1 and protected["privacy_certified"] is False + assert protected["portable_migration_qualified"] is False + assert export_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move", + expected_preview_sha256=preview["preview_sha256"], recipient=recipient, destination=bundle, + materials=[(project / "input.txt", material_hash)]) == exported + incoming = tmp_path / "target-local-container" + shutil.copytree(bundle, incoming) + shutil.rmtree(bundle) + source_state_bytes = source_state.read_bytes() + source_task = cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"] + # The target has no shared source registry, archive, private export key, + # original material or workspace. Only the encrypted directory travels. + restart_effect_runtime(wait_for_exit=True) + for path in (project, runtime): + hidden = path.with_name(path.name + "-source-unavailable") + path.rename(hidden) + hidden_source.append((path, hidden)) + protected_input = dict(directory=incoming, private_key=recipient_root / "private-key.pem", + expected_artifact_sha256=protected["artifact_sha256"]) + observed = preview_protected_goal_transfer(**protected_input) + assert observed["goal_id"] == "report" and observed["display_name"] == "Current original Goal" + assert observed["original_authority"] == prepared["authority_archive"] + assert observed["execution_authority_granted"] is False + assert observed["materials"] == [{"id": "00000002", "source_relative_path": "input.txt", "sha256": material_hash, "bytes": 6}] + with pytest.raises(EffectRuntimeRemoteError, match="same source and destination package version"): + effect_runtime_result("coordination.planned_transfer.preview_protected", { + "directory": str(incoming), "private_key_path": str(recipient_root / "private-key.pem"), + "expected_artifact_sha256": protected["artifact_sha256"], "target_package_version": "incompatible"}, + timeout=60, retry_safe=False) for target_provider in ("file", "sqlite"): destination = tmp_path / f"target-{target_provider}" target_project, target_runtime = destination / "project", destination / "runtime" @@ -260,8 +311,7 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: if target_provider == "sqlite": target_registry.parent.mkdir(parents=True) target_registry.write_text(json.dumps(_strict({"schema_version": "0.1", "common_runtime_root": str(target_runtime), "goals": []}))) - request = dict(source_preparation=prepared, source_context=context, - archive=Path(prepared["archive"]), project=target_project, runtime_root=target_runtime, provider=target_provider) + request = dict(**protected_input, project=target_project, runtime_root=target_runtime, provider=target_provider) if target_provider == "file": # Inject a real registry edit after native staging but before # codec publication. The owning CAS must preserve it, and the @@ -280,14 +330,15 @@ def concurrent_registry_edit(method, payload, **kwargs): with monkeypatch.context() as interrupted: interrupted.setattr(planned_transfer_io, "effect_runtime_result", concurrent_registry_edit) with pytest.raises(ValueError, match="registry changed before publication"): - stage_prepared_goal_transfer(**request) + stage_protected_goal_transfer(**request) concurrent = load_project_registry(target_registry) assert concurrent["retained_local_note"] == "An unrelated edit must survive transfer recovery" assert concurrent.get("goals", []) == [] - staged = stage_prepared_goal_transfer(**request) + staged = stage_protected_goal_transfer(**request) assert staged["execution_authority_granted"] is False assert staged["target_is_active"] is False and staged["fresh_admission_required"] is True assert staged["authority_readback"]["status"] == "matched" + assert staged["material_readback_verified"] is True and staged["original_material_count"] == 1 target_goal = load_project_registry(target_registry)["goals"][0] if target_provider == "sqlite": assert json.loads(target_registry.read_text())[0]["minimum_writer_protocol"] == "goal_instance_v1" @@ -298,7 +349,11 @@ def concurrent_registry_edit(method, payload, **kwargs): assert "creation_operation_id" not in target_goal assert "storage_target" not in target_goal["coordination"] assert json.loads(Path(staged["original_context_path"]).read_text()) == context - assert (target_project / target_goal["state_file"]).read_bytes() == source_state.read_bytes() + assert (target_project / target_goal["state_file"]).read_bytes() == source_state_bytes + retained_material = (target_project / target_goal["state_file"]).parent / "transfer-materials/00000002" + assert retained_material.read_bytes() == b"6\n7\n8\n" + manifest = json.loads(Path(staged["original_material_manifest"]).read_text()) + assert manifest == {"artifact_sha256": protected["artifact_sha256"], "materials": observed["materials"]} def target_cli(*arguments: str, expected_code: int = 0) -> dict: result = subprocess.run([sys.executable, "-I", "-m", "loopx.cli", "--format", "json", @@ -308,7 +363,7 @@ def target_cli(*arguments: str, expected_code: int = 0) -> dict: return json.loads(result.stdout) target_task = target_cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"] - assert target_task == cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"] + assert target_task == source_task # The existing ordinary reconnect may read this registered Goal; # it must not mint a creation receipt or reset imported work to an # empty new Goal, even if a caller supplies creation-style inputs. @@ -327,10 +382,25 @@ def target_cli(*arguments: str, expected_code: int = 0) -> dict: "--write-scope", "report.txt", "--write-worktree", str(worktree), expected_code=1)["ok"] is False restart_effect_runtime(wait_for_exit=True) original_registry = target_registry.read_bytes() - assert stage_prepared_goal_transfer(**request) == staged + assert stage_protected_goal_transfer(**request) == staged assert target_registry.read_bytes() == original_registry assert target_cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"] == target_task - assert inspect()["provider_revision"] == released["provider_revision"] - assert cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"]["note"] == retained["note"] + retained_material.write_bytes(b"Later destination work must survive") + with pytest.raises(EffectRuntimeRemoteError, match="material is occupied or changed"): + stage_protected_goal_transfer(**request) + assert retained_material.read_bytes() == b"Later destination work must survive" + assert target_registry.read_bytes() == original_registry + retained_material.unlink() + with pytest.raises(EffectRuntimeRemoteError, match="material is missing"): + stage_protected_goal_transfer(**request) + assert not retained_material.exists() + assert target_registry.read_bytes() == original_registry + for path, hidden in reversed(hidden_source): + hidden.rename(path) + hidden_source.clear() + assert inspect()["provider_revision"] == released["provider_revision"] + assert cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"]["note"] == retained["note"] finally: restart_effect_runtime(wait_for_exit=True) + for path, hidden in reversed(hidden_source): + hidden.rename(path) diff --git a/tests/control_plane_ts/planned_transfer_container.test.ts b/tests/control_plane_ts/planned_transfer_container.test.ts new file mode 100644 index 0000000000..d4eeb20540 --- /dev/null +++ b/tests/control_plane_ts/planned_transfer_container.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import test, {type TestContext} from "node:test"; +import {chmod, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, truncate, writeFile} from "node:fs/promises"; +import {join} from "node:path"; +import {tmpdir} from "node:os"; +import {createHash} from "node:crypto"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {createPlannedTransferRecipient, inspectTransferFile, validatePlannedTransferRecipient, + withVerifiedTransferContainer, writeTransferContainer} from "../../loopx/control_plane/goals/planned_transfer_container.ts"; + +async function fixture(t: TestContext) { + const root = await realpath(await mkdtemp(join(tmpdir(), "loopx-encrypted-transfer-test-"))); + t.after(() => rm(root, {recursive: true, force: true})); + const recipientRoot = join(root, "recipient"), bundle = join(root, "bundle"), keyRecord = join(root, "source-key.json"); + const descriptor = await createPlannedTransferRecipient({destination: recipientRoot, execute: true}); + const privateKey = join(recipientRoot, "private-key.pem"), source = join(root, "material"); + const content = Buffer.from("Original unfinished work and private source path\n".repeat(6000)); + await writeFile(source, content); + const inputs = [{path: source, ...await inspectTransferFile(source)}]; + const metadata = {goal: "original-private-goal", source_path: source, constraints: "Retain the original decision"}; + const exported = await writeTransferContainer(bundle, descriptor, metadata, inputs, keyRecord); + return {root, recipientRoot, descriptor, privateKey, source, content, inputs, metadata, bundle, keyRecord, exported}; +} + +test("encrypted bytes are copyable without source paths or either private key; awaited readback owns plaintext lifetime", async t => { + const f = await fixture(t), target = join(f.root, "destination-copy"); + assert.deepEqual((await readdir(f.bundle)).sort(), ["00000000.enc", "00000001.enc", "envelope.json"]); + for (const name of await readdir(f.bundle)) { + const bytes = await readFile(join(f.bundle, name)); + for (const secret of [f.source, f.metadata.goal, f.metadata.constraints, "PRIVATE KEY", "content_key_hex"]) { + assert.equal(bytes.includes(Buffer.from(secret)), false, "plaintext must stay outside the transported directory"); + } + } + const keyBytes = await readFile(f.privateKey), sourceKeyBytes = await readFile(f.keyRecord); + assert.equal((await stat(f.privateKey)).mode & 0o077, 0); + assert.equal((await stat(f.keyRecord)).mode & 0o077, 0); + await cp(f.bundle, target, {recursive: true}); + await rm(f.source); await rm(f.bundle, {recursive: true}); await rm(f.keyRecord); + let plaintextPath = ""; + await withVerifiedTransferContainer(target, f.privateKey, f.exported.artifact_sha256, async verified => { + assert.deepEqual(verified.metadata, f.metadata); plaintextPath = verified.files.get("00000001")!; + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(await readFile(plaintextPath), f.content); + assert.equal((await lstat(plaintextPath)).mode & 0o077, 0); + }); + await assert.rejects(stat(plaintextPath), {code: "ENOENT"}); + assert.deepEqual(await readFile(f.privateKey), keyBytes); + assert.equal(sourceKeyBytes.includes(Buffer.from("content_key_hex")), true); +}); + +test("recipient retry retains its original key and incomplete or insecure custody is refused", async t => { + const f = await fixture(t), before = await readFile(f.privateKey); + assert.deepEqual(await createPlannedTransferRecipient({destination: f.recipientRoot, execute: true}), f.descriptor); + assert.deepEqual(await readFile(f.privateKey), before); + assert.throws(() => validatePlannedTransferRecipient({...f.descriptor, public_key_pem: before.toString()}), /format changed/); + await chmod(f.privateKey, 0o644); + await assert.rejects(withVerifiedTransferContainer(f.bundle, f.privateKey, f.exported.artifact_sha256, async () => null), /private ownership/); + await chmod(f.privateKey, 0o600); + await rm(f.privateKey); + await assert.rejects(createPlannedTransferRecipient({destination: f.recipientRoot, execute: true}), /incomplete/); + assert.equal((await readdir(f.recipientRoot)).includes("private-key.pem"), false); +}); + +test("wrong recipients, linked key paths and wrong reviewed digests never reach the consumer", async t => { + const f = await fixture(t), other = join(f.root, "other-recipient"); + await createPlannedTransferRecipient({destination: other, execute: true}); + let calls = 0; + const consume = async () => { calls++; return null; }; + await assert.rejects(withVerifiedTransferContainer(f.bundle, join(other, "private-key.pem"), f.exported.artifact_sha256, consume), /different recipient/); + const linked = join(f.root, "linked-key"); await symlink(f.privateKey, linked); + await assert.rejects(withVerifiedTransferContainer(f.bundle, linked, f.exported.artifact_sha256, consume), /linked ancestor/); + await assert.rejects(withVerifiedTransferContainer(f.bundle, f.privateKey, "0".repeat(64), consume), /reviewed container changed/); + assert.equal(calls, 0); +}); + +test("tamper, member substitution and future formats fail before any plaintext consumer or target effect", async t => { + const f = await fixture(t); + const other = join(f.root, "other-bundle"); + await writeTransferContainer(other, f.descriptor, {...f.metadata, goal: "different original Goal"}, f.inputs, join(f.root, "other-key.json")); + const otherHeader = JSON.parse(await readFile(join(other, "envelope.json"), "utf8")); + const scenarios: [string, (directory: string, header: JsonObject) => Promise][] = [ + ["ciphertext", async (directory, header) => { + const name = join(directory, "00000001.enc"), bytes = await readFile(name); bytes[0] ^= 1; await writeFile(name, bytes); + (header.entries as JsonObject[])[1]!.sha256 = createHash("sha256").update(bytes).digest("hex"); + }], + ["authentication tag", async (_directory, header) => { (header.entries as JsonObject[])[1]!.tag = "00".repeat(16); }], + ["member from another transfer", async (directory, header) => { + await writeFile(join(directory, "00000001.enc"), await readFile(join(other, "00000001.enc"))); + (header.entries as JsonObject[])[1] = otherHeader.entries[1]; + }], + ["wrapped key", async (_directory, header) => { header.wrapped_key_base64 = Buffer.alloc(384).toString("base64"); }], + ["source identity", async (_directory, header) => { header.artifact_id = "11".repeat(32); }], + ["duplicate nonce", async (_directory, header) => { (header.entries as JsonObject[])[1]!.nonce = (header.entries as JsonObject[])[0]!.nonce; }], + ["future format", async (_directory, header) => { header.schema_version = "unsupported_future_transfer"; }], + ["member order", async (_directory, header) => { (header.entries as JsonObject[]).reverse(); }], + ["extra file", async directory => { await writeFile(join(directory, "private-leak.txt"), "must refuse"); }], + ["missing file", async directory => { await rm(join(directory, "00000001.enc")); }], + ["linked member", async directory => { + await rm(join(directory, "00000001.enc")); await symlink(join(f.bundle, "00000001.enc"), join(directory, "00000001.enc")); + }], + ]; + for (const [index, [name, corrupt]] of scenarios.entries()) { + await t.test(name, async () => { + const directory = join(f.root, `corrupt-${index}`); await cp(f.bundle, directory, {recursive: true}); + const header = JSON.parse(await readFile(join(directory, "envelope.json"), "utf8")); + await corrupt(directory, header); await writeFile(join(directory, "envelope.json"), JSON.stringify(header)); + let invoked = false; + await assert.rejects(withVerifiedTransferContainer(directory, f.privateKey, canonicalAuthoritySha256(header), async () => { invoked = true; })); + assert.equal(invoked, false); + }); + } +}); + +test("source mutation, occupied outputs and lost custody never overwrite or recreate an exported operation", async t => { + const f = await fixture(t), initialHeader = await readFile(join(f.bundle, "envelope.json")); + const occupied = join(f.root, "occupied"), unusedKey = join(f.root, "unused-key.json"); + await writeFile(occupied, "Retain this destination owner's work"); + await assert.rejects(writeTransferContainer(occupied, f.descriptor, f.metadata, f.inputs, unusedKey), /content key is missing/); + assert.equal(await readFile(occupied, "utf8"), "Retain this destination owner's work"); + await assert.rejects(stat(unusedKey), {code: "ENOENT"}); + assert.deepEqual(await writeTransferContainer(f.bundle, f.descriptor, f.metadata, f.inputs, f.keyRecord), f.exported); + await assert.rejects(writeTransferContainer(f.bundle, f.descriptor, {...f.metadata, constraints: "changed"}, f.inputs, f.keyRecord), /different reviewed inputs/); + assert.deepEqual(await readFile(join(f.bundle, "envelope.json")), initialHeader); + await rm(join(f.bundle, "00000001.enc")); + await assert.rejects(writeTransferContainer(f.bundle, f.descriptor, f.metadata, f.inputs, f.keyRecord), /missing or unexpected/); + await assert.rejects(stat(join(f.bundle, "00000001.enc")), {code: "ENOENT"}); + await rm(f.keyRecord); + await assert.rejects(writeTransferContainer(f.bundle, f.descriptor, f.metadata, f.inputs, f.keyRecord), /content key is missing/); + await assert.rejects(stat(f.keyRecord), {code: "ENOENT"}); + const newOutput = join(f.root, "new-output"), newKey = join(f.root, "new-source-key.json"); + await writeFile(f.source, "different material bytes"); + await assert.rejects(writeTransferContainer(newOutput, f.descriptor, f.metadata, f.inputs, newKey), /material changed|capacity/); + await assert.rejects(stat(newOutput), {code: "ENOENT"}); + await writeFile(f.source, Buffer.concat([f.content, Buffer.from("material grew after review")])); + await assert.rejects(writeTransferContainer(newOutput, f.descriptor, f.metadata, f.inputs, newKey), /material changed|capacity/); + await assert.rejects(stat(newOutput), {code: "ENOENT"}); + await writeFile(f.source, f.content); + await writeTransferContainer(newOutput, f.descriptor, f.metadata, f.inputs, newKey); + const sparse = join(f.root, "oversized"); await writeFile(sparse, ""); await truncate(sparse, 128 * 1024 * 1024 + 1); + await assert.rejects(inspectTransferFile(sparse), /bounded regular file/); +}); From 5a054f8aa35c05e6fc378d6ebb1de18c694243d3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:06:18 +0800 Subject: [PATCH 05/55] feat(goals): stage reviewed transfer workspace materials Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../coordination/planned_transfer_target.ts | 81 +++++++--- .../control_plane/effect_runtime_handlers.ts | 1 + loopx/control_plane/goals/planned_transfer.ts | 11 +- .../goals/planned_transfer_io.py | 54 +++++-- .../goals/planned_transfer_payload.ts | 1 + .../goals/planned_transfer_workspace.ts | 141 ++++++++++++++++++ .../test_planned_transfer_source_boundary.py | 63 +++++++- .../planned_transfer_workspace.test.ts | 107 +++++++++++++ 8 files changed, 423 insertions(+), 36 deletions(-) create mode 100644 loopx/control_plane/goals/planned_transfer_workspace.ts create mode 100644 tests/control_plane_ts/planned_transfer_workspace.test.ts diff --git a/loopx/control_plane/coordination/planned_transfer_target.ts b/loopx/control_plane/coordination/planned_transfer_target.ts index aff4f9ed3c..74ac39aba4 100644 --- a/loopx/control_plane/coordination/planned_transfer_target.ts +++ b/loopx/control_plane/coordination/planned_transfer_target.ts @@ -9,7 +9,9 @@ import {durableWriteJson, atomicWriteText, withFileMutationLock} from "../effect import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; import {requireJsonObject} from "../runtime_decode.ts"; import {planPreparedGoalStage, stagedGoalRegistry, type PreparedGoalStage} from "../goals/planned_transfer.ts"; -import {withVerifiedGoalTransferPayload} from "../goals/planned_transfer_payload.ts"; +import {withVerifiedGoalTransferPayload, type VerifiedGoalTransferPayload} from "../goals/planned_transfer_payload.ts"; +import {readGoalTransferWorkspaceReview, retainGoalTransferWorkspace, reviewGoalTransferWorkspace, + verifyGoalTransferWorkspaceReview} from "../goals/planned_transfer_workspace.ts"; import {inspectTransferFile, requirePhysicalTransferPath} from "../goals/planned_transfer_container.ts"; import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys} from "./authority_store_codec.ts"; import {verifyAuthorityArchive, restoreAuthorityArchive} from "./authority_archive.ts"; @@ -45,6 +47,46 @@ async function physicalAncestors(path: string): Promise { } } +async function verifyDestinationRegistry(target: JsonObject, witness: JsonObject): Promise { + if (!hasExactAuthorityKeys(witness, ["path", "sha256"]) || witness.path !== target.registry_path || + (witness.sha256 !== null && typeof witness.sha256 !== "string")) reject("Invalid destination registry witness"); + const bytes = await optionalBytes(String(witness.path)); + const digest = bytes === null ? null : createHash("sha256").update(bytes).digest("hex"); + if (digest !== witness.sha256) reject("Destination registry changed; retry through its original codec"); +} +function targetOperationDirectory(root: string, plan: PreparedGoalStage): string { + return join(shadowManagementDirectory(root, plan.goalId), "planned-transfer-targets", canonicalAuthoritySha256(plan.operationId)); +} +function protectedStageInput(value: JsonObject, payload: VerifiedGoalTransferPayload): JsonObject { + return {source_preparation: payload.preparation, source_context: payload.context, archive: payload.archive, + target: value.target, destination_registry: value.destination_registry, registry_witness: value.registry_witness, + execute: true, protected_transport: {artifact_sha256: payload.artifactSha256, materials: payload.materials}, + ...(Object.hasOwn(value, "workspace_mapping") ? {workspace_mapping: value.workspace_mapping, + expected_workspace_preview_sha256: value.expected_workspace_preview_sha256} : {})}; +} + +/** A live target read, not a snapshot or grant. Execution rechecks it under + * native staging's locks; exclusive file creation also protects outside edits. */ +export async function previewProtectedGoalTransferTarget(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["protected_input", "target", "destination_registry", "registry_witness", "workspace_mapping"])) reject("Target preview requires the complete reviewed destination"); + return withVerifiedGoalTransferPayload(requireJsonObject(value.protected_input, "protected input"), async payload => { + const plan = planPreparedGoalStage({operation_id: payload.preparation.operation_id, source_context: payload.context, + archive_sha256: requireJsonObject(payload.preparation.authority_archive, "original authority").archive_sha256, + target: value.target, destination_registry: value.destination_registry, + protected_transport: {artifact_sha256: payload.artifactSha256, materials: payload.materials}, workspace_mapping: value.workspace_mapping}); + const target = requireJsonObject(value.target, "destination"), witness = requireJsonObject(value.registry_witness, "destination witness"); + await physicalAncestors(String(target.runtime_root)); await physicalAncestors(String(target.project)); + await verifyDestinationRegistry(target, witness); + const review = await reviewGoalTransferWorkspace(plan.workspaceMaterials!, payload.artifactSha256, target, witness); + const reviewed = await readGoalTransferWorkspaceReview(targetOperationDirectory(String(target.runtime_root), plan), plan.identity); + await verifyDestinationRegistry(target, witness); + const {ready, ...facts} = review; + return {...facts, ready_for_workspace_staging: ready, goal_id: plan.goalId, workspace_destinations: plan.workspaceMaterials!.destinations, + reviewed_preview_sha256: reviewed ?? review.preview_sha256, resuming_original_operation: reviewed !== null, + target_is_active: false, fresh_admission_required: true, execution_authority_granted: false}; + }); +} + async function retainProtectedMaterials(plan: PreparedGoalStage, pending: boolean, completed: boolean, execute: boolean): Promise { const artifact = plan.protectedArtifact; if (!artifact) return; @@ -100,13 +142,12 @@ export async function previewProtectedGoalTransfer(value: JsonObject): Promise { - if (!hasExactAuthorityKeys(value, ["protected_input", "target", "destination_registry", "registry_witness", "execute"]) || + const workspace = Object.hasOwn(value, "workspace_mapping"); + if (!hasExactAuthorityKeys(value, ["protected_input", "target", "destination_registry", "registry_witness", "execute", + ...(workspace ? ["workspace_mapping", "expected_workspace_preview_sha256"] : [])]) || value.execute !== true) reject("Protected staging requires an explicit destination operation"); return withVerifiedGoalTransferPayload(requireJsonObject(value.protected_input, "protected transfer input"), async payload => - stageAuthorityForPlannedTransfer({source_preparation: payload.preparation, source_context: payload.context, - archive: payload.archive, target: value.target, destination_registry: value.destination_registry, - registry_witness: value.registry_witness, execute: true, - protected_transport: {artifact_sha256: payload.artifactSha256, materials: payload.materials}})); + stageAuthorityForPlannedTransfer(protectedStageInput(value, payload))); } /** Maintenance/source/registry lock order matches the existing canonical owner. @@ -115,8 +156,10 @@ export async function stageProtectedGoalTransfer(value: JsonObject): Promise { const protectedInput = value.protected_transport; + const workspaceRequested = Object.hasOwn(value, "workspace_mapping"); if (!hasExactAuthorityKeys(value, ["source_preparation", "source_context", "archive", "target", "destination_registry", "registry_witness", "execute", - ...(protectedInput === undefined ? [] : ["protected_transport"])]) || + ...(protectedInput === undefined ? [] : ["protected_transport"]), + ...(workspaceRequested ? ["workspace_mapping", "expected_workspace_preview_sha256"] : [])]) || value.execute !== true || typeof value.archive !== "string") reject("Explicit complete Goal transfer staging is required"); const prepared = requireJsonObject(value.source_preparation, "prepared original authority"); if (prepared.schema_version !== PREPARATION_SCHEMA || prepared.authority_write_fenced !== true || @@ -124,17 +167,12 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi const summary = requireJsonObject(prepared.authority_archive, "prepared authority summary"); const plan = planPreparedGoalStage({operation_id: prepared.operation_id, source_context: value.source_context, archive_sha256: summary.archive_sha256, target: value.target, destination_registry: value.destination_registry, - ...(protectedInput === undefined ? {} : {protected_transport: protectedInput})}); + ...(protectedInput === undefined ? {} : {protected_transport: protectedInput}), + ...(workspaceRequested ? {workspace_mapping: value.workspace_mapping} : {})}); if (prepared.goal_id !== plan.goalId || prepared.source_context_sha256 !== canonicalAuthoritySha256(plan.context)) reject("Prepared source and original Goal context differ"); const target = value.target as JsonObject, root = target.runtime_root as string; const witness = requireJsonObject(value.registry_witness, "codec-validated destination witness"); - if (!hasExactAuthorityKeys(witness, ["path", "sha256"]) || witness.path !== target.registry_path || - (witness.sha256 !== null && typeof witness.sha256 !== "string")) reject("Invalid destination registry witness"); - const verifyRegistry = async () => { - const bytes = await optionalBytes(String(witness.path)); - const digest = bytes === null ? null : createHash("sha256").update(bytes).digest("hex"); - if (digest !== witness.sha256) reject("Destination registry changed; retry staging through its original codec"); - }; + const verifyRegistry = () => verifyDestinationRegistry(target, witness); await verifyRegistry(); await physicalAncestors(root); await physicalAncestors(String(target.project)); const archive = await verifyAuthorityArchive(value.archive); @@ -145,7 +183,7 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi await verifyRegistry(); const managed = await readShadowManagementState(root, plan.goalId); if (managed && managed.status !== "inactive") reject("Destination shadow capture requires its original owner"); - const directory = join(shadowManagementDirectory(root, plan.goalId), "planned-transfer-targets", canonicalAuthoritySha256(plan.operationId)); + const directory = targetOperationDirectory(root, plan); await physicalAncestors(directory); await physicalAncestors(plan.statePath); await physicalAncestors(plan.contextPath); const pendingPath = join(directory, "preparation.json"); const pending = await optionalBytes(pendingPath); @@ -195,6 +233,11 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi if ((originalState && !originalState.equals(Buffer.from(String(plan.context.state_text)))) || (originalContext && !same(JSON.parse(originalContext.toString("utf8")), plan.context))) reject("Destination Goal material is occupied or changed"); await retainProtectedMaterials(plan, pending !== null, completed !== null, false); + if (plan.workspaceMaterials) { + const review = await reviewGoalTransferWorkspace(plan.workspaceMaterials, plan.protectedArtifact!.sha256, target, witness); + await verifyGoalTransferWorkspaceReview(plan.workspaceMaterials, review, directory, plan.identity, + value.expected_workspace_preview_sha256, completed !== null); + } await mkdir(directory, {recursive: true, mode: 0o700}); await retainJson(pendingPath, plan.identity); const engaged = await engageLegacyCoordinationWriterFenceUnderLocks(root, plan.goalId, plan.statePath, fence); @@ -203,6 +246,8 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi if (!originalState) await atomicWriteText(plan.statePath, String(plan.context.state_text)); await retainJson(plan.contextPath, plan.context); await retainProtectedMaterials(plan, pending !== null, completed !== null, true); + if (plan.workspaceMaterials) await retainGoalTransferWorkspace(plan.workspaceMaterials, directory, plan.identity, + String(value.expected_workspace_preview_sha256)); const Store = target.provider === "file" ? FileAuthorityStore : SqliteAuthorityStore; const store = new Store(paths[target.provider as "file" | "sqlite"], plan.goalId); const identity = await store.storeIdentity(); @@ -218,7 +263,9 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi target_is_active: false, fresh_admission_required: true, execution_authority_granted: false, ...(plan.protectedArtifact ? {artifact_sha256: plan.protectedArtifact.sha256, original_material_count: plan.protectedArtifact.materials.length, material_readback_verified: true, - original_material_manifest: plan.protectedArtifact.manifestPath} : {})}; + original_material_manifest: plan.protectedArtifact.manifestPath} : {}), + ...(plan.workspaceMaterials ? {workspace_materials_readback_verified: true, + workspace_destinations: plan.workspaceMaterials.destinations} : {})}; await retainJson(join(directory, "receipt.json"), result); return {...result, destination_registry: stagedGoalRegistry(value.destination_registry as JsonObject, plan, root), original_context_path: plan.contextPath}; diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 60b81fd429..96e9bfbc7b 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -421,6 +421,7 @@ export function createEffectRuntimeHandlers( ["coordination.planned_transfer.stage_target", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({stageAuthorityForPlannedTransfer}) => stageAuthorityForPlannedTransfer)], ["coordination.planned_transfer.create_recipient", lazyHandler(() => import("./goals/planned_transfer_container.ts"), ({createPlannedTransferRecipient}) => createPlannedTransferRecipient)], ["coordination.planned_transfer.preview_protected", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({previewProtectedGoalTransfer}) => previewProtectedGoalTransfer)], + ["coordination.planned_transfer.preview_target", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({previewProtectedGoalTransferTarget}) => previewProtectedGoalTransferTarget)], ["coordination.planned_transfer.stage_protected", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({stageProtectedGoalTransfer}) => stageProtectedGoalTransfer)], ["configuration.backup", lazyHandler(() => import("./configuration_backup.ts"), ({configurationBackupOperation}) => configurationBackupOperation)], ["runtime.checkpoint_artifacts", lazyHandler(() => import("./runtime/checkpoint_artifacts.ts"), ({checkpointArtifacts}) => checkpointArtifacts)], diff --git a/loopx/control_plane/goals/planned_transfer.ts b/loopx/control_plane/goals/planned_transfer.ts index 46843b37c8..781691c430 100644 --- a/loopx/control_plane/goals/planned_transfer.ts +++ b/loopx/control_plane/goals/planned_transfer.ts @@ -17,6 +17,7 @@ import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import {withFileMutationLock} from "../effect_runtime_io.ts"; import {legacyCoordinationTodoLockPath} from "../coordination/legacy_writer_lock_paths.ts"; +import {planGoalTransferWorkspace, type GoalTransferWorkspacePlan} from "./planned_transfer_workspace.ts"; export const PLANNED_TRANSFER_GOAL_STAGE_SCHEMA = "loopx_planned_transfer_goal_stage_v0"; export const PREPARATION_SCHEMA = "loopx_planned_transfer_authority_preparation_v0"; @@ -63,6 +64,7 @@ export interface PreparedGoalStage { contextPath: string; identity: JsonObject; protectedArtifact?: {sha256: string; materials: ProtectedGoalMaterial[]; manifest: JsonObject; manifestPath: string}; + workspaceMaterials?: GoalTransferWorkspacePlan; } /** Capture Goal intent through its actual registered source while the caller @@ -111,8 +113,10 @@ export async function withVerifiedGoalTransferContext(raw: unknown, rawWitnes * This decision never serializes or mutates a project registry itself. */ export function planPreparedGoalStage(value: JsonObject): PreparedGoalStage { const protectedInput = value.protected_transport === undefined ? undefined : requireJsonObject(value.protected_transport, "verified protected transport"); + const workspaceRequested = Object.hasOwn(value, "workspace_mapping"); if (!hasExactAuthorityKeys(value, ["operation_id", "source_context", "archive_sha256", "target", "destination_registry", - ...(protectedInput ? ["protected_transport"] : [])])) { + ...(protectedInput ? ["protected_transport"] : []), ...(workspaceRequested ? ["workspace_mapping"] : [])]) || + (workspaceRequested && !protectedInput)) { reject("Goal transfer staging requires the complete original context and destination observation"); } const operation = requireAuthorityStoreId(value.operation_id, "original transfer operation"); @@ -139,6 +143,7 @@ export function planPreparedGoalStage(value: JsonObject): PreparedGoalStage { const directory = join(project, ".loopx", "goals", goalId); const statePath = join(directory, "ACTIVE_GOAL_STATE.md"), contextPath = join(directory, "TRANSFER_CONTEXT.json"); let protectedArtifact: PreparedGoalStage["protectedArtifact"]; + let workspaceMaterials: GoalTransferWorkspacePlan | undefined; if (protectedInput) { if (!hasExactAuthorityKeys(protectedInput, ["artifact_sha256", "materials"]) || !BARE_SHA256_PATTERN.test(String(protectedInput.artifact_sha256)) || !Array.isArray(protectedInput.materials)) reject("Protected staging requires its authenticated material inventory"); @@ -153,8 +158,10 @@ export function planPreparedGoalStage(value: JsonObject): PreparedGoalStage { }); const manifest = {artifact_sha256: protectedInput.artifact_sha256, materials: materials.map(({path: _path, ...row}) => row)}; + if (workspaceRequested) workspaceMaterials = planGoalTransferWorkspace(project, root, materials, value.workspace_mapping); protectedArtifact = {sha256: String(protectedInput.artifact_sha256), materials, manifest, manifestPath: join(directory, "TRANSFER_MATERIALS.json")}; + if (workspaceMaterials) protectedArtifact.manifest = {...manifest, workspace_destinations: workspaceMaterials.destinations}; } const identity = {schema_version: PLANNED_TRANSFER_GOAL_STAGE_SCHEMA, goal_id: goalId, operation_id: operation, source_context_sha256: canonicalAuthoritySha256(context), archive_sha256: value.archive_sha256, target, @@ -182,7 +189,7 @@ export function planPreparedGoalStage(value: JsonObject): PreparedGoalStage { if (matches.length > 1 || (matches.length === 1 && !canonicalAuthorityBytes(matches[0]).equals(canonicalAuthorityBytes(goal)))) { reject("Destination Goal is occupied or its staged registration changed; preserve it and recover the original transfer"); } - return {goalId, operationId: operation, context, goal, statePath, contextPath, identity, protectedArtifact}; + return {goalId, operationId: operation, context, goal, statePath, contextPath, identity, protectedArtifact, workspaceMaterials}; } export function stagedGoalRegistry(registry: JsonObject, plan: PreparedGoalStage, runtime: string): JsonObject { diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index f56bd950cc..3fe20b380c 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -156,28 +156,36 @@ def stage_prepared_goal_transfer( def stage_protected_goal_transfer( *, directory: Path, private_key: Path, expected_artifact_sha256: str, project: Path, runtime_root: Path, provider: str, + expected_workspace_preview_sha256: str | None = None, + material_destinations: dict[str, str] | None = None, ) -> dict[str, Any]: """Consume only destination-local protected input, remaining stopped.""" - return _stage_goal_transfer("coordination.planned_transfer.stage_protected", { + payload: dict[str, Any] = {"protected_input": _protected_input(directory, private_key, expected_artifact_sha256)} + if expected_workspace_preview_sha256 is not None or material_destinations is not None: + payload.update(workspace_mapping=material_destinations, + expected_workspace_preview_sha256=expected_workspace_preview_sha256) + return _stage_goal_transfer("coordination.planned_transfer.stage_protected", payload, project, runtime_root, provider) + + +def preview_protected_goal_transfer_target( + *, directory: Path, private_key: Path, expected_artifact_sha256: str, + project: Path, runtime_root: Path, provider: str, + material_destinations: dict[str, str] | None = None, +) -> dict[str, Any]: + """Review actual target material paths; no authority or execution is admitted.""" + _, target, registry, witness = _read_transfer_destination(project, runtime_root, provider) + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.preview_target", { "protected_input": _protected_input(directory, private_key, expected_artifact_sha256), - }, project, runtime_root, provider) + "target": target, "destination_registry": registry, "registry_witness": witness, + "workspace_mapping": material_destinations, + }, timeout=300.0, retry_safe=True) + return result def _stage_goal_transfer( method: str, payload: dict[str, Any], project: Path, runtime_root: Path, provider: str, ) -> dict[str, Any]: - project, runtime_root = project.expanduser().absolute(), runtime_root.expanduser().absolute() - registry_path = project / ".loopx" / "registry.json" - # Mechanical filesystem validation precedes even the registry lock's IO. - # Do not follow a caller's linked destination into another project. - for path in (registry_path, runtime_root): - if path.resolve() != path: - raise ValueError("Planned transfer destination must use a physical normalized path") - target = {"runtime_root": str(runtime_root), "project": str(project), - "registry_path": str(registry_path), "provider": provider} - with project_registry_transaction(registry_path, operation="planned_transfer_stage_read", create=dict) as transaction: - registry = transaction.payload_copy() - witness = transaction.source_read_witness() if registry_path.exists() else {"path": str(registry_path), "sha256": None} + registry_path, target, registry, witness = _read_transfer_destination(project, runtime_root, provider) result: dict[str, Any] = effect_runtime_result(method, { **payload, "target": target, "destination_registry": registry, "registry_witness": witness, "execute": True, @@ -194,6 +202,24 @@ def _stage_goal_transfer( return {**result, "registry_readback_verified": True} +def _read_transfer_destination( + project: Path, runtime_root: Path, provider: str, +) -> tuple[Path, dict[str, Any], dict[str, Any], dict[str, Any]]: + project, runtime_root = project.expanduser().absolute(), runtime_root.expanduser().absolute() + registry_path = project / ".loopx" / "registry.json" + # Mechanical filesystem validation precedes even the registry lock's IO. + # Do not follow a caller's linked destination into another project. + for path in (registry_path, runtime_root): + if path.resolve() != path: + raise ValueError("Planned transfer destination must use a physical normalized path") + target = {"runtime_root": str(runtime_root), "project": str(project), + "registry_path": str(registry_path), "provider": provider} + with project_registry_transaction(registry_path, operation="planned_transfer_stage_read", create=dict) as transaction: + registry = transaction.payload_copy() + witness = transaction.source_read_witness() if registry_path.exists() else {"path": str(registry_path), "sha256": None} + return registry_path, target, registry, witness + + if __name__ == "__main__": # Fixed native codec reader, with no write action or interpreter override. # The invoking native owner holds the maintenance/state/registry locks. diff --git a/loopx/control_plane/goals/planned_transfer_payload.ts b/loopx/control_plane/goals/planned_transfer_payload.ts index 82706889ab..310cd976d0 100644 --- a/loopx/control_plane/goals/planned_transfer_payload.ts +++ b/loopx/control_plane/goals/planned_transfer_payload.ts @@ -34,6 +34,7 @@ export interface VerifiedGoalTransferPayload { * Materials are an explicit reviewed selection, not a completeness/privacy * certification. No machine root, registry inventory or Git metadata is swept. */ export async function reviewProtectedGoalTransferInputs(raw: unknown, context: JsonObject, packageVersion: unknown) { + if (process.platform === "win32") reject("Protected source transfer requires the local POSIX profile before preparation"); const transport = requireJsonObject(raw, "protected transfer request"); if (!hasExactAuthorityKeys(transport, ["destination", "recipient", "materials"]) || !Array.isArray(transport.materials)) { reject("Protected transfer requires an explicit recipient, destination and reviewed materials"); diff --git a/loopx/control_plane/goals/planned_transfer_workspace.ts b/loopx/control_plane/goals/planned_transfer_workspace.ts new file mode 100644 index 0000000000..1aaf55222e --- /dev/null +++ b/loopx/control_plane/goals/planned_transfer_workspace.ts @@ -0,0 +1,141 @@ +/** Reviewed material placement for the stopped Goal transfer. This owns only + * selected workspace files, never Goal admission or authority. Credential policy + * remains with its owner; selected bytes are not privacy-certified here. */ +import {constants} from "node:fs"; +import {copyFile, mkdir, open, readFile} from "node:fs/promises"; +import {dirname, isAbsolute, join, relative, resolve, sep} from "node:path"; +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; +import {durableWriteJson} from "../effect_runtime_io.ts"; +import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys} from "../coordination/authority_store_codec.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; +import {inspectTransferFile, requirePhysicalTransferPath} from "./planned_transfer_container.ts"; +import type {ProtectedGoalMaterial} from "./planned_transfer.ts"; + +interface WorkspaceMaterial {source: ProtectedGoalMaterial; destination: string; relativePath: string} +export interface GoalTransferWorkspacePlan {materials: WorkspaceMaterial[]; destinations: JsonObject[]} +type MaterialPresence = "missing" | "matched" | "occupied"; +interface MaterialObservation extends JsonObject {id: string; relative_path: string; presence: MaterialPresence; sha256: string | null; bytes: number | null} +interface WorkspaceReview extends JsonObject {preview_sha256: string; ready: boolean; materials: MaterialObservation[]} +function reject(message: string): never { throw new EffectRuntimeConflictError(message); } +function same(a: unknown, b: unknown): boolean { return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b)); } +function beneath(root: string, path: string): boolean { + const suffix = relative(root, path); + return suffix === "" || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)); +} +async function optionalRecord(path: string): Promise { + await requirePhysicalTransferPath(path); + try { return requireJsonObject(JSON.parse(await readFile(path, "utf8")), "original material placement"); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } +} +async function retainRecord(path: string, value: JsonObject): Promise { + const prior = await optionalRecord(path); + if (prior) { if (!same(prior, value)) reject("Material placement belongs to another original operation"); return; } + await mkdir(dirname(path), {recursive: true, mode: 0o700}); + await durableWriteJson(path, value); + if (!same(await optionalRecord(path), value)) reject("Material placement receipt readback failed"); +} + +/** A null mapping keeps original relative names. An explicit mapping must + * account for every selected member; no basename guessing or silent omission. + * Reserved control state stays with its existing owner, outside this operation. */ +export function planGoalTransferWorkspace(project: string, runtime: string, + materials: ProtectedGoalMaterial[], raw: unknown): GoalTransferWorkspacePlan { + if (process.platform === "win32") reject("Workspace transfer requires the local POSIX profile"); + const mapping = raw === null ? Object.fromEntries(materials.map(row => [row.id, row.source_relative_path])) : requireJsonObject(raw, "material destinations"); + if (!hasExactAuthorityKeys(mapping, materials.map(row => row.id))) reject("Review a destination for every selected material"); + const planned = materials.map(source => { + const destination = mapping[source.id]; + if (typeof destination !== "string" || !destination || destination.includes("\0") || isAbsolute(destination) || + destination.split(sep).some(part => part === "" || part === "." || part === "..")) reject("Material destination must be a normalized relative workspace path"); + const path = resolve(project, destination); + if (!beneath(project, path) || path === project || beneath(runtime, path) || + beneath(join(project, ".git"), path) || beneath(join(project, ".loopx"), path)) reject("Material destination overlaps runtime, registry or repository control state"); + return {source, destination: path, relativePath: destination}; + }); + const paths = new Set(planned.map(row => row.destination)); + if (paths.size !== planned.length) reject("Material destinations collide"); + for (const path of paths) { + for (let parent = dirname(path); parent !== project; parent = dirname(parent)) { + if (paths.has(parent)) reject("Material destinations contain one another"); + } + } + return {materials: planned, destinations: planned.map(row => ({id: row.source.id, relative_path: row.relativePath}))}; +} + +export async function reviewGoalTransferWorkspace(plan: GoalTransferWorkspacePlan, artifact: string, + target: JsonObject, witness: JsonObject): Promise { + const materials: MaterialObservation[] = []; + for (const row of plan.materials) { + await requirePhysicalTransferPath(row.destination); + let observed: Awaited> | null = null; + try { observed = await inspectTransferFile(row.destination); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const presence: MaterialPresence = !observed ? "missing" + : observed.sha256 === row.source.sha256 && observed.bytes === row.source.bytes ? "matched" : "occupied"; + materials.push({id: row.source.id, relative_path: row.relativePath, presence, + sha256: observed?.sha256 ?? null, bytes: observed?.bytes ?? null}); + } + const facts = {schema_version: "loopx_planned_transfer_workspace_preview_v0", artifact_sha256: artifact, + target, registry_witness: witness, materials}; + return {...facts, ready: materials.every(row => row.presence !== "occupied"), preview_sha256: canonicalAuthoritySha256(facts)}; +} + +/** A retained review binds the original operation, not a fresh confirmation on + * each retry. Publishing a file is preceded by an intent. A missing file after + * that intent is ambiguous and held, never recreated over a later deletion. */ +export async function verifyGoalTransferWorkspaceReview(plan: GoalTransferWorkspacePlan, review: WorkspaceReview, + directory: string, identity: JsonObject, expected: unknown, completed: boolean): Promise { + if (typeof expected !== "string" || !BARE_SHA256_PATTERN.test(expected)) reject("Workspace material placement requires its reviewed target preview"); + const original = await optionalRecord(join(directory, "workspace-review.json")); + const binding = {identity_sha256: canonicalAuthoritySha256(identity), preview_sha256: expected}; + if (original ? !same(original, binding) : review.preview_sha256 !== expected) reject("Destination material preview changed; review the current target before staging"); + for (const [index, row] of plan.materials.entries()) { + const observed = review.materials[index]!; + if (observed.presence === "occupied") reject("Destination workspace material changed or is occupied; preserve it"); + const intent = await optionalRecord(join(directory, "workspace-materials", `${row.source.id}.json`)); + if (completed && !intent) reject("Completed workspace material intent is missing; recover the original stage"); + if (intent && !same(intent, materialIntent(row))) reject("Destination material intent differs from the original operation"); + if (intent && observed.presence === "missing") reject("Previously attempted workspace material is missing; recover it without repeating publication"); + } +} +function materialIntent(row: WorkspaceMaterial): JsonObject { + return {id: row.source.id, destination: row.destination, sha256: row.source.sha256, bytes: row.source.bytes}; +} +export async function readGoalTransferWorkspaceReview(directory: string, identity: JsonObject): Promise { + const original = await optionalRecord(join(directory, "workspace-review.json")); + if (!original) return null; + if (!hasExactAuthorityKeys(original, ["identity_sha256", "preview_sha256"]) || + original.identity_sha256 !== canonicalAuthoritySha256(identity) || !BARE_SHA256_PATTERN.test(String(original.preview_sha256))) reject("Original destination review changed; recover its existing operation"); + return String(original.preview_sha256); +} +export async function retainGoalTransferWorkspace(plan: GoalTransferWorkspacePlan, directory: string, + identity: JsonObject, expected: string): Promise { + await retainRecord(join(directory, "workspace-review.json"), {identity_sha256: canonicalAuthoritySha256(identity), preview_sha256: expected}); + for (const row of plan.materials) { + await requirePhysicalTransferPath(row.destination); + let observed: Awaited> | null = null; + try { observed = await inspectTransferFile(row.destination); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const path = join(directory, "workspace-materials", `${row.source.id}.json`); + const intent = await optionalRecord(path); + if (intent && !same(intent, materialIntent(row))) reject("Destination material intent changed"); + if (intent && !observed) reject("Previously attempted workspace material is missing; preserve the original operation"); + if (observed && (observed.sha256 !== row.source.sha256 || observed.bytes !== row.source.bytes)) reject("Destination workspace material changed; preserve it"); + await retainRecord(path, materialIntent(row)); + if (!observed) { + const input = await inspectTransferFile(row.source.path); + if (input.sha256 !== row.source.sha256 || input.bytes !== row.source.bytes) reject("Authenticated workspace material changed"); + await mkdir(dirname(row.destination), {recursive: true, mode: 0o700}); + await requirePhysicalTransferPath(row.destination); + await copyFile(row.source.path, row.destination, constants.COPYFILE_EXCL); + const handle = await open(row.destination, constants.O_RDONLY | constants.O_NOFOLLOW); + try { await handle.sync(); } finally { await handle.close(); } + const parent = await open(dirname(row.destination), constants.O_RDONLY); + try { await parent.sync(); } finally { await parent.close(); } + } + const final = await inspectTransferFile(row.destination); + if (final.sha256 !== row.source.sha256 || final.bytes !== row.source.bytes) reject("Destination workspace material readback failed"); + } +} diff --git a/tests/control_plane/test_planned_transfer_source_boundary.py b/tests/control_plane/test_planned_transfer_source_boundary.py index 23225c53d0..1f94dc4193 100644 --- a/tests/control_plane/test_planned_transfer_source_boundary.py +++ b/tests/control_plane/test_planned_transfer_source_boundary.py @@ -22,7 +22,8 @@ from loopx.control_plane.goals.planned_transfer_io import ( create_planned_transfer_recipient, export_registered_goal_transfer, prepare_registered_goal_transfer, preview_registered_goal_transfer, - preview_protected_goal_transfer, read_goal_transfer_context, stage_protected_goal_transfer, + preview_protected_goal_transfer, preview_protected_goal_transfer_target, + read_goal_transfer_context, stage_protected_goal_transfer, ) from loopx.control_plane.projects.registry_codec import load_project_registry, project_registry_transaction from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime @@ -304,6 +305,12 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: "directory": str(incoming), "private_key_path": str(recipient_root / "private-key.pem"), "expected_artifact_sha256": protected["artifact_sha256"], "target_package_version": "incompatible"}, timeout=60, retry_safe=False) + retained_only = tmp_path / "retain-only" + legacy_stage = stage_protected_goal_transfer(**protected_input, project=retained_only / "project", + runtime_root=retained_only / "runtime", provider="file") + assert legacy_stage["authority_readback"]["status"] == "matched" + assert "workspace_materials_readback_verified" not in legacy_stage + assert not (retained_only / "project/input.txt").exists() for target_provider in ("file", "sqlite"): destination = tmp_path / f"target-{target_provider}" target_project, target_runtime = destination / "project", destination / "runtime" @@ -311,8 +318,32 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: if target_provider == "sqlite": target_registry.parent.mkdir(parents=True) target_registry.write_text(json.dumps(_strict({"schema_version": "0.1", "common_runtime_root": str(target_runtime), "goals": []}))) - request = dict(**protected_input, project=target_project, runtime_root=target_runtime, provider=target_provider) + target_input = target_project / "input.txt" + before_input = None + if target_provider == "sqlite": + target_input.write_bytes(b"6\n7\n8\n") + before_input = target_input.stat() + target_request = dict(**protected_input, project=target_project, runtime_root=target_runtime, provider=target_provider) + target_review = preview_protected_goal_transfer_target(**target_request) + assert target_review["ready_for_workspace_staging"] is True + assert target_review["execution_authority_granted"] is False + assert target_review["materials"][0]["presence"] == ("matched" if before_input else "missing") + assert target_review["workspace_destinations"] == [{"id": "00000002", "relative_path": "input.txt"}] + request = dict(**target_request, expected_workspace_preview_sha256=target_review["reviewed_preview_sha256"]) if target_provider == "file": + for path in ("../outside", ".loopx/registry.json", ".git/config"): + with pytest.raises(EffectRuntimeRemoteError, match="destination|overlaps"): + preview_protected_goal_transfer_target(**target_request, material_destinations={"00000002": path}) + target_project.mkdir(parents=True, exist_ok=True) + target_input.write_bytes(b"A later destination file") + occupied = preview_protected_goal_transfer_target(**target_request) + assert occupied["ready_for_workspace_staging"] is False + assert occupied["materials"][0]["presence"] == "occupied" + with pytest.raises(EffectRuntimeRemoteError, match="preview changed"): + stage_protected_goal_transfer(**request) + assert target_input.read_bytes() == b"A later destination file" + assert not target_registry.exists() + target_input.unlink() # Inject a real registry edit after native staging but before # codec publication. The owning CAS must preserve it, and the # same operation must recover without another source export. @@ -334,11 +365,21 @@ def concurrent_registry_edit(method, payload, **kwargs): concurrent = load_project_registry(target_registry) assert concurrent["retained_local_note"] == "An unrelated edit must survive transfer recovery" assert concurrent.get("goals", []) == [] + resumed = preview_protected_goal_transfer_target(**target_request) + assert resumed["resuming_original_operation"] is True + assert resumed["reviewed_preview_sha256"] == request["expected_workspace_preview_sha256"] + assert resumed["preview_sha256"] != target_review["preview_sha256"] staged = stage_protected_goal_transfer(**request) assert staged["execution_authority_granted"] is False assert staged["target_is_active"] is False and staged["fresh_admission_required"] is True assert staged["authority_readback"]["status"] == "matched" assert staged["material_readback_verified"] is True and staged["original_material_count"] == 1 + assert staged["workspace_materials_readback_verified"] is True + assert target_input.read_bytes() == b"6\n7\n8\n" + if before_input: + after_input = target_input.stat() + assert (after_input.st_ino, after_input.st_mode, after_input.st_mtime_ns) == ( + before_input.st_ino, before_input.st_mode, before_input.st_mtime_ns) target_goal = load_project_registry(target_registry)["goals"][0] if target_provider == "sqlite": assert json.loads(target_registry.read_text())[0]["minimum_writer_protocol"] == "goal_instance_v1" @@ -353,7 +394,9 @@ def concurrent_registry_edit(method, payload, **kwargs): retained_material = (target_project / target_goal["state_file"]).parent / "transfer-materials/00000002" assert retained_material.read_bytes() == b"6\n7\n8\n" manifest = json.loads(Path(staged["original_material_manifest"]).read_text()) - assert manifest == {"artifact_sha256": protected["artifact_sha256"], "materials": observed["materials"]} + assert manifest == {"artifact_sha256": protected["artifact_sha256"], "materials": observed["materials"], + "workspace_destinations": [{"id": "00000002", "relative_path": "input.txt"}]} + assert retained_material.stat().st_ino != target_input.stat().st_ino def target_cli(*arguments: str, expected_code: int = 0) -> dict: result = subprocess.run([sys.executable, "-I", "-m", "loopx.cli", "--format", "json", @@ -385,6 +428,20 @@ def target_cli(*arguments: str, expected_code: int = 0) -> dict: assert stage_protected_goal_transfer(**request) == staged assert target_registry.read_bytes() == original_registry assert target_cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"] == target_task + current_preview = preview_protected_goal_transfer_target(**target_request) + assert current_preview["resuming_original_operation"] is True + assert current_preview["reviewed_preview_sha256"] == request["expected_workspace_preview_sha256"] + target_input.write_bytes(b"Later workspace result must survive") + with pytest.raises(EffectRuntimeRemoteError, match="workspace material changed or is occupied"): + stage_protected_goal_transfer(**request) + assert target_input.read_bytes() == b"Later workspace result must survive" + assert retained_material.read_bytes() == b"6\n7\n8\n" + assert target_registry.read_bytes() == original_registry + target_input.unlink() + with pytest.raises(EffectRuntimeRemoteError, match="attempted workspace material is missing"): + stage_protected_goal_transfer(**request) + assert not target_input.exists() + assert target_registry.read_bytes() == original_registry retained_material.write_bytes(b"Later destination work must survive") with pytest.raises(EffectRuntimeRemoteError, match="material is occupied or changed"): stage_protected_goal_transfer(**request) diff --git a/tests/control_plane_ts/planned_transfer_workspace.test.ts b/tests/control_plane_ts/planned_transfer_workspace.test.ts new file mode 100644 index 0000000000..1e8ecf88a6 --- /dev/null +++ b/tests/control_plane_ts/planned_transfer_workspace.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test, {type TestContext} from "node:test"; +import {mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile} from "node:fs/promises"; +import {join} from "node:path"; +import {tmpdir} from "node:os"; +import {planGoalTransferWorkspace, readGoalTransferWorkspaceReview, retainGoalTransferWorkspace, + reviewGoalTransferWorkspace, verifyGoalTransferWorkspaceReview} from "../../loopx/control_plane/goals/planned_transfer_workspace.ts"; +import {inspectTransferFile} from "../../loopx/control_plane/goals/planned_transfer_container.ts"; +import {reviewProtectedGoalTransferInputs} from "../../loopx/control_plane/goals/planned_transfer_payload.ts"; + +async function fixture(t: TestContext) { + const root = await realpath(await mkdtemp(join(tmpdir(), "loopx-workspace-transfer-"))); + t.after(() => rm(root, {recursive: true, force: true})); + const source = join(root, "source"), project = join(root, "project"), runtime = join(root, "runtime"); + await writeFile(source, "Retain the original pending work input\n"); + const material = {id: "00000002", source_relative_path: "input.txt", path: source, ...await inspectTransferFile(source)}; + const plan = planGoalTransferWorkspace(project, runtime, [material], null); + const target = {project, runtime_root: runtime, registry_path: join(project, ".loopx/registry.json"), provider: "file"}; + const witness = {path: target.registry_path, sha256: null}, artifact = "a".repeat(64); + const directory = join(runtime, "original-operation"), identity = {goal_id: "original", operation_id: "reviewed-move"}; + const review = await reviewGoalTransferWorkspace(plan, artifact, target, witness); + return {root, source, project, runtime, material, plan, target, witness, artifact, directory, identity, review}; +} + +test("reviewed placement makes original relative input usable and retry neither republishes nor overwrites later work", async t => { + const f = await fixture(t), path = join(f.project, "input.txt"); + assert.equal(f.review.ready, true); assert.equal(f.review.materials[0]!.presence, "missing"); + await verifyGoalTransferWorkspaceReview(f.plan, f.review, f.directory, f.identity, f.review.preview_sha256, false); + await retainGoalTransferWorkspace(f.plan, f.directory, f.identity, f.review.preview_sha256); + assert.equal(await readFile(path, "utf8"), await readFile(f.source, "utf8")); + const before = await stat(path); + const current = await reviewGoalTransferWorkspace(f.plan, f.artifact, f.target, f.witness); + assert.equal(current.materials[0]!.presence, "matched"); assert.notEqual(current.preview_sha256, f.review.preview_sha256); + assert.equal(await readGoalTransferWorkspaceReview(f.directory, f.identity), f.review.preview_sha256); + await verifyGoalTransferWorkspaceReview(f.plan, current, f.directory, f.identity, f.review.preview_sha256, true); + await retainGoalTransferWorkspace(f.plan, f.directory, f.identity, f.review.preview_sha256); + assert.equal((await stat(path)).mtimeMs, before.mtimeMs); + await writeFile(path, "Later destination work"); + const changed = await reviewGoalTransferWorkspace(f.plan, f.artifact, f.target, f.witness); + assert.equal(changed.ready, false); assert.equal(changed.materials[0]!.presence, "occupied"); + await assert.rejects(verifyGoalTransferWorkspaceReview(f.plan, changed, f.directory, f.identity, f.review.preview_sha256, true), /occupied/); + assert.equal(await readFile(path, "utf8"), "Later destination work"); + await rm(path); + const missing = await reviewGoalTransferWorkspace(f.plan, f.artifact, f.target, f.witness); + await assert.rejects(verifyGoalTransferWorkspaceReview(f.plan, missing, f.directory, f.identity, f.review.preview_sha256, true), /attempted workspace material is missing/); + await assert.rejects(retainGoalTransferWorkspace(f.plan, f.directory, f.identity, f.review.preview_sha256), /attempted workspace material is missing/); + await assert.rejects(stat(path), {code: "ENOENT"}); +}); + +test("stale target state or changed operation cannot consume an earlier review", async t => { + const f = await fixture(t); await mkdir(f.project); + const path = join(f.project, "input.txt"); await writeFile(path, "Unrelated destination input"); + const changed = await reviewGoalTransferWorkspace(f.plan, f.artifact, f.target, f.witness); + await assert.rejects(verifyGoalTransferWorkspaceReview(f.plan, changed, f.directory, f.identity, f.review.preview_sha256, false), /preview changed/); + await assert.rejects(stat(f.directory), {code: "ENOENT"}); + assert.equal(await readFile(path, "utf8"), "Unrelated destination input"); + await rm(path); + await retainGoalTransferWorkspace(f.plan, f.directory, f.identity, f.review.preview_sha256); + await assert.rejects(readGoalTransferWorkspaceReview(f.directory, {...f.identity, operation_id: "different"}), /review changed/); + const intent = join(f.directory, "workspace-materials", "00000002.json"); await rm(intent); + const current = await reviewGoalTransferWorkspace(f.plan, f.artifact, f.target, f.witness); + await assert.rejects(verifyGoalTransferWorkspaceReview(f.plan, current, f.directory, f.identity, f.review.preview_sha256, true), /intent is missing/); +}); + +test("matching existing input is read and retained without changing its inode, mode or timestamp", async t => { + const f = await fixture(t); await mkdir(f.project); + const path = join(f.project, "input.txt"); await writeFile(path, await readFile(f.source), {mode: 0o640}); + const before = await stat(path), review = await reviewGoalTransferWorkspace(f.plan, f.artifact, f.target, f.witness); + assert.equal(review.materials[0]!.presence, "matched"); + await verifyGoalTransferWorkspaceReview(f.plan, review, f.directory, f.identity, review.preview_sha256, false); + await retainGoalTransferWorkspace(f.plan, f.directory, f.identity, review.preview_sha256); + const after = await stat(path); + assert.deepEqual([after.ino, after.mode, after.mtimeMs], [before.ino, before.mode, before.mtimeMs]); +}); + +test("explicit complete mapping refuses control state, traversal, omission, collisions and linked destinations", async t => { + const f = await fixture(t); + for (const path of ["../escape", ".loopx/goals/other/state", ".git/config", "./input.txt", "data//input", ""]) { + assert.throws(() => planGoalTransferWorkspace(f.project, f.runtime, [f.material], {"00000002": path}), /destination/); + } + assert.throws(() => planGoalTransferWorkspace(f.project, f.runtime, [f.material], {}), /every selected material/); + const materials = [f.material, {...f.material, id: "00000003"}, {...f.material, id: "00000004"}]; + for (const paths of [["data", "data/input", "data-other"], ["same", "same", "elsewhere"]]) { + assert.throws(() => planGoalTransferWorkspace(f.project, f.runtime, materials, Object.fromEntries(materials.map((m, i) => [m.id, paths[i]]))), /collide|contain/); + } + const outside = join(f.root, "outside"); await mkdir(f.project); await mkdir(outside); + await symlink(outside, join(f.project, "linked")); + const linked = planGoalTransferWorkspace(f.project, f.runtime, [f.material], {"00000002": "linked/input"}); + await assert.rejects(reviewGoalTransferWorkspace(linked, f.artifact, f.target, f.witness), /linked ancestor/); + await assert.rejects(stat(join(outside, "input")), {code: "ENOENT"}); +}); + +test("interrupted publication with a missing original file is held, rather than inferred safe to repeat", async t => { + const f = await fixture(t); await rm(f.source); + await assert.rejects(retainGoalTransferWorkspace(f.plan, f.directory, f.identity, f.review.preview_sha256), {code: "ENOENT"}); + assert.equal(JSON.parse(await readFile(join(f.directory, "workspace-materials", "00000002.json"), "utf8")).sha256, f.material.sha256); + await writeFile(f.source, "Retain the original pending work input\n"); + await assert.rejects(retainGoalTransferWorkspace(f.plan, f.directory, f.identity, f.review.preview_sha256), /attempted workspace material is missing/); + await assert.rejects(stat(join(f.project, "input.txt")), {code: "ENOENT"}); +}); + +test("unsupported source platform refuses before recipient validation or preparation effects", async () => { + const original = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", {...original, value: "win32"}); + try { await assert.rejects(reviewProtectedGoalTransferInputs({}, {}, "test"), /POSIX profile before preparation/); } + finally { Object.defineProperty(process, "platform", original); } +}); From 55a0a8066534bee7d9cbb4290616fba11e9d8df8 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:41:06 +0800 Subject: [PATCH 06/55] fix(goals): fence runtime and metadata during planned transfer Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../coordination/local_authority_write.ts | 20 ++- .../planned_transfer_authority.ts | 67 +++++++- .../coordination/planned_transfer_target.ts | 2 + .../coordination/shadow_management.ts | 27 ++- .../control_plane/effect_runtime_handlers.ts | 1 + loopx/control_plane/goals/planned_transfer.ts | 1 + .../goals/planned_transfer_io.py | 26 +++ .../control_plane/projects/registry_codec.py | 13 ++ .../quota/accounting_artifact_transaction.ts | 67 +++++++- loopx/control_plane/quota/spend_commit.py | 8 +- loopx/control_plane/quota/spend_commit.ts | 3 + loopx/control_plane/quota/void_commit.ts | 3 + .../turn_driver/journal_store.py | 19 ++- .../turn_driver/turn_journal_effects.ts | 14 +- .../turn_driver/turn_journal_query.ts | 63 +++++-- .../turn_driver/turn_journal_runtime.py | 3 + .../test_planned_transfer_source_boundary.py | 73 ++++++++ .../planned_transfer_authority.test.ts | 160 +++++++++++++++++- 18 files changed, 530 insertions(+), 40 deletions(-) diff --git a/loopx/control_plane/coordination/local_authority_write.ts b/loopx/control_plane/coordination/local_authority_write.ts index 50c2e82576..d41a256ca4 100644 --- a/loopx/control_plane/coordination/local_authority_write.ts +++ b/loopx/control_plane/coordination/local_authority_write.ts @@ -1,7 +1,8 @@ import {withFileMutationLock} from "../effect_runtime_io.ts"; import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; import {loadLegacyCoordinationWriterFence, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; -import {requireShadowPrimaryWriteAllowed, shadowMaintenanceLockPath} from "./shadow_management.ts"; +import {requireShadowPrimaryWriteAllowed, shadowMaintenanceLockPath, withShadowMaintenanceLock} from "./shadow_management.ts"; // File-v0 still verifies and republishes its retained history on each write. // Give a competing canonical writer a bounded wait for that real critical @@ -58,6 +59,23 @@ interface CanonicalWriterOptions { businessWrite?: CanonicalBusinessWrite; } +/** Runtime artifacts share the prepared Goal's exclusion, including operations + * that could otherwise repair a receipt or debit outside the Todo provider. + * Ordinary missing/promotion fences preserve their existing runtime behavior. + */ +export async function withGoalRuntimeWriter(root: string, goalId: string, + write: () => Promise, borrowedMaintenance?: unknown): Promise { + return withShadowMaintenanceLock(root, goalId, async () => { + const fence = await loadLegacyCoordinationWriterFence(root, goalId); + if (fence.status === "failed") throw new EffectRuntimeConflictError(fence.reason, "canonical_authority_fence_invalid"); + if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) { + throw new EffectRuntimeConflictError(`Goal runtime is prepared for transfer ${fence.fence.operation_id}; recover that original operation`, + "canonical_authority_transfer_prepared"); + } + return write(); + }, borrowedMaintenance); +} + /** Call only while holding the Goal maintenance lock. */ export async function requireCanonicalBusinessWrite( root: string, diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index 5b56ae3bd0..1d058ad05f 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -6,7 +6,8 @@ * exposing a cutover. No public activation entry is registered here. */ import {mkdir, readFile} from "node:fs/promises"; -import {join, resolve} from "node:path"; +import {isAbsolute, join, resolve} from "node:path"; +import {createHash} from "node:crypto"; import type {JsonObject} from "../effect_program.ts"; import {durableWriteJson} from "../effect_runtime_io.ts"; import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; @@ -26,10 +27,63 @@ import type {AuthorityStore} from "./authority_store.ts"; import {newGoalCreationBinding} from "./new_goal_initialization.ts"; import {readPromotionReceipt} from "./promotion_receipt.ts"; import {NEW_GOAL_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; +import {readGoalTurnHistoryForTransfer} from "../turn_driver/turn_journal_query.ts"; +import {readGoalQuotaHistoryForTransfer} from "../quota/accounting_artifact_transaction.ts"; export {PREPARATION_SCHEMA} from "../goals/planned_transfer.ts"; const REGISTERED_SOURCE_PREVIEW_SCHEMA = "loopx_planned_transfer_source_preview_v0"; +/** The codec calls while holding its original registry transaction. Ordinary + * metadata can no longer reopen, remove or redirect a prepared Goal. Changes + * to other Goals remain legal; the registry is not frozen as a whole. This + * consumes codec-decoded facts and a byte witness, never another JSON parser. + */ +export async function checkPlannedTransferRegistryWrite(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["before", "after", "registry_witness"])) reject("Registry transfer guard requires its codec observation"); + const before = requireJsonObject(value.before, "current registry"), after = requireJsonObject(value.after, "proposed registry"); + const witness = requireJsonObject(value.registry_witness, "registry codec witness"); + if (!hasExactAuthorityKeys(witness, ["path", "sha256"]) || typeof witness.path !== "string" || + !isAbsolute(witness.path) || resolve(witness.path) !== witness.path || + (witness.sha256 !== null && !BARE_SHA256_PATTERN.test(String(witness.sha256)))) reject("Invalid registry codec witness"); + const verify = async () => { + let digest: string | null; + try { digest = createHash("sha256").update(await readFile(String(witness.path))).digest("hex"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; digest = null; } + if (digest !== witness.sha256) reject("Registry codec observation changed"); + }; + await verify(); + // A global-local registry is a projection, never the actual source owner. + if (before.registry_role !== "global-local") { + const original = Array.isArray(before.goals) ? before.goals : []; + const proposed = Array.isArray(after.goals) ? after.goals : []; + const roots = new Set([before.common_runtime_root, after.common_runtime_root].filter(value => typeof value === "string")); + const ids = new Set([...original, ...proposed].map(raw => requireAuthorityStoreId(requireJsonObject(raw, "Goal").id, "Goal id"))); + for (const rawRoot of roots) for (const goalId of ids) { + const root = requireLocalAuthorityRuntimeRoot(rawRoot); + const fence = await loadLegacyCoordinationWriterFence(root, goalId); + if (fence.status === "failed") reject("Goal writer fence is unreadable; preserve its original registration"); + if (fence.status !== "loaded" || fence.fence.schema_version !== PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) continue; + const matches = proposed.filter(row => row !== null && typeof row === "object" && !Array.isArray(row) && (row as JsonObject).id === goalId); + const prior = original.filter(row => row !== null && typeof row === "object" && !Array.isArray(row) && (row as JsonObject).id === goalId); + if (prior.length === 0 && fence.fence.role === "target" && matches.length === 1) { + const directory = join(shadowManagementDirectory(root, goalId), "planned-transfer-targets", canonicalAuthoritySha256(fence.fence.operation_id)); + const registration = await optionalJson(join(directory, "registration.json")), receipt = await optionalJson(join(directory, "receipt.json")); + if (registration && receipt && registration.identity_sha256 === fence.fence.source_identity_sha256 && registration.registry_path === witness.path && + receipt.identity_sha256 === fence.fence.source_identity_sha256 && receipt.target_is_active === false && + receipt.execution_authority_granted === false && after.common_runtime_root === root && same(registration.goal, matches[0]) && + after.profile_id === undefined && after.registry_role !== "global-local" && after.role !== "global-local" && + ["registry_role", "role", "profile_id"].every(key => same(before[key] ?? null, after[key] ?? null))) continue; + } + if (root !== before.common_runtime_root || prior.length !== 1 || matches.length !== 1 || !same(matches[0], prior[0]) || + ["common_runtime_root", "registry_role", "role", "profile_id"].some(key => !same(before[key] ?? null, after[key] ?? null))) { + reject(`Goal registration is prepared for transfer ${fence.fence.operation_id}; recover that original operation`); + } + } + } + await verify(); + return {allowed: true}; +} + function reject(message: string): never { throw new EffectRuntimeConflictError(message); } function same(a: unknown, b: unknown): boolean { return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b)); } async function optionalJson(path: string): Promise { @@ -72,11 +126,14 @@ async function registeredSourcePreview(context: JsonObject, originalFence: JsonO const index = indexCoordinationProjection(head.head, goalId); const retainedLeases = [...index.leases].map(([todo, lease]) => canonicalTaskLease(lease, goalId, todo)) .filter(lease => lease.status !== "released"); + const turnHistory = await readGoalTurnHistoryForTransfer(String(context.runtime_root), goalId); + const quotaHistory = await readGoalQuotaHistoryForTransfer(String(context.runtime_root), goalId); const facts = {schema_version: REGISTERED_SOURCE_PREVIEW_SCHEMA, goal_id: goalId, operation_id: operationId, source_context_sha256: canonicalAuthoritySha256(context), original_registration_verified: true, original_writer_fence_sha256: canonicalAuthoritySha256(originalFence), provider, provider_revision: head.provider_revision, cursor: head.cursor, source_store_identity: storeIdentity, - retained_leases: retainedLeases, ready_for_authority_preparation: retainedLeases.length === 0, + retained_leases: retainedLeases, turn_history: turnHistory, quota_history: quotaHistory, + ready_for_authority_preparation: retainedLeases.length === 0 && turnHistory.closed === true && quotaHistory.closed === true, host_drain_qualified: false, portable_artifact_qualified: false, execution_authority_granted: false}; return {...facts, preview_sha256: canonicalAuthoritySha256(facts)}; } @@ -152,11 +209,17 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro if (leases.some(lease => lease.status !== "released")) { reject("Source retains an unreleased lease; settle its original operation before planned transfer"); } + const turnHistory = await readGoalTurnHistoryForTransfer(root, goal); + if (turnHistory.closed !== true) reject("Source Turn effects are not closed; settle or recover their original operations before preparing transfer"); + const quotaHistory = await readGoalQuotaHistoryForTransfer(root, goal); + if (quotaHistory.closed !== true) reject("Source quota effects are not closed; recover the original debit or void before preparing transfer"); const pendingPath = join(directory, "preparation.json"); const pending = await optionalJson(pendingPath); const facts = {schema_version: PREPARATION_SCHEMA, request, source_provider: opened.provider, source_store_identity: identity.store_identity, source_cursor: head.cursor, source_projection_sha256: canonicalAuthoritySha256(head.head), + source_turn_history: turnHistory, + source_quota_history: quotaHistory, ...(context ? {source_context: context, source_context_sha256: canonicalAuthoritySha256(context)} : {})}; let priorFence: JsonObject; if (pending) { diff --git a/loopx/control_plane/coordination/planned_transfer_target.ts b/loopx/control_plane/coordination/planned_transfer_target.ts index 74ac39aba4..1fe15deccc 100644 --- a/loopx/control_plane/coordination/planned_transfer_target.ts +++ b/loopx/control_plane/coordination/planned_transfer_target.ts @@ -240,6 +240,8 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi } await mkdir(directory, {recursive: true, mode: 0o700}); await retainJson(pendingPath, plan.identity); + await retainJson(join(directory, "registration.json"), {identity_sha256: canonicalAuthoritySha256(plan.identity), + registry_path: witness.path, goal: plan.goal}); const engaged = await engageLegacyCoordinationWriterFenceUnderLocks(root, plan.goalId, plan.statePath, fence); if (engaged.status !== "applied" && engaged.status !== "replayed") reject("Destination transfer fence readback failed"); await mkdir(dirname(plan.statePath), {recursive: true, mode: 0o700}); diff --git a/loopx/control_plane/coordination/shadow_management.ts b/loopx/control_plane/coordination/shadow_management.ts index 02698590d2..643a0d1d23 100644 --- a/loopx/control_plane/coordination/shadow_management.ts +++ b/loopx/control_plane/coordination/shadow_management.ts @@ -4,7 +4,8 @@ import { realpathSync } from "node:fs"; import { lstat, mkdir, open, readFile, readdir, rename } from "node:fs/promises"; import { dirname, isAbsolute, join, resolve } from "node:path"; import type { JsonObject } from "../effect_program.ts"; -import { atomicWriteJson, withFileMutationLock } from "../effect_runtime_io.ts"; +import { atomicWriteJson, withFileMutationLock, claimFileMutationLock, + mutationLockOwner, releaseFileMutationLockClaim } from "../effect_runtime_io.ts"; import { canonicalAuthorityBytes, canonicalAuthorityObject, canonicalAuthoritySha256, hasExactAuthorityKeys, isAuthorityJsonObject, requireAuthorityStoreId, @@ -96,9 +97,29 @@ export function shadowMaintenanceLockPath(root: string, goal: string): string { export function shadowManagementStatePath(root: string, goal: string): string { return join(shadowManagementDirectory(root, goal), "state.json"); } -export async function withShadowMaintenanceLock(root: string, goal: string, operation: () => Promise): Promise { +export async function withShadowMaintenanceLock(root: string, goal: string, operation: () => Promise, + borrowed?: unknown): Promise { + const target = shadowMaintenanceLockPath(root, goal); + if (borrowed !== undefined) { + // The Python IO owner keeps its surrounding transaction. Claim this exact + // live token, never discover or adopt whichever process happens to hold M. + if (!isAuthorityJsonObject(borrowed) || borrowed.target !== target || + typeof borrowed.token !== "string" || !borrowed.token || + typeof borrowed.pid !== "number" || !Number.isSafeInteger(borrowed.pid) || borrowed.pid <= 0) { + throw new ShadowManagementError("shadow_maintenance_lock_invalid"); + } + const claim = await claimFileMutationLock(target, borrowed.token); + if (!claim) throw new ShadowManagementError("shadow_maintenance_lock_expired"); + try { + const owner = await mutationLockOwner(target); + if (owner?.pid !== borrowed.pid || owner.token !== borrowed.token) { + throw new ShadowManagementError("shadow_maintenance_lock_expired"); + } + return await operation(); + } finally { await releaseFileMutationLockClaim(claim); } + } await makeDirectoryDurable(shadowManagementDirectory(root, goal)); - return await withFileMutationLock(shadowMaintenanceLockPath(root, goal), operation); + return await withFileMutationLock(target, operation); } function exact(value: unknown, fields: string[]): value is JsonObject { return isAuthorityJsonObject(value) && hasExactAuthorityKeys(value, fields); diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 96e9bfbc7b..17ea1c82cc 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -418,6 +418,7 @@ export function createEffectRuntimeHandlers( ["coordination.authority_archive.manage", lazyHandler(() => import("./coordination/local_authority_archive.ts"), ({manageLocalAuthorityArchive}) => manageLocalAuthorityArchive)], ["coordination.planned_transfer.preview_source", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({previewRegisteredAuthorityForPlannedTransfer}) => previewRegisteredAuthorityForPlannedTransfer)], ["coordination.planned_transfer.prepare_authority", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({prepareAuthorityForPlannedTransfer}) => prepareAuthorityForPlannedTransfer)], + ["coordination.planned_transfer.registry_write_check", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({checkPlannedTransferRegistryWrite}) => checkPlannedTransferRegistryWrite)], ["coordination.planned_transfer.stage_target", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({stageAuthorityForPlannedTransfer}) => stageAuthorityForPlannedTransfer)], ["coordination.planned_transfer.create_recipient", lazyHandler(() => import("./goals/planned_transfer_container.ts"), ({createPlannedTransferRecipient}) => createPlannedTransferRecipient)], ["coordination.planned_transfer.preview_protected", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({previewProtectedGoalTransfer}) => previewProtectedGoalTransfer)], diff --git a/loopx/control_plane/goals/planned_transfer.ts b/loopx/control_plane/goals/planned_transfer.ts index 781691c430..ffd065c660 100644 --- a/loopx/control_plane/goals/planned_transfer.ts +++ b/loopx/control_plane/goals/planned_transfer.ts @@ -182,6 +182,7 @@ export function planPreparedGoalStage(value: JsonObject): PreparedGoalStage { fresh_admission_required: true}}; const registry = requireJsonObject(value.destination_registry, "destination registry"); if (registry.profile_id !== undefined) reject("Destination registry profile requires its own lifetime admission"); + if (registry.registry_role === "global-local" || registry.role === "global-local") reject("Destination must be the actual project registry, not a global read model"); if (registry.common_runtime_root !== undefined && registry.common_runtime_root !== root) reject("Destination registry belongs to a different runtime"); if (registry.goals !== undefined && !Array.isArray(registry.goals)) reject("Invalid destination Goal inventory"); const matches = ((registry.goals ?? []) as unknown[]).map(item => requireJsonObject(item, "registered destination Goal")) diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index 3fe20b380c..a839f48fae 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -15,6 +15,32 @@ from ..projects.registry_codec import load_project_registry, project_registry_transaction +def require_goal_transfer_registry_write( + *, registry_path: Path, before: dict[str, Any], after: dict[str, Any], + registry_witness: dict[str, Any], +) -> None: + """Adapt an existing codec transaction; never parse or decide fence state.""" + from ..coordination.legacy_writer_fence import legacy_coordination_writer_fence_path + + # This is an IO-only fast path. Source preparation takes the same registry + # lock, so it cannot install a new fence between this observation and save. + roots = {value["common_runtime_root"] for value in (before, after) + if isinstance(value.get("common_runtime_root"), str) and Path(value["common_runtime_root"]).is_absolute()} + goals = [goal for value in (before, after) for goal in + (value["goals"] if isinstance(value.get("goals"), list) else [])] + if not any(isinstance(goal, dict) and isinstance(goal.get("id"), str) + and legacy_coordination_writer_fence_path(runtime_root=Path(root), goal_id=goal["id"]).exists() + for root in roots for goal in goals): + return + if registry_witness["path"] != str(registry_path.expanduser().resolve()): + raise ValueError("Registry transfer witness route changed") + result = effect_runtime_result("coordination.planned_transfer.registry_write_check", { + "before": before, "after": after, "registry_witness": registry_witness, + }) + if result != {"allowed": True}: + raise ValueError("Registry transfer guard did not admit this mutation") + + def _read_goal_transfer_snapshot(*, registry_path: Path, goal_id: str) -> dict[str, Any]: """Codec/byte IO only; the native Goal owner decides supported admission.""" from ...registry import find_registry_goal, resolve_state_file diff --git a/loopx/control_plane/projects/registry_codec.py b/loopx/control_plane/projects/registry_codec.py index f05952425a..c6cb71e3c6 100644 --- a/loopx/control_plane/projects/registry_codec.py +++ b/loopx/control_plane/projects/registry_codec.py @@ -374,6 +374,8 @@ def commit(self, payload: dict[str, Any]) -> bool: self._finished = True return False + self._require_transfer_write(payload) + encoded = _encode_document( payload, format=self._document.format, @@ -410,6 +412,7 @@ def remove(self) -> bool: if not self._path.exists(): self._finished = True return False + self._require_transfer_write({}) self._path.unlink() if self._path.exists(): raise ProjectRegistryMutationError( @@ -418,6 +421,16 @@ def remove(self) -> bool: self._finished = True return True + def _require_transfer_write(self, payload: dict[str, Any]) -> None: + # The format-preserving codec retains serialization, locking and + # compensation. The existing typed Goal owner decides prepared state. + from ..goals.planned_transfer_io import require_goal_transfer_registry_write + + require_goal_transfer_registry_write(registry_path=self._path, + before=self._document.payload, after=payload, + registry_witness=(self.source_read_witness() if self._existed else + {"path": str(self._path.resolve()), "sha256": None})) + def restore(self) -> None: if self._existed: _atomic_write_bytes( diff --git a/loopx/control_plane/quota/accounting_artifact_transaction.ts b/loopx/control_plane/quota/accounting_artifact_transaction.ts index 576deb4323..0a2168f048 100644 --- a/loopx/control_plane/quota/accounting_artifact_transaction.ts +++ b/loopx/control_plane/quota/accounting_artifact_transaction.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { access, lstat, readFile } from "node:fs/promises"; +import { access, lstat, readFile, readdir, realpath } from "node:fs/promises"; import { basename, dirname, extname, join, resolve } from "node:path"; import type { JsonObject } from "../effect_program.ts"; @@ -11,6 +11,8 @@ import { withFileMutationLock, } from "../effect_runtime_io.ts"; import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; +import {withGoalRuntimeWriter} from "../coordination/local_authority_write.ts"; +import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import { jsonObject, optionalNonEmptyString as optionalString, @@ -109,6 +111,7 @@ export interface QuotaAccountingArtifactCommitRequest { expectedIndexDigest: string | null; goalRef?: JsonObject; indexLockHeld?: boolean; + maintenanceLock?: unknown; prepare: ( context: QuotaAccountingArtifactPrepareContext, ) => @@ -891,6 +894,58 @@ async function ensureReceiptArtifacts( return repaired; } +/** Source preparation owns maintenance, the same exclusion as actual quota + * commits above. Inspect the original receipt/index/artifacts without repair. + * A prepared or incomplete debit/void must finish through its existing owner + * before transfer; directory emptiness is not an external-effect certificate. + */ +export async function readGoalQuotaHistoryForTransfer(root: string, goalId: string): Promise { + const runs = join(root, "goals", goalId, "runs"), index = join(runs, "index.jsonl"); + try { + if (await realpath(runs) !== runs) throw new EffectRuntimeRequestError("Transfer quota history contains a linked ancestor"); + } catch (error) { if (!isNodeErrorCode(error, "ENOENT")) throw error; } + await rejectSymlinkPath(index, "transfer quota index"); + const indexBytes = await readOptionalBytes(index), records = parseQuotaAccountingIndex(indexBytes?.toString("utf8") ?? null); + const receipts: JsonObject[] = []; + const seen = new Set(); + for (const kind of ["spend", "void"] as const) { + const contract = contractFor(kind), directory = join(runs, ".transactions", contract.transactionDirectory); + await rejectSymlinkPath(directory, "transfer quota transactions"); + let entries: string[]; + try { entries = await readdir(directory); } + catch (error) { if (isNodeErrorCode(error, "ENOENT")) entries = []; else throw error; } + for (const name of entries.sort()) { + if (!/^[a-f0-9]{24}\.json$/u.test(name)) throw new EffectRuntimeRequestError("Transfer quota transaction has an unattributable address"); + const path = join(directory, name); + await rejectSymlinkPath(path, "transfer quota receipt"); + const receipt = await readReceipt(contract, path, runs); + if (!receipt || receipt.record.goal_id !== goalId || path !== transactionPath(contract, runs, receipt.effect_id)) throw new EffectRuntimeRequestError("Transfer quota receipt identity is unavailable"); + const resolution = resolveQuotaAccountingEffect(kind, records, receipt.effect_id, receipt.goal_ref ?? null); + const body = await readOptionalText(receipt.json_path), markdown = await readOptionalText(receipt.markdown_path); + let bodyMatches = false; + if (body !== null) { + try { bodyMatches = canonicalJson(JSON.parse(body)) === canonicalJson(receipt.record); } + catch { throw new EffectRuntimeRequestError("Transfer quota artifact is unreadable"); } + } + const closed = receipt.status === "committed" && resolution.kind === "matched" && + canonicalJson(resolution.record) === canonicalJson(receipt.index_record) && bodyMatches && markdown === receipt.markdown; + receipts.push({kind, effect_id: receipt.effect_id, receipt_sha256: canonicalAuthoritySha256(receipt), closed}); + seen.add(`${kind}:${receipt.effect_id}`); + } + for (const record of records) { + if (record.classification !== contract.classification) continue; + if (record.goal_id !== goalId) throw new EffectRuntimeRequestError("Transfer quota index belongs to another Goal"); + const effect = jsonObject(record[contract.metadataField])?.effect_id; + if (typeof effect !== "string" || !seen.has(`${kind}:${effect}`)) { + throw new EffectRuntimeRequestError("Transfer quota index has no matching original receipt; reconcile its owner before transfer"); + } + } + } + const facts = {closed: receipts.every(row => row.closed === true), receipts, + index_sha256: indexBytes === null ? null : sha256Bytes(indexBytes)}; + return {...facts, sha256: canonicalAuthoritySha256(facts)}; +} + export async function commitQuotaAccountingArtifactTransaction( request: QuotaAccountingArtifactCommitRequest, ): Promise { @@ -1038,7 +1093,11 @@ export async function commitQuotaAccountingArtifactTransaction( indexDigest: await quotaAccountingIndexDigest(indexPath), }; }; - return request.indexLockHeld - ? await commit() - : await withFileMutationLock(indexPath, commit); + const lockedCommit = () => request.indexLockHeld ? commit() : withFileMutationLock(indexPath, commit); + const goal = dirname(request.runsDir), goals = dirname(goal); + if (basename(request.runsDir) === "runs" && basename(goals) === "goals") { + return withGoalRuntimeWriter(dirname(goals), basename(goal), lockedCommit, request.maintenanceLock); + } + if (request.maintenanceLock !== undefined) throw new EffectRuntimeRequestError("Quota maintenance handoff requires its canonical runtime route"); + return lockedCommit(); } diff --git a/loopx/control_plane/quota/spend_commit.py b/loopx/control_plane/quota/spend_commit.py index 4d75ee47f6..1dc205dc4a 100644 --- a/loopx/control_plane/quota/spend_commit.py +++ b/loopx/control_plane/quota/spend_commit.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any -from ...file_lock import exclusive_cross_runtime_file_lock +from ...file_lock import cross_runtime_lock_witness, exclusive_cross_runtime_file_lock from ..coordination.shadow_management import ( runtime_artifact_lock_target, shadow_maintenance_lock_target, @@ -50,6 +50,7 @@ def _quota_spend_commit_request( expected_index_digest: str | None, goal_ref: Mapping[str, Any] | None = None, source_admission: Mapping[str, Any] | None = None, + maintenance_lock: Mapping[str, Any] | None = None, ) -> dict[str, Any]: # Import lazily because runtime loads history, whose quota compatibility # surface re-exports this module during package initialization. @@ -86,6 +87,8 @@ def _quota_spend_commit_request( request["goal_ref"] = dict(goal_ref) if source_admission is not None: request["source_admission"] = dict(source_admission) + if maintenance_lock is not None: + request["maintenance_lock"] = dict(maintenance_lock) return request @@ -99,6 +102,7 @@ def _quota_spend_commit_result( expected_index_digest: str | None, goal_ref: Mapping[str, Any] | None = None, source_admission: Mapping[str, Any] | None = None, + maintenance_lock: Mapping[str, Any] | None = None, ) -> Mapping[str, Any]: params = _quota_spend_commit_request( preview, @@ -109,6 +113,7 @@ def _quota_spend_commit_result( expected_index_digest=expected_index_digest, goal_ref=goal_ref, source_admission=source_admission, + maintenance_lock=maintenance_lock, ) try: result = effect_runtime_result("quota.spend.commit", params) @@ -263,6 +268,7 @@ def commit(source_admission: Mapping[str, Any] | None) -> Mapping[str, Any]: expected_index_digest=expected_index_digest, goal_ref=goal_ref, source_admission=source_admission, + maintenance_lock=cross_runtime_lock_witness(maintenance_target), ) with exclusive_cross_runtime_file_lock( diff --git a/loopx/control_plane/quota/spend_commit.ts b/loopx/control_plane/quota/spend_commit.ts index 7a4f7eab4a..846a3981e1 100644 --- a/loopx/control_plane/quota/spend_commit.ts +++ b/loopx/control_plane/quota/spend_commit.ts @@ -99,6 +99,7 @@ interface QuotaSpendCommitRequest { after: CompactQuotaDecision; resolved_agent_id: string | null; owner: QuotaAccountingOwner; + maintenance_lock?: unknown; } interface QuotaSpendReplayRequest { @@ -308,6 +309,7 @@ function requestObject(value: unknown): QuotaSpendCommitRequest { "resolved_agent_id", )?.trim() ?? null, owner, + maintenance_lock: request.maintenance_lock, }; } @@ -851,6 +853,7 @@ export async function evaluateQuotaSpendCommit( expectedIndexDigest: request.expected_index_digest, ...(goalRef === null ? {} : { goalRef }), indexLockHeld, + maintenanceLock: request.maintenance_lock, prepare: ({ jsonPath, markdownPath, indexPath: lockedIndexPath }) => { const payload = payloadFor( request, diff --git a/loopx/control_plane/quota/void_commit.ts b/loopx/control_plane/quota/void_commit.ts index abb056decf..7183eb8e0c 100644 --- a/loopx/control_plane/quota/void_commit.ts +++ b/loopx/control_plane/quota/void_commit.ts @@ -71,6 +71,7 @@ interface QuotaVoidCommitRequest { expected_index_digest: string | null; before: JsonObject; owner: QuotaAccountingOwner; + maintenance_lock?: unknown; } interface QuotaVoidProjectionRequest { @@ -209,6 +210,7 @@ function commitRequest(value: unknown): QuotaVoidCommitRequest { "expected_index_digest", ), before, + maintenance_lock: request.maintenance_lock, owner: parseQuotaAccountingOwner({ goalRefValue: request.goal_ref, sourceAdmissionValue: request.source_admission, @@ -808,6 +810,7 @@ async function evaluateCommit( expectedIndexDigest: request.expected_index_digest, ...(goalRef === null ? {} : { goalRef }), indexLockHeld, + maintenanceLock: request.maintenance_lock, prepare: async (context) => await prepareArtifacts(request, fingerprint, runsDir, context), }); diff --git a/loopx/control_plane/turn_driver/journal_store.py b/loopx/control_plane/turn_driver/journal_store.py index 80a9dd4339..d0c45f3e87 100644 --- a/loopx/control_plane/turn_driver/journal_store.py +++ b/loopx/control_plane/turn_driver/journal_store.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any -from ...file_lock import exclusive_cross_runtime_file_lock, exclusive_file_lock +from ...file_lock import cross_runtime_lock_witness, exclusive_cross_runtime_file_lock, exclusive_file_lock from ..coordination.shadow_management import ( runtime_artifact_lock_target, shadow_maintenance_lock_target, @@ -77,12 +77,13 @@ def write_turn_journal_checkpoint( *, source_admission: Mapping[str, Any] | None = None, ) -> None: - def write() -> None: + def write(maintenance_lock: Mapping[str, Any] | None = None) -> None: write_turn_journal( str(path), journal, expected_effect_id=journal_committed_effect_id(journal), source_admission=source_admission, + maintenance_lock=maintenance_lock, ) route = _turn_journal_goal_route(path) @@ -96,7 +97,7 @@ def write_under_maintenance() -> None: shadow_maintenance_lock_target(runtime_root, goal_id), operation="turn_journal_runtime_artifact_commit", ): - write() + write(cross_runtime_lock_witness(shadow_maintenance_lock_target(runtime_root, goal_id))) if source_admission is not None: write_under_maintenance() @@ -122,14 +123,14 @@ def load_loopx_turn_plan_from_journal( plan = journal.get("plan") if not isinstance(plan, dict): raise TypeError("LoopX Turn resume journal does not contain a plan") - transaction = ( - plan.get("transaction") if isinstance(plan.get("transaction"), dict) else {} - ) + transaction = plan.get("transaction") + if not isinstance(transaction, dict): + transaction = {} if transaction.get("turn_key") != turn_key or journal.get("turn_key") != turn_key: raise ValueError("LoopX Turn resume journal has mismatched turn lineage") - envelope = ( - plan.get("turn_envelope") if isinstance(plan.get("turn_envelope"), dict) else {} - ) + envelope = plan.get("turn_envelope") + if not isinstance(envelope, dict): + envelope = {} if envelope.get("goal_id") != goal_id or journal.get("goal_id") != goal_id: raise ValueError("LoopX Turn resume journal belongs to another goal") return dict(plan) diff --git a/loopx/control_plane/turn_driver/turn_journal_effects.ts b/loopx/control_plane/turn_driver/turn_journal_effects.ts index 730302f7bb..3d14c62d28 100644 --- a/loopx/control_plane/turn_driver/turn_journal_effects.ts +++ b/loopx/control_plane/turn_driver/turn_journal_effects.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import {withGoalRuntimeWriter} from "../coordination/local_authority_write.ts"; import type { JsonObject } from "../effect_program.ts"; import { settlementIdentityFromPlan } from "../effect_program.ts"; @@ -337,7 +338,7 @@ export async function commitTurnJournal( ); } const incomingOperationId = operationId(journal); - const commit = async (): Promise => + const commitBytes = async (): Promise => await withFileMutationLock(path, async () => { let existing: JsonObject | null = null; try { @@ -382,6 +383,15 @@ export async function commitTurnJournal( operation_id: incomingOperationId, }; }); + const commit = async (): Promise => { + const turns = dirname(path), goal = dirname(turns), goals = dirname(goal); + if (basename(turns) !== "turns" || basename(goals) !== "goals") { + if (params.maintenance_lock !== undefined) conflict("Turn journal maintenance handoff requires its canonical runtime route"); + return commitBytes(); + } + if (basename(goal) !== journal.goal_id || resolve(path) !== path) conflict("Turn journal runtime route differs from its original Goal"); + return withGoalRuntimeWriter(dirname(goals), basename(goal), commitBytes, params.maintenance_lock); + }; if (goalBinding.kind === "legacy") { if (params.source_admission !== undefined) { throw new EffectRuntimeRequestError( diff --git a/loopx/control_plane/turn_driver/turn_journal_query.ts b/loopx/control_plane/turn_driver/turn_journal_query.ts index 24e33e540a..3aea0522ca 100644 --- a/loopx/control_plane/turn_driver/turn_journal_query.ts +++ b/loopx/control_plane/turn_driver/turn_journal_query.ts @@ -1,6 +1,7 @@ /** Settlement-addressed File journal readback; observations never grant execution. */ -import { constants } from "node:fs"; -import { open, readdir } from "node:fs/promises"; +import { constants, type Dirent } from "node:fs"; +import {createHash} from "node:crypto"; +import { open, readdir, realpath } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { @@ -22,6 +23,7 @@ import { interpretTurnJournal, parseTurnJournalGoalBinding, } from "./turn_journal.ts"; +import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; interface JournalEvidence { turn_key: string; @@ -52,6 +54,49 @@ function journalConflict(message: string): never { throw new EffectRuntimeConflictError(message, "journal_readback_conflict"); } +async function journalSnapshot(path: string): Promise<{value: JsonObject; sha256: string}> { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + if (!(await handle.stat()).isFile()) journalConflict("Turn journal snapshot is not a regular file"); + const bytes = await handle.readFile(); + let value: JsonObject; + try { value = requireJsonObject(JSON.parse(new TextDecoder("utf-8", {fatal: true}).decode(bytes)), "Turn journal"); } + catch { journalConflict("Turn journal history is unreadable; reconcile it before retrying"); } + return {value, sha256: createHash("sha256").update(bytes).digest("hex")}; + } finally { await handle.close(); } +} + +/** Call while source preparation owns Goal maintenance. The existing journal + * interpreter decides terminal/uncertain effects. This closes only Turn + * history, never physical Host drain, provider absence or external outboxes. + */ +export async function readGoalTurnHistoryForTransfer(root: string, goalId: string): Promise { + const directory = join(root, "goals", goalId, "turns"); + let entries: Dirent[]; + try { + if (await realpath(directory) !== directory) journalConflict("Transfer Turn history contains a linked ancestor"); + entries = await readdir(directory, {withFileTypes: true}); + } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") entries = []; else throw error; } + const turns: JsonObject[] = []; + for (const entry of entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) { + if (!entry.name.endsWith(".json")) continue; + if (!/^[a-f0-9]{64}\.json$/u.test(entry.name) || !entry.isFile()) journalConflict("Transfer source has an unattributable Turn journal; reconcile its owning operation"); + const {value: journal, sha256} = await journalSnapshot(join(directory, entry.name)); + const key = `sha256:${entry.name.slice(0, -5)}`; + const envelope = jsonObject(jsonObject(journal.plan)?.turn_envelope); + const agent = envelope?.agent_id; + if (typeof agent !== "string" || !agent) journalConflict("Transfer source Turn has no original owner"); + const observed = interpretTurnJournal({schema_version: "loopx_turn_journal_interpretation_request_v0", + journal, goal_id: goalId, agent_id: agent, turn_key: key}); + turns.push({turn_key: key, journal_sha256: sha256, + closed: observed.journal_consistent && ["committed", "stopped"].includes(observed.journal_status) && + observed.recovery_decision.action === "return_existing", + recorded_effects: observed.recorded_effects, violations: observed.violations}); + } + return {closed: turns.every(row => row.closed === true), turns, sha256: canonicalAuthoritySha256(turns)}; +} + function observedCapabilities(envelope: JsonObject): string[] { const observed = new Set(); function append(value: unknown): void { @@ -153,22 +198,10 @@ async function querySettlementJournal( for (const entry of entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) { if (!/^[a-f0-9]{64}\.json$/u.test(entry.name)) continue; if (!entry.isFile()) journalConflict("Turn journal history contains a non-regular snapshot; reconcile it before retrying"); - let value: unknown; // Journals are atomic-replace files. Read one complete version without // creating reader lock files; do not follow a replaced symlink. This is // not a snapshot of the directory or proof of provider-side absence. - const handle = await open(join(directory, entry.name), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - try { - if (!(await handle.stat()).isFile()) journalConflict("Turn journal snapshot is not a regular file"); - try { - value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await handle.readFile())); - } catch (error) { - if (error instanceof SyntaxError || error instanceof TypeError) journalConflict("Turn journal history is unreadable; reconcile it before retrying"); - throw error; - } - } finally { - await handle.close(); - } + const {value} = await journalSnapshot(join(directory, entry.name)); const evidence = evidenceForJournal( value, `sha256:${entry.name.slice(0, -5)}`, diff --git a/loopx/control_plane/turn_driver/turn_journal_runtime.py b/loopx/control_plane/turn_driver/turn_journal_runtime.py index 226723d914..8a6f862048 100644 --- a/loopx/control_plane/turn_driver/turn_journal_runtime.py +++ b/loopx/control_plane/turn_driver/turn_journal_runtime.py @@ -167,6 +167,7 @@ def write_turn_journal( *, expected_effect_id: str | None = None, source_admission: Mapping[str, Any] | None = None, + maintenance_lock: Mapping[str, Any] | None = None, ) -> dict[str, object]: """Commit a Turn-journal transition through the TS semantic owner.""" @@ -177,6 +178,8 @@ def write_turn_journal( } if source_admission is not None: request["source_admission"] = dict(source_admission) + if maintenance_lock is not None: + request["maintenance_lock"] = dict(maintenance_lock) payload = effect_runtime_result( "turn_journal.write", request, diff --git a/tests/control_plane/test_planned_transfer_source_boundary.py b/tests/control_plane/test_planned_transfer_source_boundary.py index 1f94dc4193..ef99e5dca4 100644 --- a/tests/control_plane/test_planned_transfer_source_boundary.py +++ b/tests/control_plane/test_planned_transfer_source_boundary.py @@ -30,6 +30,19 @@ from tests.control_plane.test_project_registry_codec import _strict from loopx.global_registry import sync_project_registry_to_global from loopx.paths import global_registry_path +from loopx.control_plane.turn_driver.journal_store import write_turn_journal_checkpoint +from loopx.control_plane.quota.spend_commit import record_quota_slot_spend_from_preview, replay_quota_spend_by_effect_ref +from tests.control_plane.test_quota_spend_commit_runtime import _preview as quota_preview + + +def _original_turn(goal: str, todo: str) -> dict: + key = "sha256:" + "a" * 64 + return {"schema_version": "loopx_turn_journal_v0", "goal_id": goal, "turn_key": key, + "status": "in_progress", "completed_phases": [], "plan": { + "turn_envelope": {"goal_id": goal, "agent_id": "agent-a", "action": {"selected_todo": {"todo_id": todo}}}, + "transaction": {"turn_key": key, "settlement_plan": {"schema_version": "quota_settlement_plan_v1", + "identity": {"schema_version": "quota_settlement_identity_v0", "goal_id": goal, "agent_id": "agent-a", + "todo_id": todo, "turn_instance_id": key, "effect_id": f"{goal}:agent-a:{todo}:{key}"}}}}} @pytest.mark.parametrize("provider", ["file", "sqlite"]) @@ -213,6 +226,40 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: assert inspect()["provider_revision"] == released["provider_revision"] preview = preview_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move") assert preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move") == preview + + # The real journal/checkpoint and quota facades serialize against the + # preparation owner. Terminal metadata is not physical Host drain; no + # model or Host is run by this synthetic boundary fixture. + journal = _original_turn("report", todo) + journal_path = runtime / "goals/report/turns" / ("a" * 64 + ".json") + write_turn_journal_checkpoint(journal_path, journal) + active_turn = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move") + assert active_turn["ready_for_authority_preparation"] is False + assert active_turn["turn_history"]["closed"] is False + with pytest.raises(EffectRuntimeRemoteError, match="Turn effects are not closed"): + prepare_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move", + expected_preview_sha256=active_turn["preview_sha256"]) + phases = ["host_execute", "typed_result", "validation", "durable_writeback", "quota_spend", "scheduler_apply", "scheduler_ack"] + for count in (2, 3, 4, 5, 7): + journal["completed_phases"] = phases[:count] + if count == 7: + journal["status"] = "committed" + write_turn_journal_checkpoint(journal_path, journal) + with pytest.raises(EffectRuntimeRemoteError, match="preview changed"): + prepare_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move", + expected_preview_sha256=preview["preview_sha256"]) + preview = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move") + debit_preview = quota_preview(goal_id="report", agent_id="agent-a", effect_ref="original-boundary-effect#quota_spend") + debit = record_quota_slot_spend_from_preview(debit_preview, {"runtime_root": str(runtime)}, + goal_id="report", execute=True, registry_path=registry) + assert debit["appended"] is True and debit["slots"] == 1 + with pytest.raises(EffectRuntimeRemoteError, match="preview changed"): + prepare_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move", + expected_preview_sha256=preview["preview_sha256"]) + preview = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move") + assert preview["turn_history"]["closed"] is True and preview["quota_history"]["closed"] is True + assert len(preview["quota_history"]["receipts"]) == 1 + assert json.loads(legacy_coordination_writer_fence_path(runtime_root=runtime, goal_id="report").read_text()) == original_fence request = {"runtime_root": str(runtime), "goal_id": "report", "operation_id": "prepared-move", "expected_provider_revision": released["provider_revision"], "expected_writer_fence_sha256": hashlib.sha256(canonical_bytes(original_fence)).hexdigest(), @@ -238,6 +285,28 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: assert prepared["original_registration_verified"] is True assert prepared["execution_authority_granted"] is False assert prepared["host_drain_qualified"] is False + frozen_registry, frozen_journal = registry.read_bytes(), journal_path.read_bytes() + frozen_index = Path(debit["index_path"]).read_bytes() + for cold in (False, True): + if cold: + restart_effect_runtime(wait_for_exit=True) + with pytest.raises(EffectRuntimeRemoteError, match="prepared for transfer"): + write_turn_journal_checkpoint(journal_path, journal) + with pytest.raises(ValueError, match="prepared for transfer"): + record_quota_slot_spend_from_preview(debit_preview, {"runtime_root": str(runtime)}, + goal_id="report", execute=True, registry_path=registry) + readback = replay_quota_spend_by_effect_ref(runtime, goal_id="report", + effect_ref=debit["effect_id"], agent_id="agent-a", read_only=True, registry_path=registry) + assert readback["replay_found"] is True and readback["appended"] is False + rejected_metadata = cli("configure-goal", "--goal-id", "report", "--registered-agent", "agent-b", "--execute", expected_code=1) + assert "prepared for transfer" in rejected_metadata["error"] + action = cli("goal-lifecycle", "--goal-id", "report", "--operation", "resume") + refused_resume = cli("goal-lifecycle", "--goal-id", "report", "--operation", "resume", + "--actor-kind", "owner", "--expected-state-fingerprint", action["observed_state_fingerprint"], + "--execute", expected_code=1) + assert "prepared for transfer" in refused_resume["error"] + assert registry.read_bytes() == frozen_registry and journal_path.read_bytes() == frozen_journal + assert Path(debit["index_path"]).read_bytes() == frozen_index for cold in (False, True): if cold: restart_effect_runtime(wait_for_exit=True) @@ -407,6 +476,10 @@ def target_cli(*arguments: str, expected_code: int = 0) -> dict: target_task = target_cli("todo", "list", "--goal-id", "report", "--todo-id", todo)["todo"] assert target_task == source_task + staged_registry = target_registry.read_bytes() + refused_binding = target_cli("configure-goal", "--goal-id", "report", "--registered-agent", "agent-a", "--execute", expected_code=1) + assert "prepared for transfer" in refused_binding["error"] + assert target_registry.read_bytes() == staged_registry # The existing ordinary reconnect may read this registered Goal; # it must not mint a creation receipt or reset imported work to an # empty new Goal, even if a caller supplies creation-style inputs. diff --git a/tests/control_plane_ts/planned_transfer_authority.test.ts b/tests/control_plane_ts/planned_transfer_authority.test.ts index 8bd4afaee6..9480aec491 100644 --- a/tests/control_plane_ts/planned_transfer_authority.test.ts +++ b/tests/control_plane_ts/planned_transfer_authority.test.ts @@ -8,19 +8,26 @@ import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {prepareAuthorityForPlannedTransfer as prepare} from "../../loopx/control_plane/coordination/planned_transfer_authority.ts"; import {stageAuthorityForPlannedTransfer as stage} from "../../loopx/control_plane/coordination/planned_transfer_target.ts"; import {localAuthorityProviderPaths, selectLocalAuthorityTarget, openLocalAuthorityStoreHandle} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; -import {withFileMutationLock} from "../../loopx/control_plane/effect_runtime_io.ts"; +import {withFileMutationLock, acquireFileMutationLock, mutationLockOwner, releaseFileMutationLock} from "../../loopx/control_plane/effect_runtime_io.ts"; import {COLD_SOURCE_IMPORT_WRITER_FENCE_SCHEMA, legacyCoordinationWriterFencePath, loadLegacyCoordinationWriterFence, checkLegacyCoordinationWriteAllowed, LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; -import {CanonicalWriteRouteError, withCanonicalWriter, type CanonicalBusinessWrite} from "../../loopx/control_plane/coordination/local_authority_write.ts"; +import {CanonicalWriteRouteError, withCanonicalWriter, withGoalRuntimeWriter, type CanonicalBusinessWrite} from "../../loopx/control_plane/coordination/local_authority_write.ts"; import {projectCoordinationSource, SOURCE_PROJECTION_REQUEST_SCHEMA} from "../../loopx/control_plane/coordination/source_projection.ts"; -import {shadowManagementDirectory} from "../../loopx/control_plane/coordination/shadow_management.ts"; +import {shadowManagementDirectory, shadowMaintenanceLockPath} from "../../loopx/control_plane/coordination/shadow_management.ts"; import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import {manageLocalAuthorityArchive} from "../../loopx/control_plane/coordination/local_authority_archive.ts"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; import {todo} from "./shadow_file_fixture.ts"; import {resolveTestPython} from "../../scripts/test-python.mjs"; +import {commitTurnJournal} from "../../loopx/control_plane/turn_driver/turn_journal_effects.ts"; +import {readGoalTurnHistoryForTransfer} from "../../loopx/control_plane/turn_driver/turn_journal_query.ts"; +import {transactionPhases} from "../../loopx/control_plane/turn_driver/turn_journal.ts"; +import {evaluateQuotaSpendCommit, QUOTA_SPEND_COMMIT_REQUEST_SCHEMA} from "../../loopx/control_plane/quota/spend_commit.ts"; +import {evaluateQuotaVoidCommit, QUOTA_VOID_COMMIT_REQUEST_SCHEMA} from "../../loopx/control_plane/quota/void_commit.ts"; +import {readGoalQuotaHistoryForTransfer} from "../../loopx/control_plane/quota/accounting_artifact_transaction.ts"; +import {checkPlannedTransferRegistryWrite} from "../../loopx/control_plane/coordination/planned_transfer_authority.ts"; // Mirror the ordinary Python entrypoint's fixed daemon context. Requests cannot // choose an interpreter; the real codec is used in every context-binding test. @@ -117,6 +124,8 @@ for (const sourceProvider of ["file", "sqlite"] as const) { await assert.rejects(withCanonicalWriter(d.root, "goal-a", false, async () => ++calls, {businessWrite}), /prepared for transfer/); } assert.equal(calls, 0); + await assert.rejects(withGoalRuntimeWriter(d.root, "goal-a", async () => ++calls), /prepared for transfer/); + assert.equal(calls, 0, "stopped target runtime cannot debit or publish a Turn either"); // Model the codec adapter's exact publication, then retry with its fresh // byte witness. Unrelated registry revisions are not an operation identity. const registryBytes = JSON.stringify(registry); @@ -212,6 +221,151 @@ for (const sourceProvider of ["file", "sqlite"] as const) { }); } +function originalTurn(goal = "goal-a", digest = "a"): JsonObject { + const key = `sha256:${digest.repeat(64)}`, task = "unfinished"; + return {schema_version: "loopx_turn_journal_v0", goal_id: goal, turn_key: key, + status: "in_progress", completed_phases: [], plan: { + turn_envelope: {goal_id: goal, agent_id: "agent-a", action: {selected_todo: {todo_id: task}}}, + transaction: {turn_key: key, settlement_plan: {schema_version: "quota_settlement_plan_v1", + identity: {schema_version: "quota_settlement_identity_v0", effect_id: `${goal}:agent-a:${task}:${key}`, + goal_id: goal, agent_id: "agent-a", todo_id: task, turn_instance_id: key}}}}}; +} + +function quotaRequest(root: string): JsonObject { + const decision = (spent_slots: number) => ({should_run: true, normal_delivery_allowed: true, + recovery_delivery_allowed: false, effective_action: "advance", self_repair_allowed: false, + capability_repair_allowed: false, workspace_repair_allowed: false, state: "eligible", + safe_bypass_allowed: false, safe_bypass_kind: null, blocked_action_scope: null, + compute: 1, window_hours: 24, slot_minutes: 1, spent_slots, allowed_slots: 1440}); + const before = decision(0), after = decision(1); + return {schema_version: QUOTA_SPEND_COMMIT_REQUEST_SCHEMA, effect_id: "original-effect#quota_spend", + runtime_root: root, goal_id: "goal-a", source: "heartbeat", generated_at: "2026-08-25T12:00:00+08:00", + execute: true, expected_index_digest: null, resolved_agent_id: "agent-a", before, after, + preview: {ok: true, mode: "spend-slot", dry_run: true, goal_id: "goal-a", slots: 1, agent_id: "agent-a", + before, after, appended: false, registry_mutated: false, delivery_completion_spend: false, + safe_bypass_spend: false, delivery_workspace_validated: false}}; +} + +for (const provider of ["file", "sqlite"] as const) { + test(`${provider}: a live or uncertain original Turn holds preparation; closed history stays frozen`, async t => { + const f = await fixture(t, provider), path = join(f.root, "goals/goal-a/turns", `${"a".repeat(64)}.json`); + const journal = originalTurn(); + await commitTurnJournal({path, journal}); + const before = await readFile(path), head = await f.store.loadAuthority(); + assert.equal((await readGoalTurnHistoryForTransfer(f.root, "goal-a")).closed, false); + await assert.rejects(prepare(f.request), /Turn effects are not closed/); + assert.deepEqual(await readFile(path), before); assert.deepEqual(await f.store.loadAuthority(), head); + assert.deepEqual(JSON.parse(await readFile(f.fencePath, "utf8")), f.fence); + for (const count of [2, 3, 4, 5, 7]) { + journal.completed_phases = transactionPhases.slice(0, count); + if (count === 7) journal.status = "committed"; + await commitTurnJournal({path, journal}); + } + const closed = await readGoalTurnHistoryForTransfer(f.root, "goal-a"); + assert.equal(closed.closed, true); + const prepared = await prepare(f.request), frozen = await readFile(path); + assert.equal(prepared.host_drain_qualified, false, "terminal history cannot certify a physical Host or an external outbox"); + await assert.rejects(commitTurnJournal({path, journal}), /prepared for transfer/); + const nextPath = join(f.root, "goals/goal-a/turns", `${"b".repeat(64)}.json`); + await assert.rejects(commitTurnJournal({path: nextPath, journal: originalTurn("goal-a", "b")}), /prepared for transfer/); + assert.deepEqual(await readFile(path), frozen); + await assert.rejects(readFile(nextPath), {code: "ENOENT"}); + assert.deepEqual(await readGoalTurnHistoryForTransfer(f.root, "goal-a"), closed); + assert.deepEqual(await prepare(f.request), prepared); + const sibling = join(f.root, "goals/other-goal/turns", `${"c".repeat(64)}.json`); + assert.equal((await commitTurnJournal({path: sibling, journal: originalTurn("other-goal", "c")})).appended, true); + }); + + test(`${provider}: incomplete quota holds transfer; prepared source refuses fresh debit, void and receipt repair`, async t => { + const f = await fixture(t, provider), request = quotaRequest(f.root); + const spent = await evaluateQuotaSpendCommit(request), payload = spent.payload as JsonObject; + assert.equal(spent.status, "written"); + const runs = join(f.root, "goals/goal-a/runs"), transactions = join(runs, ".transactions/quota-spend"); + const receiptPath = join(transactions, (await readdir(transactions))[0]); + const receipt = JSON.parse(await readFile(receiptPath, "utf8")); + await writeFile(receiptPath, JSON.stringify({...receipt, status: "prepared"})); + assert.equal((await readGoalQuotaHistoryForTransfer(f.root, "goal-a")).closed, false); + await assert.rejects(prepare(f.request), /quota effects are not closed/); + assert.deepEqual(JSON.parse(await readFile(f.fencePath, "utf8")), f.fence); + assert.equal((await evaluateQuotaSpendCommit(request)).status, "replayed"); + assert.equal((await readGoalQuotaHistoryForTransfer(f.root, "goal-a")).closed, true); + const prepared = await prepare(f.request), index = await readFile(String(payload.index_path)); + for (const effect_id of [request.effect_id, "later-effect#quota_spend"]) { + await assert.rejects(evaluateQuotaSpendCommit({...request, effect_id}), /prepared for transfer/); + } + await assert.rejects(evaluateQuotaVoidCommit({schema_version: QUOTA_VOID_COMMIT_REQUEST_SCHEMA, + operation: "commit", effect_id: "original-void", runtime_root: f.root, goal_id: "goal-a", + voided_run_generated_at: request.generated_at, source: "heartbeat", reason_summary: "Fixture void", + generated_at: request.generated_at, execute: true, expected_index_digest: null, + before: {...request.before as JsonObject, quota: {spent_slots: 1, allowed_slots: 1440, window_hours: 24, + compute: 1, slot_minutes: 1}}}), /prepared for transfer/); + const jsonPath = String(payload.json_path), bytes = await readFile(jsonPath); + await rm(jsonPath); + await assert.rejects(evaluateQuotaSpendCommit(request), /prepared for transfer/); + await assert.rejects(readFile(jsonPath), {code: "ENOENT"}); + assert.deepEqual(await readFile(String(payload.index_path)), index); + await assert.rejects(prepare(f.request), /quota effects are not closed/); + await writeFile(jsonPath, bytes); // fixture repairs its own synthetic loss + assert.deepEqual(await prepare(f.request), prepared); + }); +} + +test("prepared registry guard preserves the exact Goal/root while admitting unrelated metadata", async t => { + const f = await fixture(t, "file"), d = await destination(t, f, "sqlite"); + const before = {common_runtime_root: f.root, goals: [d.context.goal, {id: "unrelated", display_name: "before"}]}; + const path = d.context.registry_path; + await writeFile(path, JSON.stringify(before)); + const check = (after: JsonObject, sha256 = createHash("sha256").update(JSON.stringify(before)).digest("hex")) => + checkPlannedTransferRegistryWrite({before, after, registry_witness: {path, sha256}}); + assert.deepEqual(await check({...before, goals: [d.context.goal, {id: "unrelated", display_name: "after"}]}), {allowed: true}); + for (const after of [{...before, common_runtime_root: join(f.root, "other-runtime")}, {...before, registry_role: "global-local"}, + {...before, profile_id: "source_session_v1"}, {...before, goals: []}, + {...before, goals: [{...d.context.goal, activation: {state: "active"}}]}, {}]) { + await assert.rejects(check(after), /prepared for transfer/); + } + await assert.rejects(check(before, "0".repeat(64)), /observation changed/); + assert.equal(await readFile(path, "utf8"), JSON.stringify(before)); +}); + +test("first target publication accepts only its completed stopped proposal", async t => { + const f = await fixture(t, "file"), d = await destination(t, f, "sqlite"); + const staged = await stage(d.request), after = staged.destination_registry as JsonObject; + const check = (proposed: JsonObject) => checkPlannedTransferRegistryWrite({before: {}, after: proposed, + registry_witness: {path: d.registry, sha256: null}}); + assert.deepEqual(await check(after), {allowed: true}); + const original = (after.goals as JsonObject[])[0]; + for (const goal of [{...original, activation: {state: "active"}}, + {...original, coordination: {registered_agents: ["forged-agent"]}}]) { + await assert.rejects(check({...after, goals: [goal]}), /prepared for transfer/); + } + for (const metadata of [{registry_role: "global-local"}, {role: "global-local"}, {profile_id: "source_session_v1"}]) { + await assert.rejects(check({...after, ...metadata}), /prepared for transfer/); + await assert.rejects(stage({...d.request, destination_registry: metadata}), /actual project registry|own lifetime admission/); + } + const directory = join(shadowManagementDirectory(d.root, "goal-a"), "planned-transfer-targets", canonicalAuthoritySha256("move-original")); + const receipt = await readFile(join(directory, "receipt.json")); + await rm(join(directory, "receipt.json")); + await assert.rejects(check(after), /prepared for transfer/); + await writeFile(join(directory, "receipt.json"), receipt); + assert.deepEqual(await check(after), {allowed: true}); + await assert.rejects(readFile(d.registry), {code: "ENOENT"}, "the guard cannot publish the registry itself"); +}); + +test("runtime maintenance handoff retains the caller's lock and refuses a stale or foreign witness", async t => { + const f = await fixture(t, "file"), target = shadowMaintenanceLockPath(f.root, "goal-a"); + let writes = 0; + await assert.rejects(withGoalRuntimeWriter(f.root, "goal-a", async () => ++writes, + {target, pid: process.pid, token: "not-a-held-token"}), /maintenance_lock_expired/); + const {token} = await acquireFileMutationLock(target); + try { + const witness = {...await mutationLockOwner(target), target}; + await assert.rejects(withGoalRuntimeWriter(f.root, "other-goal", async () => ++writes, witness), /maintenance_lock_invalid/); + assert.equal(await withGoalRuntimeWriter(f.root, "goal-a", async () => ++writes, witness), 1); + assert.equal((await mutationLockOwner(target))?.token, token, "borrowed readback must not release its Python/native transaction owner"); + assert.equal(writes, 1); + } finally { await releaseFileMutationLock(target, token); } +}); + test("target staging recovers an interrupted real File archive write under the original closed fence", async t => { const f = await fixture(t, "sqlite"), d = await destination(t, f, "file"); const paths = localAuthorityProviderPaths(d.root, "goal-a"); From 23db3336dfe91780d4d15fbf7e547825170d792f Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:11:40 +0800 Subject: [PATCH 07/55] fix(transfer): fence warm Hosts and retain real Turn process drain Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../coordination/local_authority_write.ts | 12 ++ .../planned_transfer_authority.ts | 4 +- .../control_plane/effect_runtime_handlers.ts | 1 + .../goals/first_party_host_admission.py | 28 ++- .../turn_driver/codex_sessions.py | 110 ++++++------ loopx/control_plane/turn_driver/executor.py | 6 +- .../turn_driver/host_process_transport.py | 77 +++++--- .../turn_driver/turn_host_process.py | 168 ++++++++++++++++++ .../turn_driver/turn_journal_query.ts | 50 +++++- .../test_planned_transfer_source_boundary.py | 84 ++++++++- .../control_plane/test_turn_host_transfer.py | 73 ++++++++ .../planned_transfer_authority.test.ts | 28 ++- 12 files changed, 540 insertions(+), 101 deletions(-) create mode 100644 loopx/control_plane/turn_driver/turn_host_process.py create mode 100644 tests/control_plane/test_turn_host_transfer.py diff --git a/loopx/control_plane/coordination/local_authority_write.ts b/loopx/control_plane/coordination/local_authority_write.ts index d41a256ca4..8ed1605889 100644 --- a/loopx/control_plane/coordination/local_authority_write.ts +++ b/loopx/control_plane/coordination/local_authority_write.ts @@ -3,6 +3,8 @@ import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; import {loadLegacyCoordinationWriterFence, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; import {requireShadowPrimaryWriteAllowed, shadowMaintenanceLockPath, withShadowMaintenanceLock} from "./shadow_management.ts"; +import {requireLocalAuthorityRuntimeRoot} from "./local_authority_provider.ts"; +import {hasExactAuthorityKeys, requireAuthorityStoreId} from "./authority_store_codec.ts"; // File-v0 still verifies and republishes its retained history on each write. // Give a competing canonical writer a bounded wait for that real critical @@ -76,6 +78,16 @@ export async function withGoalRuntimeWriter(root: string, goalId: string, }, borrowedMaintenance); } +/** The caller owns this exact maintenance lock while publishing Host/session + * IO. Transport never supplies a policy boolean or chooses a different fence. */ +export async function checkGoalRuntimeWrite(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "maintenance_lock"])) { + throw new EffectRuntimeConflictError("Runtime write check requires its owning maintenance witness"); + } + return withGoalRuntimeWriter(requireLocalAuthorityRuntimeRoot(value.runtime_root), + requireAuthorityStoreId(value.goal_id, "runtime Goal"), async () => ({allowed: true}), value.maintenance_lock); +} + /** Call only while holding the Goal maintenance lock. */ export async function requireCanonicalBusinessWrite( root: string, diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index 1d058ad05f..377c2e626f 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -133,7 +133,8 @@ async function registeredSourcePreview(context: JsonObject, originalFence: JsonO original_writer_fence_sha256: canonicalAuthoritySha256(originalFence), provider, provider_revision: head.provider_revision, cursor: head.cursor, source_store_identity: storeIdentity, retained_leases: retainedLeases, turn_history: turnHistory, quota_history: quotaHistory, - ready_for_authority_preparation: retainedLeases.length === 0 && turnHistory.closed === true && quotaHistory.closed === true, + ready_for_authority_preparation: retainedLeases.length === 0 && turnHistory.closed === true && + turnHistory.turn_hosts_closed === true && quotaHistory.closed === true, host_drain_qualified: false, portable_artifact_qualified: false, execution_authority_granted: false}; return {...facts, preview_sha256: canonicalAuthoritySha256(facts)}; } @@ -211,6 +212,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro } const turnHistory = await readGoalTurnHistoryForTransfer(root, goal); if (turnHistory.closed !== true) reject("Source Turn effects are not closed; settle or recover their original operations before preparing transfer"); + if (turnHistory.turn_hosts_closed !== true) reject("Source Turn Hosts are not proven drained; recover their original process supervision before preparing transfer"); const quotaHistory = await readGoalQuotaHistoryForTransfer(root, goal); if (quotaHistory.closed !== true) reject("Source quota effects are not closed; recover the original debit or void before preparing transfer"); const pendingPath = join(directory, "preparation.json"); diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 17ea1c82cc..741a24d513 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -419,6 +419,7 @@ export function createEffectRuntimeHandlers( ["coordination.planned_transfer.preview_source", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({previewRegisteredAuthorityForPlannedTransfer}) => previewRegisteredAuthorityForPlannedTransfer)], ["coordination.planned_transfer.prepare_authority", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({prepareAuthorityForPlannedTransfer}) => prepareAuthorityForPlannedTransfer)], ["coordination.planned_transfer.registry_write_check", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({checkPlannedTransferRegistryWrite}) => checkPlannedTransferRegistryWrite)], + ["coordination.planned_transfer.runtime_write_check", lazyHandler(() => import("./coordination/local_authority_write.ts"), ({checkGoalRuntimeWrite}) => checkGoalRuntimeWrite)], ["coordination.planned_transfer.stage_target", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({stageAuthorityForPlannedTransfer}) => stageAuthorityForPlannedTransfer)], ["coordination.planned_transfer.create_recipient", lazyHandler(() => import("./goals/planned_transfer_container.ts"), ({createPlannedTransferRecipient}) => createPlannedTransferRecipient)], ["coordination.planned_transfer.preview_protected", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({previewProtectedGoalTransfer}) => previewProtectedGoalTransfer)], diff --git a/loopx/control_plane/goals/first_party_host_admission.py b/loopx/control_plane/goals/first_party_host_admission.py index 1fb3941ee0..14a4789a84 100644 --- a/loopx/control_plane/goals/first_party_host_admission.py +++ b/loopx/control_plane/goals/first_party_host_admission.py @@ -19,10 +19,10 @@ from ..effect_program import TurnProviderStepKind from .activation import goal_activation_state from .source_session_registry_state import ( - exact_goal_ref, guard_path, require_goal_id, ) +from .goal_ref_validation import exact_goal_ref from .source_session_turn_effects import ( JournalPersist, SourceTurnEffect, @@ -236,6 +236,7 @@ class FirstPartyHostGoalAdmission: goal_id: str planned_goal_ref: object | None source_profile: bool + runtime_root: Path | None = None @classmethod def for_plan( @@ -248,16 +249,23 @@ def for_plan( requested_registry = registry_path.expanduser().resolve() require_goal_id(goal_id) source_profile = planned_goal_ref is not None + runtime_root = None if not source_profile and requested_registry.is_file(): - source_profile = ( - load_project_registry(requested_registry).get("profile_id") - == SOURCE_SESSION_PROFILE_ID - ) + registry = load_project_registry(requested_registry) + source_profile = registry.get("profile_id") == SOURCE_SESSION_PROFILE_ID + if not source_profile and isinstance(registry.get("common_runtime_root"), str): + from ..runtime.runtime_projection_route import resolve_goal_source_runtime_route + + route = resolve_goal_source_runtime_route( + registry_path=requested_registry, goal_id=goal_id, registry=registry, + ) + runtime_root = Path(route["source_runtime_root"]) return cls( registry_path=requested_registry, goal_id=goal_id, planned_goal_ref=planned_goal_ref, source_profile=source_profile, + runtime_root=runtime_root, ) @property @@ -299,6 +307,7 @@ def select_state( initialize_state: Callable[[dict[str, str]], T] | None = None, ) -> T | None: if not self.source_profile: + self.require_current() state = read_state() return state with exclusive_cross_runtime_file_lock( @@ -323,6 +332,11 @@ def select_state( def require_current(self) -> None: if not self.source_profile: + if self.runtime_root is not None: + from ..turn_driver.turn_host_process import goal_runtime_write + + with goal_runtime_write(self.runtime_root, self.goal_id): + pass return with exclusive_cross_runtime_file_lock( guard_path(self.registry_path, self.goal_id), @@ -335,6 +349,9 @@ def current_lifetime(self, *, operation: str) -> Iterator[None]: """Keep a source-session side effect inside its planned Goal lifetime.""" if not self.source_profile: + # The actual canonical writer owns its mutation lock. Holding it + # across this arbitrary callback would deadlock nested native IO. + self.require_current() yield return with exclusive_cross_runtime_file_lock( @@ -397,6 +414,7 @@ def turn_effect_admission( def accept_result(self, commit_result: Callable[[], T]) -> T: if not self.source_profile: + self.require_current() return commit_result() with exclusive_cross_runtime_file_lock( guard_path(self.registry_path, self.goal_id), diff --git a/loopx/control_plane/turn_driver/codex_sessions.py b/loopx/control_plane/turn_driver/codex_sessions.py index b013f5b538..05729b8936 100644 --- a/loopx/control_plane/turn_driver/codex_sessions.py +++ b/loopx/control_plane/turn_driver/codex_sessions.py @@ -20,6 +20,7 @@ ) from ..goals.first_party_host_admission import FirstPartyHostGoalAdmission from .driver import selected_turn_todo, session_identity_fields +from .turn_host_process import goal_runtime_write CODEX_CLI_SESSION_SCHEMA_VERSION = "loopx_codex_cli_session_v1" MANAGED_CLI_SESSION_SCHEMA_VERSION = "loopx_managed_cli_session_v1" @@ -290,60 +291,61 @@ def _write_codex_cli_session( if not normalized_session_id: raise ValueError("Codex CLI returned an invalid session id") path = _session_path(runtime_root, lineage, session_scope) - path.parent.mkdir(parents=True, exist_ok=True) - descriptor, temporary_name = tempfile.mkstemp( - prefix=f".{path.name}.", dir=path.parent - ) - temporary = Path(temporary_name) - try: - if hasattr(os, "fchmod"): - os.fchmod(descriptor, 0o600) - handle = os.fdopen(descriptor, "w", encoding="utf-8") - descriptor = -1 - with handle: - payload: dict[str, Any] = { - "schema_version": schema, - **lineage, - "host": host, - "session_id": normalized_session_id, - } - if session_scope != "todo": - payload["session_scope"] = session_scope - if session_profile_digest is not None: - payload["session_profile_digest"] = session_profile_digest - if goal_ref is not None: - payload["goal_ref"] = dict(goal_ref) - if checkpoint_artifact_sha256 is not None: - payload["checkpoint_artifact_sha256"] = checkpoint_artifact_sha256 - if checkpoint_artifact_root is not None: - payload["checkpoint_artifact_root"] = checkpoint_artifact_root - if checkpoint_context_refresh is not None: - payload["checkpoint_context_refresh"] = checkpoint_context_refresh - if operation_profile_digest is not None: - payload["operation_transport"] = ( - "dsh-native-operation-tools-v0" if host == "dsh" - else "app-server-operation-tools-v0" + with goal_runtime_write(runtime_root, lineage["goal_id"]): + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", dir=path.parent + ) + temporary = Path(temporary_name) + try: + if hasattr(os, "fchmod"): + os.fchmod(descriptor, 0o600) + handle = os.fdopen(descriptor, "w", encoding="utf-8") + descriptor = -1 + with handle: + payload: dict[str, Any] = { + "schema_version": schema, + **lineage, + "host": host, + "session_id": normalized_session_id, + } + if session_scope != "todo": + payload["session_scope"] = session_scope + if session_profile_digest is not None: + payload["session_profile_digest"] = session_profile_digest + if goal_ref is not None: + payload["goal_ref"] = dict(goal_ref) + if checkpoint_artifact_sha256 is not None: + payload["checkpoint_artifact_sha256"] = checkpoint_artifact_sha256 + if checkpoint_artifact_root is not None: + payload["checkpoint_artifact_root"] = checkpoint_artifact_root + if checkpoint_context_refresh is not None: + payload["checkpoint_context_refresh"] = checkpoint_context_refresh + if operation_profile_digest is not None: + payload["operation_transport"] = ( + "dsh-native-operation-tools-v0" if host == "dsh" + else "app-server-operation-tools-v0" + ) + payload["operation_profile_digest"] = operation_profile_digest + payload["operation_model"] = operation_model + payload["operation_reasoning_effort"] = operation_reasoning_effort + json.dump( + payload, + handle, + ensure_ascii=False, + indent=2, + sort_keys=True, ) - payload["operation_profile_digest"] = operation_profile_digest - payload["operation_model"] = operation_model - payload["operation_reasoning_effort"] = operation_reasoning_effort - json.dump( - payload, - handle, - ensure_ascii=False, - indent=2, - sort_keys=True, - ) - handle.write("\n") - if checkpoint_artifact_sha256 is not None: - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, path) - path.chmod(0o600) - finally: - if descriptor >= 0: - os.close(descriptor) - temporary.unlink(missing_ok=True) + handle.write("\n") + if checkpoint_artifact_sha256 is not None: + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + path.chmod(0o600) + finally: + if descriptor >= 0: + os.close(descriptor) + temporary.unlink(missing_ok=True) def _discard_codex_cli_session( @@ -353,7 +355,7 @@ def _discard_codex_cli_session( session_scope: str = "todo", ) -> None: path = _session_path(runtime_root, lineage, session_scope) - with exclusive_file_lock(path): + with exclusive_file_lock(path), goal_runtime_write(runtime_root, lineage["goal_id"]): path.unlink(missing_ok=True) diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index abc4c89c9f..e4d96b3ae1 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -894,8 +894,12 @@ def _host_result_stage( if confirm_start is not None: confirm_start() from ...usage_goal import observe_goal_execution + from .turn_host_process import turn_host_process_scope - with observe_goal_execution( + with turn_host_process_scope( + usage_runtime_root or project, goal_id=usage_goal_id, + turn_key=str(request["turn_key"]), attempt=journal["host_attempt_count"], + ), observe_goal_execution( usage_runtime_root or project, usage_goal_id, host=str((plan.get("host") or {}).get("kind") or "unknown"), diff --git a/loopx/control_plane/turn_driver/host_process_transport.py b/loopx/control_plane/turn_driver/host_process_transport.py index 1431cc7f09..2fcda39d1f 100644 --- a/loopx/control_plane/turn_driver/host_process_transport.py +++ b/loopx/control_plane/turn_driver/host_process_transport.py @@ -8,6 +8,7 @@ import sys import tempfile from collections.abc import Callable, Sequence +from contextlib import nullcontext from pathlib import Path from typing import Any @@ -59,7 +60,15 @@ def _write_host_process_record(path: Path, record: dict[str, Any]) -> None: try: with os.fdopen(descriptor, "w", encoding="utf-8") as handle: json.dump(record, handle, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) os.replace(temporary, path) + if hasattr(os, "O_DIRECTORY"): + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) finally: Path(temporary).unlink(missing_ok=True) @@ -232,7 +241,7 @@ def run_host_process( command = list(argv) if sys.platform == "win32": command = [sys.executable, "-c", _WINDOWS_COMMAND_RELAY, *command] - request = { + request: dict[str, Any] = { "argv": command, "cwd": str(project), "input": input_text, @@ -262,6 +271,15 @@ def run_host_process( record_path = host_process_supervisor_record(record_path) supervises = HOST_PROCESS_SUPERVISES_NESTED_HOST supervision = "leased" + from .turn_host_process import current_turn_host_process + + turn_process = current_turn_host_process() + record_paths = [record_path] if record_path is not None else [] + if turn_process is not None: + if delegated_lease is not None: + raise ValueError("A Turn Host scope cannot supervise a second delegated CLI") + if turn_process.record_path not in record_paths: + record_paths.append(turn_process.record_path) with subprocess.Popen( [ _node_executable(), @@ -279,31 +297,36 @@ def run_host_process( env=bridge_environment, ) as proc: assert proc.stdin is not None and proc.stdout is not None - result = None + result: dict[str, Any] | None = None record = None try: - if record_path is not None: - # Written before the request, so no Host exists that the record - # does not name; a record that cannot be written launches nothing. - record = {"schema_version": HOST_PROCESS_RECORD_SCHEMA_VERSION, - "host": lock_holder_host_label(), "owner_pid": os.getpid(), - "supervises": supervises, "supervision": supervision, "bridge_pid": proc.pid, - "phase": "launching", "process_group": None} - if supervises == HOST_PROCESS_SUPERVISES_NESTED_HOST: - # Positive evidence for a CLI that exits before starting its - # Host. Missing evidence must never mean "not launched". - # Both writes precede the request that can start the CLI. - _write_host_process_record(Path(record_value), { - "schema_version": HOST_PROCESS_RECORD_SCHEMA_VERSION, - "host": lock_holder_host_label(), "owner_pid": os.getpid(), - "supervises": HOST_PROCESS_SUPERVISES_HOST, "supervision": "leased", - "phase": HOST_PROCESS_NOT_LAUNCHED, "process_group": None, - }) - _write_host_process_record(record_path, record) - proc.stdin.write( - json.dumps(request, ensure_ascii=False, separators=(",", ":")) + "\n" - ) - proc.stdin.flush() + # Maintenance serializes only this bounded launch publication with + # source preparation. Never hold it while the Host runs: nested + # canonical writes and result callbacks take the same owner lock. + with turn_process.admit_launch() if turn_process is not None else nullcontext(): + if record_paths: + # Written before the request, so no Host exists that the record + # does not name; a record that cannot be written launches nothing. + record = {"schema_version": HOST_PROCESS_RECORD_SCHEMA_VERSION, + "host": lock_holder_host_label(), "owner_pid": os.getpid(), + "supervises": supervises, "supervision": supervision, "bridge_pid": proc.pid, + "phase": "launching", "process_group": None} + if supervises == HOST_PROCESS_SUPERVISES_NESTED_HOST: + # Positive evidence for a CLI that exits before starting its + # Host. Missing evidence must never mean "not launched". + # Both writes precede the request that can start the CLI. + _write_host_process_record(Path(record_value), { + "schema_version": HOST_PROCESS_RECORD_SCHEMA_VERSION, + "host": lock_holder_host_label(), "owner_pid": os.getpid(), + "supervises": HOST_PROCESS_SUPERVISES_HOST, "supervision": "leased", + "phase": HOST_PROCESS_NOT_LAUNCHED, "process_group": None, + }) + for path in record_paths: + _write_host_process_record(path, record) + proc.stdin.write( + json.dumps(request, ensure_ascii=False, separators=(",", ":")) + "\n" + ) + proc.stdin.flush() for line in proc.stdout: event = json.loads(line) kind = event.get("kind") @@ -312,7 +335,8 @@ def run_host_process( group = event.get("process_group") record.update(phase="spawned", host_pid=event["pid"], process_group=group if isinstance(group, int) else None) - _write_host_process_record(record_path, record) + for path in record_paths: + _write_host_process_record(path, record) elif kind in {"stdout", "stderr"} and isinstance(event.get("text"), str): consume = on_stdout if kind == "stdout" else on_stderr if consume is not None: @@ -342,7 +366,8 @@ def run_host_process( if record is not None and result is not None and proc.returncode == 0: # The supervisor returned only after cleaning its group; the group is still re-read. record["phase"] = "finished" - _write_host_process_record(record_path, record) + for path in record_paths: + _write_host_process_record(path, record) if proc.returncode != 0 or result is None: raise RuntimeError("Managed Host process supervision returned no result") return result diff --git a/loopx/control_plane/turn_driver/turn_host_process.py b/loopx/control_plane/turn_driver/turn_host_process.py new file mode 100644 index 0000000000..4543fdd036 --- /dev/null +++ b/loopx/control_plane/turn_driver/turn_host_process.py @@ -0,0 +1,168 @@ +"""Bind the existing process supervisor to one canonical Turn attempt. + +This is an IO/transport adapter, not another Host or migration authority. The +native Goal runtime fence admits launch under maintenance; the original process +owner still records, terminates and observes its own groups. SDK runners that +do not use that supervisor cannot acquire a drain proof from this scope. +""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ...file_lock import cross_runtime_lock_witness, exclusive_cross_runtime_file_lock +from ...runtime import validate_goal_id_path_segment +from ..coordination.shadow_management import shadow_maintenance_lock_target +from ..effect_runtime import effect_runtime_result +from .journal_store import turn_journal_path + + +@contextmanager +def goal_runtime_write(runtime_root: Path, goal_id: str) -> Iterator[None]: + """Adapt IO through the native fence; missing legacy fences keep parity.""" + from ..coordination.legacy_writer_fence import legacy_coordination_writer_fence_path + + goal_id = validate_goal_id_path_segment(goal_id) + root = runtime_root.expanduser().resolve() + fence = legacy_coordination_writer_fence_path(runtime_root=root, goal_id=goal_id) + if not fence.exists() and not fence.is_symlink(): + yield + return + target = shadow_maintenance_lock_target(root, goal_id) + with exclusive_cross_runtime_file_lock(target, operation="goal_runtime_io"): + result = effect_runtime_result("coordination.planned_transfer.runtime_write_check", { + "runtime_root": str(root), "goal_id": goal_id, + "maintenance_lock": cross_runtime_lock_witness(target), + }) + if result != {"allowed": True}: + raise RuntimeError("Goal runtime did not admit this IO") + yield + + +@dataclass(frozen=True, slots=True) +class TurnHostProcess: + runtime_root: Path + goal_id: str + record_path: Path + + @contextmanager + def admit_launch(self) -> Iterator[None]: + """Hold only the request publication, never model execution or callbacks.""" + target = shadow_maintenance_lock_target(self.runtime_root, self.goal_id) + with exclusive_cross_runtime_file_lock(target, operation="turn_host_launch"): + result = effect_runtime_result("coordination.planned_transfer.runtime_write_check", { + "runtime_root": str(self.runtime_root), "goal_id": self.goal_id, + "maintenance_lock": cross_runtime_lock_witness(target), + }) + if result != {"allowed": True}: + raise RuntimeError("Goal runtime did not admit this Host launch") + if self.record_path.exists() or self.record_path.is_symlink(): + raise ValueError("Host attempt already has evidence; recover its original Turn") + if self.record_path.resolve() != self.record_path: + raise ValueError("Host attempt requires a physical record route") + yield + + +_CURRENT: ContextVar[TurnHostProcess | None] = ContextVar("loopx_turn_host_process", default=None) + + +def current_turn_host_process() -> TurnHostProcess | None: + return _CURRENT.get() + + +@contextmanager +def turn_host_process_scope( + runtime_root: Path, *, goal_id: str, turn_key: str, attempt: int, +) -> Iterator[None]: + if type(attempt) is not int or attempt < 1: + raise ValueError("Host process scope requires the original positive attempt") + goal_id = validate_goal_id_path_segment(goal_id) + journal = turn_journal_path(runtime_root.resolve(), goal_id=goal_id, turn_key=turn_key) + scope = TurnHostProcess(runtime_root.resolve(), goal_id, + journal.with_suffix(".hosts") / f"attempt-{attempt}.json") + token = _CURRENT.set(scope) + try: + yield + finally: + _CURRENT.reset(token) + + +def _record_digest(path: Path) -> str | None: + try: + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) + except FileNotFoundError: + return None + with os.fdopen(descriptor, "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise ValueError("Host evidence is not a physical regular file") + digest = hashlib.sha256() + for chunk in iter(lambda: stream.read(64 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def read_turn_host_processes(runtime_root: Path, goal_id: str) -> list[dict[str, Any]]: + """Read actual original groups; an absent record proves no execution state. + + The native journal owner compares this complete address inventory against + every durable attempt. It interprets missing, orphan or unavailable evidence + as a hold. A record changing during OS readback is retried, not certified. + No signal or repair is performed by this reader. + """ + from .host_process_transport import execution_host_drain, host_process_supervisor_record + + if (not runtime_root.is_absolute() or runtime_root.resolve() != runtime_root + or not goal_id or goal_id in {".", ".."} or any(c in goal_id for c in "/\\\0")): + raise ValueError("Host readback requires a physical Goal runtime route") + turns = runtime_root / "goals" / goal_id / "turns" + if turns.is_symlink(): + raise ValueError("Host evidence contains a linked ancestor") + if not turns.exists(): + return [] + if turns.resolve() != turns: + raise ValueError("Host evidence contains a linked ancestor") + observed = [] + for directory in sorted(turns.iterdir()): + if not directory.name.endswith(".hosts"): + continue + if (not re.fullmatch(r"[a-f0-9]{64}\.hosts", directory.name) + or directory.is_symlink() or not directory.is_dir()): + raise ValueError("Host history contains an unattributable attempt directory") + attempts = set() + for path in directory.iterdir(): + # Retained atomic-write temporaries do not identify an execution. + if path.name.startswith(".") and path.name.endswith(".tmp"): + continue + match = re.fullmatch(r"attempt-([1-9][0-9]*)\.(?:json|cli\.host\.json)", path.name) + if not match or path.is_symlink() or not path.is_file(): + raise ValueError("Host history contains unattributable process evidence") + attempts.add(int(match[1])) + for attempt in sorted(attempts): + path = directory / f"attempt-{attempt}.json" + peer = host_process_supervisor_record(path) + before = (_record_digest(path), _record_digest(peer)) + drain = execution_host_drain(path, launch_possible=True) + if before != (_record_digest(path), _record_digest(peer)): + raise ValueError("Host evidence changed during process-group readback; retry the original operation") + observed.append({"turn_key": "sha256:" + directory.name[:-6], "attempt": attempt, + "record_sha256": before[0], "supervisor_sha256": before[1], "drain": drain}) + return observed + + +if __name__ == "__main__": + # Fixed read-only bridge invoked by the native journal owner. The caller's + # selected interpreter/source is inherited; the request cannot choose code. + import sys + + if len(sys.argv) != 3: + raise ValueError("Host history reader requires runtime and Goal") + print(json.dumps(read_turn_host_processes(Path(sys.argv[1]), sys.argv[2]), separators=(",", ":"))) diff --git a/loopx/control_plane/turn_driver/turn_journal_query.ts b/loopx/control_plane/turn_driver/turn_journal_query.ts index 3aea0522ca..737a3075d3 100644 --- a/loopx/control_plane/turn_driver/turn_journal_query.ts +++ b/loopx/control_plane/turn_driver/turn_journal_query.ts @@ -3,6 +3,9 @@ import { constants, type Dirent } from "node:fs"; import {createHash} from "node:crypto"; import { open, readdir, realpath } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; +import {execFile} from "node:child_process"; +import {promisify} from "node:util"; +import {fileURLToPath} from "node:url"; import { settlementIdentityFromPlan, @@ -24,6 +27,7 @@ import { parseTurnJournalGoalBinding, } from "./turn_journal.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; interface JournalEvidence { turn_key: string; @@ -67,8 +71,9 @@ async function journalSnapshot(path: string): Promise<{value: JsonObject; sha256 } /** Call while source preparation owns Goal maintenance. The existing journal - * interpreter decides terminal/uncertain effects. This closes only Turn - * history, never physical Host drain, provider absence or external outboxes. + * interpreter decides terminal/uncertain effects. Actual process readback is a + * separate requirement: a terminal journal cannot certify its Host exited. + * This does not cover Hosts outside Turn execution or external effect owners. */ export async function readGoalTurnHistoryForTransfer(root: string, goalId: string): Promise { const directory = join(root, "goals", goalId, "turns"); @@ -89,12 +94,49 @@ export async function readGoalTurnHistoryForTransfer(root: string, goalId: strin if (typeof agent !== "string" || !agent) journalConflict("Transfer source Turn has no original owner"); const observed = interpretTurnJournal({schema_version: "loopx_turn_journal_interpretation_request_v0", journal, goal_id: goalId, agent_id: agent, turn_key: key}); - turns.push({turn_key: key, journal_sha256: sha256, + turns.push({turn_key: key, journal_sha256: sha256, host_attempt_count: journal.host_attempt_count ?? null, closed: observed.journal_consistent && ["committed", "stopped"].includes(observed.journal_status) && observed.recovery_decision.action === "return_existing", recorded_effects: observed.recorded_effects, violations: observed.violations}); } - return {closed: turns.every(row => row.closed === true), turns, sha256: canonicalAuthoritySha256(turns)}; + let hosts: JsonObject[] = []; + if (turns.length || entries.some(entry => entry.name.endsWith(".hosts"))) { + const python = process.env.LOOPX_EFFECT_RUNTIME_PYTHON; + if (!python || !isAbsolute(python) || python.includes("\0")) journalConflict("Host drain requires the source-selected process owner adapter"); + try { + const result = await promisify(execFile)(python, ["-m", "loopx.control_plane.turn_driver.turn_host_process", root, goalId], { + env: {...process.env, PYTHONPATH: fileURLToPath(new URL("../../../", import.meta.url))}, + timeout: 30_000, maxBuffer: 16 * 1024 * 1024, + }); + const rows: unknown = JSON.parse(result.stdout); + if (!Array.isArray(rows)) journalConflict("Invalid Host process readback"); + hosts = rows.map(row => requireJsonObject(row, "original Host process observation")); + } catch { journalConflict("Host process readback is unavailable; recover the original Host before transfer"); } + } + const addresses = new Set(); + for (const host of hosts) { + const turn = turns.find(row => row.turn_key === host.turn_key); + const key = `${String(host.turn_key)}:${String(host.attempt)}`; + if (!turn || !Number.isSafeInteger(host.attempt) || Number(host.attempt) < 1 || addresses.has(key) || + !Number.isSafeInteger(turn.host_attempt_count) || Number(host.attempt) > Number(turn.host_attempt_count) || + typeof host.drain !== "string" || [host.record_sha256, host.supervisor_sha256].some( + value => value !== null && (typeof value !== "string" || !BARE_SHA256_PATTERN.test(value)))) { + journalConflict("Host process evidence has no matching original Turn attempt; reconcile its owner"); + } + addresses.add(key); + } + const turnHostsClosed = turns.every(turn => { + const count = turn.host_attempt_count; + const recorded = turn.recorded_effects as JsonObject; + const actual = hosts.filter(host => host.turn_key === turn.turn_key); + // The journal owner may prove no attempt, but a missing attempt counter + // beside a recorded invocation is historical uncertainty, never absence. + if ((count === null || count === 0) && recorded.host_invoked === false) return actual.length === 0; + return Number.isSafeInteger(count) && Number(count) > 0 && actual.length === count && + actual.every(host => host.record_sha256 !== null && ["drained", "not_launched"].includes(String(host.drain))); + }); + const facts = {turns, hosts, turn_hosts_closed: turnHostsClosed}; + return {...facts, closed: turns.every(row => row.closed === true), sha256: canonicalAuthoritySha256(facts)}; } function observedCapabilities(envelope: JsonObject): string[] { diff --git a/tests/control_plane/test_planned_transfer_source_boundary.py b/tests/control_plane/test_planned_transfer_source_boundary.py index ef99e5dca4..4c306eb722 100644 --- a/tests/control_plane/test_planned_transfer_source_boundary.py +++ b/tests/control_plane/test_planned_transfer_source_boundary.py @@ -12,6 +12,9 @@ import shutil import subprocess import sys +import threading +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager from pathlib import Path import pytest @@ -33,6 +36,10 @@ from loopx.control_plane.turn_driver.journal_store import write_turn_journal_checkpoint from loopx.control_plane.quota.spend_commit import record_quota_slot_spend_from_preview, replay_quota_spend_by_effect_ref from tests.control_plane.test_quota_spend_commit_runtime import _preview as quota_preview +from loopx.control_plane.turn_driver.turn_host_process import turn_host_process_scope +from loopx.control_plane.turn_driver.host_process_transport import run_host_process +from loopx.control_plane.turn_driver.codex_sessions import _session_path, _store_codex_cli_session, _discard_codex_cli_session +from loopx.control_plane.goals.first_party_host_admission import FirstPartyHostGoalAdmission def _original_turn(goal: str, todo: str) -> dict: @@ -45,6 +52,34 @@ def _original_turn(goal: str, todo: str) -> dict: "todo_id": todo, "turn_instance_id": key, "effect_id": f"{goal}:agent-a:{todo}:{key}"}}}}} +@contextmanager +def _running_turn_host(runtime: Path, project: Path, journal: dict): + """An owned real group, including a child that ignores graceful stop.""" + ready = threading.Event() + release = project / "finish-original-host" + command = "\n".join([ + "import subprocess,sys,time;from pathlib import Path", + "subprocess.Popen([sys.executable,'-c','import signal,time;signal.signal(signal.SIGTERM,signal.SIG_IGN);time.sleep(60)'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)", + "print('ready',flush=True)", + "while not Path(sys.argv[1]).exists(): time.sleep(.01)", + ]) + + def launch(): + with turn_host_process_scope(runtime, goal_id="report", turn_key=journal["turn_key"], attempt=1): + return run_host_process([sys.executable, "-c", command, str(release)], project=project, + input_text="", timeout_seconds=30, on_stdout=lambda _text: ready.set()) + + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(launch) + try: + assert ready.wait(10), "the original real Host did not start" + yield + finally: + release.touch() + observed = future.result(timeout=15) + assert observed["outcome"] == "exited" + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_stop_withdrawal_and_exact_release_have_distinct_authority( tmp_path: Path, monkeypatch, provider: str, @@ -228,9 +263,11 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: assert preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move") == preview # The real journal/checkpoint and quota facades serialize against the - # preparation owner. Terminal metadata is not physical Host drain; no - # model or Host is run by this synthetic boundary fixture. + # preparation owner. Terminal metadata is not physical Host drain. An + # actual supervised synthetic command supplies that separate evidence; + # no model or external service is invoked. journal = _original_turn("report", todo) + journal["host_attempt_count"] = 1 journal_path = runtime / "goals/report/turns" / ("a" * 64 + ".json") write_turn_journal_checkpoint(journal_path, journal) active_turn = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move") @@ -239,12 +276,22 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: with pytest.raises(EffectRuntimeRemoteError, match="Turn effects are not closed"): prepare_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move", expected_preview_sha256=active_turn["preview_sha256"]) - phases = ["host_execute", "typed_result", "validation", "durable_writeback", "quota_spend", "scheduler_apply", "scheduler_ack"] - for count in (2, 3, 4, 5, 7): - journal["completed_phases"] = phases[:count] - if count == 7: - journal["status"] = "committed" - write_turn_journal_checkpoint(journal_path, journal) + with _running_turn_host(runtime, project, journal): + phases = ["host_execute", "typed_result", "validation", "durable_writeback", "quota_spend", "scheduler_apply", "scheduler_ack"] + for count in (2, 3, 4, 5, 7): + journal["completed_phases"] = phases[:count] + if count == 7: + journal["status"] = "committed" + write_turn_journal_checkpoint(journal_path, journal) + # Deliberately contradictory cross-owner timing: even a terminal + # journal must not make this actually running group disappear. + terminal_preview = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move") + assert terminal_preview["turn_history"]["closed"] is True + assert terminal_preview["turn_history"]["turn_hosts_closed"] is False + assert terminal_preview["turn_history"]["hosts"][0]["drain"] == "draining" + with pytest.raises(EffectRuntimeRemoteError, match="Hosts are not proven drained"): + prepare_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move", + expected_preview_sha256=terminal_preview["preview_sha256"]) with pytest.raises(EffectRuntimeRemoteError, match="preview changed"): prepare_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move", expected_preview_sha256=preview["preview_sha256"]) @@ -259,6 +306,12 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: preview = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="prepared-move") assert preview["turn_history"]["closed"] is True and preview["quota_history"]["closed"] is True assert len(preview["quota_history"]["receipts"]) == 1 + assert preview["turn_history"]["turn_hosts_closed"] is True + lineage = {"goal_id": "report", "agent_id": "agent-a", "todo_id": todo} + warm_admission = FirstPartyHostGoalAdmission.for_plan(registry_path=registry, goal_id="report", planned_goal_ref=None) + _store_codex_cli_session(runtime, lineage=lineage, session_id="original-source-context") + session_path = _session_path(runtime, lineage) + original_session = session_path.read_bytes() assert json.loads(legacy_coordination_writer_fence_path(runtime_root=runtime, goal_id="report").read_text()) == original_fence request = {"runtime_root": str(runtime), "goal_id": "report", "operation_id": "prepared-move", "expected_provider_revision": released["provider_revision"], @@ -306,6 +359,21 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: "--execute", expected_code=1) assert "prepared for transfer" in refused_resume["error"] assert registry.read_bytes() == frozen_registry and journal_path.read_bytes() == frozen_journal + for callback in ( + warm_admission.require_current, + lambda: warm_admission.accept_result(lambda: pytest.fail("prepared source result must not be accepted")), + lambda: _store_codex_cli_session(runtime, lineage=lineage, session_id="late-source-context"), + lambda: _discard_codex_cli_session(runtime, lineage=lineage), + ): + with pytest.raises(EffectRuntimeRemoteError, match="prepared for transfer"): + callback() + assert session_path.read_bytes() == original_session + marker = project / f"late-host-{cold}" + with turn_host_process_scope(runtime, goal_id="report", turn_key=journal["turn_key"], attempt=2): + with pytest.raises(EffectRuntimeRemoteError, match="prepared for transfer"): + run_host_process([sys.executable, "-c", "from pathlib import Path;import sys;Path(sys.argv[1]).touch()", str(marker)], + project=project, input_text="", timeout_seconds=5) + assert not marker.exists() assert Path(debit["index_path"]).read_bytes() == frozen_index for cold in (False, True): if cold: diff --git a/tests/control_plane/test_turn_host_transfer.py b/tests/control_plane/test_turn_host_transfer.py new file mode 100644 index 0000000000..7fb28e8c0c --- /dev/null +++ b/tests/control_plane/test_turn_host_transfer.py @@ -0,0 +1,73 @@ +"""Production Turn supervision must be independently readable for a transfer.""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from loopx.control_plane.turn_driver.turn_host_process import read_turn_host_processes +from loopx.control_plane.turn_driver.executor import run_loopx_turn_once +from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime +from tests.test_loopx_turn_driver import ( + _completion_host_and_validation_scripts, + _turn_run_once_completion_argv, + _write_live_fixture, +) + + +@pytest.mark.skipif(not hasattr(os, "killpg"), reason="transfer requires provable POSIX group drain") +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_real_turn_cli_retains_original_host_group_without_record_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, +) -> None: + isolate_sqlite_runtime(tmp_path, monkeypatch) + monkeypatch.delenv("LOOPX_HOST_PROCESS_RECORD", raising=False) + project, runtime, registry = _write_live_fixture(tmp_path, provider=provider) + host, validator = _completion_host_and_validation_scripts() + command = [sys.executable, "-I", "-m", "loopx.cli", + *_turn_run_once_completion_argv(project, runtime, registry, host, validator)] + first = subprocess.run(command, cwd=project, capture_output=True, text=True, timeout=60) + assert first.returncode == 0, first.stdout + first.stderr + result = json.loads(first.stdout) + assert result["status"] == "committed" + assert (project / "completion-artifact.txt").read_text() == "completed" + history = read_turn_host_processes(runtime.resolve(), "loopx-turn-fixture") + assert len(history) == 1 and history[0]["attempt"] == 1 + assert history[0]["drain"] == "drained" + assert history[0]["record_sha256"] and history[0]["supervisor_sha256"] is None + journal = runtime / "goals/loopx-turn-fixture/turns" / (history[0]["turn_key"][7:] + ".json") + snapshot = json.loads(journal.read_bytes()) + assert snapshot["status"] == "committed" and snapshot["host_attempt_count"] == 1 + records = list(journal.with_suffix(".hosts").glob("*.json")) + original_bytes = {path: path.read_bytes() for path in records} + def forbidden(*_args): + pytest.fail("original settled Turn replay must not execute or settle twice") + + replay = run_loopx_turn_once(snapshot["plan"], + host_argv=[sys.executable, "-c", "raise SystemExit(66)"], + project=project, runtime_root=runtime, goal_id="loopx-turn-fixture", + timeout_seconds=5, execute=True, writeback=forbidden, spend=forbidden, scheduler=forbidden) + assert replay["status"] == "committed" and replay["replayed"] is True + assert read_turn_host_processes(runtime.resolve(), "loopx-turn-fixture") == history + assert {path: path.read_bytes() for path in records} == original_bytes + + +def test_host_history_refuses_linked_and_orphan_addresses(tmp_path: Path) -> None: + root = tmp_path.resolve() + turns = root / "goals/report/turns" + turns.mkdir(parents=True) + directory = turns / ("a" * 64 + ".hosts") + directory.mkdir() + outside = root / "outside.json" + outside.write_text("{}") + (directory / "attempt-1.json").symlink_to(outside) + with pytest.raises(ValueError, match="unattributable"): + read_turn_host_processes(root, "report") + (directory / "attempt-1.json").unlink() + (directory / "unowned.json").write_text("{}") + with pytest.raises(ValueError, match="unattributable"): + read_turn_host_processes(root, "report") diff --git a/tests/control_plane_ts/planned_transfer_authority.test.ts b/tests/control_plane_ts/planned_transfer_authority.test.ts index 9480aec491..29a508cb0c 100644 --- a/tests/control_plane_ts/planned_transfer_authority.test.ts +++ b/tests/control_plane_ts/planned_transfer_authority.test.ts @@ -3,6 +3,9 @@ import {mkdtemp, mkdir, readFile, readdir, realpath, rename, rm, writeFile} from import {createHash} from "node:crypto"; import {join} from "node:path"; import {tmpdir} from "node:os"; +import {execFile} from "node:child_process"; +import {promisify} from "node:util"; +import {fileURLToPath} from "node:url"; import test, {type TestContext} from "node:test"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {prepareAuthorityForPlannedTransfer as prepare} from "../../loopx/control_plane/coordination/planned_transfer_authority.ts"; @@ -249,7 +252,7 @@ function quotaRequest(root: string): JsonObject { for (const provider of ["file", "sqlite"] as const) { test(`${provider}: a live or uncertain original Turn holds preparation; closed history stays frozen`, async t => { const f = await fixture(t, provider), path = join(f.root, "goals/goal-a/turns", `${"a".repeat(64)}.json`); - const journal = originalTurn(); + const journal: JsonObject = {...originalTurn(), host_attempt_count: 1}; await commitTurnJournal({path, journal}); const before = await readFile(path), head = await f.store.loadAuthority(); assert.equal((await readGoalTurnHistoryForTransfer(f.root, "goal-a")).closed, false); @@ -263,6 +266,20 @@ for (const provider of ["file", "sqlite"] as const) { } const closed = await readGoalTurnHistoryForTransfer(f.root, "goal-a"); assert.equal(closed.closed, true); + assert.equal(closed.turn_hosts_closed, false, "terminal settlement cannot replace missing process evidence"); + await assert.rejects(prepare(f.request), /Hosts are not proven drained/); + // Exercise the actual existing supervisor, without a model or fabricated + // finished record. Its positive process-group readback closes this attempt. + await promisify(execFile)(process.env.LOOPX_EFFECT_RUNTIME_PYTHON!, ["-c", [ + "import sys;from pathlib import Path", + "from loopx.control_plane.turn_driver.turn_host_process import turn_host_process_scope", + "from loopx.control_plane.turn_driver.host_process_transport import run_host_process", + "with turn_host_process_scope(Path(sys.argv[1]),goal_id='goal-a',turn_key='sha256:'+'a'*64,attempt=1):", + " run_host_process([sys.executable,'-c','print(42)'],project=Path(sys.argv[1]),input_text='',timeout_seconds=5)", + ].join("\n"), f.root], {env: {...process.env, PYTHONPATH: fileURLToPath(new URL("../../", import.meta.url))}}); + const drained = await readGoalTurnHistoryForTransfer(f.root, "goal-a"); + assert.equal(drained.turn_hosts_closed, true); + assert.equal((drained.hosts as JsonObject[])[0].drain, "drained"); const prepared = await prepare(f.request), frozen = await readFile(path); assert.equal(prepared.host_drain_qualified, false, "terminal history cannot certify a physical Host or an external outbox"); await assert.rejects(commitTurnJournal({path, journal}), /prepared for transfer/); @@ -270,8 +287,15 @@ for (const provider of ["file", "sqlite"] as const) { await assert.rejects(commitTurnJournal({path: nextPath, journal: originalTurn("goal-a", "b")}), /prepared for transfer/); assert.deepEqual(await readFile(path), frozen); await assert.rejects(readFile(nextPath), {code: "ENOENT"}); - assert.deepEqual(await readGoalTurnHistoryForTransfer(f.root, "goal-a"), closed); + assert.deepEqual(await readGoalTurnHistoryForTransfer(f.root, "goal-a"), drained); assert.deepEqual(await prepare(f.request), prepared); + const record = join(f.root, "goals/goal-a/turns", `${"a".repeat(64)}.hosts/attempt-1.json`); + const retainedRecord = join(f.root, "retained-original-host.json"); + await rename(record, retainedRecord); + await assert.rejects(prepare(f.request), /Hosts are not proven drained/); + await assert.rejects(readFile(record), {code: "ENOENT"}); + await rename(retainedRecord, record); + assert.deepEqual(await prepare(f.request), prepared, "only the retained original process evidence recovers this operation"); const sibling = join(f.root, "goals/other-goal/turns", `${"c".repeat(64)}.json`); assert.equal((await commitTurnJournal({path: sibling, journal: originalTurn("other-goal", "c")})).appended, true); }); From 1eec6eec1b2236b5449de2c782aec728fde0edd0 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:46:46 +0800 Subject: [PATCH 08/55] feat(recovery): retain original settlement content during prepared transfer Carry closed Turn journals and quota receipts, index and artifacts through the recipient-bound container into an immutable stopped-target history. Reinterpret retained bytes with the original owners without reopening the source, rewriting receipts, or adopting Host identities or active accounting. Preserve later work and refuse missing or changed completed history. Recover only an original empty publication reservation; destination admission and the complete Agent continuation journey remain outside this staging result. Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../planned_transfer_authority.ts | 5 + .../coordination/planned_transfer_target.ts | 42 +++- .../control_plane/effect_runtime_handlers.ts | 1 + .../goals/planned_transfer_history.ts | 181 ++++++++++++++++++ .../goals/planned_transfer_io.py | 15 ++ .../goals/planned_transfer_payload.ts | 24 ++- .../quota/accounting_artifact_transaction.ts | 61 ++++-- .../turn_driver/turn_journal_query.ts | 33 +++- .../test_planned_transfer_source_boundary.py | 48 +++++ .../planned_transfer_history.test.ts | 116 +++++++++++ 10 files changed, 493 insertions(+), 33 deletions(-) create mode 100644 loopx/control_plane/goals/planned_transfer_history.ts create mode 100644 tests/control_plane_ts/planned_transfer_history.test.ts diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index 377c2e626f..2f9b5b4343 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -28,6 +28,7 @@ import {newGoalCreationBinding} from "./new_goal_initialization.ts"; import {readPromotionReceipt} from "./promotion_receipt.ts"; import {NEW_GOAL_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; import {readGoalTurnHistoryForTransfer} from "../turn_driver/turn_journal_query.ts"; +import {goalTransferHistory} from "../goals/planned_transfer_history.ts"; import {readGoalQuotaHistoryForTransfer} from "../quota/accounting_artifact_transaction.ts"; export {PREPARATION_SCHEMA} from "../goals/planned_transfer.ts"; @@ -215,6 +216,9 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro if (turnHistory.turn_hosts_closed !== true) reject("Source Turn Hosts are not proven drained; recover their original process supervision before preparing transfer"); const quotaHistory = await readGoalQuotaHistoryForTransfer(root, goal); if (quotaHistory.closed !== true) reject("Source quota effects are not closed; recover the original debit or void before preparing transfer"); + // Validate the new carrier's addresses and per-file bounds before + // changing the original writer fence. + const runtimeHistory = goalTransferHistory(root, goal, turnHistory, quotaHistory); const pendingPath = join(directory, "preparation.json"); const pending = await optionalJson(pendingPath); const facts = {schema_version: PREPARATION_SCHEMA, request, @@ -265,6 +269,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro captured.projection_sha256 !== facts.source_projection_sha256) reject("Transfer archive differs from its prepared source"); const result: JsonObject = {schema_version: PREPARATION_SCHEMA, operation_id: operation, goal_id: goal, authority_write_fenced: true, authority_archive: {...captured}, archive, + runtime_history: runtimeHistory, ...(context ? {source_context_sha256: canonicalAuthoritySha256(context)} : {}), ...(registered ? {original_registration_verified: true} : {}), host_drain_qualified: false, portable_artifact_qualified: false, execution_authority_granted: false}; diff --git a/loopx/control_plane/coordination/planned_transfer_target.ts b/loopx/control_plane/coordination/planned_transfer_target.ts index 1fe15deccc..c7e6732546 100644 --- a/loopx/control_plane/coordination/planned_transfer_target.ts +++ b/loopx/control_plane/coordination/planned_transfer_target.ts @@ -13,7 +13,7 @@ import {withVerifiedGoalTransferPayload, type VerifiedGoalTransferPayload} from import {readGoalTransferWorkspaceReview, retainGoalTransferWorkspace, reviewGoalTransferWorkspace, verifyGoalTransferWorkspaceReview} from "../goals/planned_transfer_workspace.ts"; import {inspectTransferFile, requirePhysicalTransferPath} from "../goals/planned_transfer_container.ts"; -import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys} from "./authority_store_codec.ts"; +import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys, requireAuthorityStoreId} from "./authority_store_codec.ts"; import {verifyAuthorityArchive, restoreAuthorityArchive} from "./authority_archive.ts"; import {auditAuthorityArchive} from "./authority_archive_audit.ts"; import {FileAuthorityStore} from "./file_authority_store.ts"; @@ -22,6 +22,7 @@ import {localAuthorityProviderPaths, publishLocalAuthoritySelection} from "./loc import {engageLegacyCoordinationWriterFenceUnderLocks, loadLegacyCoordinationWriterFence, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, type PlannedTransferWriterFence} from "./legacy_writer_fence.ts"; import {readShadowManagementState, shadowManagementDirectory, withShadowMaintenanceLock} from "./shadow_management.ts"; +import {decodeGoalTransferHistory, inspectGoalTransferHistory, readGoalTransferHistoryJson, retainGoalTransferHistory, type ProtectedGoalTransferHistory} from "../goals/planned_transfer_history.ts"; import {PREPARATION_SCHEMA} from "./planned_transfer_authority.ts"; import {legacyCoordinationTodoLockPath} from "./legacy_writer_lock_paths.ts"; @@ -61,6 +62,7 @@ function protectedStageInput(value: JsonObject, payload: VerifiedGoalTransferPay return {source_preparation: payload.preparation, source_context: payload.context, archive: payload.archive, target: value.target, destination_registry: value.destination_registry, registry_witness: value.registry_witness, execute: true, protected_transport: {artifact_sha256: payload.artifactSha256, materials: payload.materials}, + runtime_history: payload.runtimeHistory, ...(Object.hasOwn(value, "workspace_mapping") ? {workspace_mapping: value.workspace_mapping, expected_workspace_preview_sha256: value.expected_workspace_preview_sha256} : {})}; } @@ -135,6 +137,7 @@ export async function previewProtectedGoalTransfer(value: JsonObject): Promise row), protected_transport_verified: true, privacy_certified: false, host_drain_qualified: false, portable_migration_qualified: false, execution_authority_granted: false, credential_adoption_performed: false}; @@ -156,14 +159,28 @@ export async function stageProtectedGoalTransfer(value: JsonObject): Promise { const protectedInput = value.protected_transport; + const runtimeInput = value.runtime_history; const workspaceRequested = Object.hasOwn(value, "workspace_mapping"); if (!hasExactAuthorityKeys(value, ["source_preparation", "source_context", "archive", "target", "destination_registry", "registry_witness", "execute", ...(protectedInput === undefined ? [] : ["protected_transport"]), + ...(runtimeInput === undefined ? [] : ["runtime_history"]), ...(workspaceRequested ? ["workspace_mapping", "expected_workspace_preview_sha256"] : [])]) || value.execute !== true || typeof value.archive !== "string") reject("Explicit complete Goal transfer staging is required"); const prepared = requireJsonObject(value.source_preparation, "prepared original authority"); if (prepared.schema_version !== PREPARATION_SCHEMA || prepared.authority_write_fenced !== true || prepared.execution_authority_granted !== false) reject("An original source authority preparation is required"); + let history: ProtectedGoalTransferHistory | undefined; + if (runtimeInput !== undefined) { + const raw = requireJsonObject(runtimeInput, "authenticated runtime history"); + if (protectedInput === undefined || !hasExactAuthorityKeys(raw, ["manifest", "files"]) || !Array.isArray(raw.files) || + !same(raw.manifest, prepared.runtime_history)) reject("Original runtime history differs from its prepared source"); + history = {manifest: decodeGoalTransferHistory(raw.manifest), files: raw.files.map(value => { + const file = requireJsonObject(value, "authenticated runtime file"); + if (!hasExactAuthorityKeys(file, ["path", "sha256", "bytes"]) || typeof file.path !== "string" || + typeof file.sha256 !== "string" || typeof file.bytes !== "number") reject("Invalid authenticated runtime content"); + return {path: file.path, sha256: file.sha256, bytes: file.bytes}; + })}; + } const summary = requireJsonObject(prepared.authority_archive, "prepared authority summary"); const plan = planPreparedGoalStage({operation_id: prepared.operation_id, source_context: value.source_context, archive_sha256: summary.archive_sha256, target: value.target, destination_registry: value.destination_registry, @@ -232,6 +249,7 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi if (!pending && (originalState || originalContext)) reject("Destination Goal material is occupied; retain its original owner"); if ((originalState && !originalState.equals(Buffer.from(String(plan.context.state_text)))) || (originalContext && !same(JSON.parse(originalContext.toString("utf8")), plan.context))) reject("Destination Goal material is occupied or changed"); + if (history) await retainGoalTransferHistory(directory, history, pending !== null, completed !== null, false); await retainProtectedMaterials(plan, pending !== null, completed !== null, false); if (plan.workspaceMaterials) { const review = await reviewGoalTransferWorkspace(plan.workspaceMaterials, plan.protectedArtifact!.sha256, target, witness); @@ -259,9 +277,13 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi const audit = await auditAuthorityArchive(value.archive as string, store, archive.archive_sha256); if (audit.status !== "matched") reject("Complete original authority/receipt readback did not match"); await publishLocalAuthoritySelection(root, plan.goalId, target.provider as "file" | "sqlite", identity.store_identity); + const historyReadback = history ? await retainGoalTransferHistory(directory, history, true, completed !== null, true) : null; const result = {schema_version: "loopx_planned_transfer_target_stage_v0", operation_id: plan.operationId, goal_id: plan.goalId, identity_sha256: canonicalAuthoritySha256(plan.identity), original_authority: {...archive}, authority_readback: audit, target_store_identity: identity.store_identity, target_provider: target.provider, + ...(historyReadback ? {runtime_history_sha256: historyReadback.history_sha256, + original_runtime_history_root: join(directory, "runtime-history"), runtime_history_readback_verified: true, + runtime_accounting_adopted: false} : {}), target_is_active: false, fresh_admission_required: true, execution_authority_granted: false, ...(plan.protectedArtifact ? {artifact_sha256: plan.protectedArtifact.sha256, original_material_count: plan.protectedArtifact.materials.length, material_readback_verified: true, @@ -273,3 +295,21 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi original_context_path: plan.contextPath}; })))); } + +/** Read back the retained original operation after restart, without reopening + * its source or inferring an empty live target index means no prior debit. */ +export async function inspectStagedGoalTransferHistory(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id"])) reject("Original staged transfer identity is required"); + const root = await requirePhysicalTransferPath(value.runtime_root); + const operation = requireAuthorityStoreId(value.operation_id, "original transfer operation"); + const goalId = requireAuthorityStoreId(value.goal_id, "original transfer Goal"); + if (!goalId || /[\\/\0]/u.test(goalId) || goalId === "." || goalId === "..") reject("Original transfer Goal is required"); + const directory = join(shadowManagementDirectory(root, goalId), "planned-transfer-targets", canonicalAuthoritySha256(operation)); + await requirePhysicalTransferPath(directory); + const receipt = await readGoalTransferHistoryJson(join(directory, "receipt.json")); + if (receipt.goal_id !== goalId || receipt.operation_id !== operation || receipt.runtime_history_readback_verified !== true || + receipt.original_runtime_history_root !== join(directory, "runtime-history")) reject("Original staged runtime history is unavailable"); + const manifest = decodeGoalTransferHistory(await readGoalTransferHistoryJson(join(directory, "runtime-history", "manifest.json"))); + if (manifest.goal_id !== goalId || canonicalAuthoritySha256(manifest) !== receipt.runtime_history_sha256) reject("Original staged runtime history identity changed"); + return inspectGoalTransferHistory(join(directory, "runtime-history"), manifest); +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 741a24d513..e7a12ce019 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -425,6 +425,7 @@ export function createEffectRuntimeHandlers( ["coordination.planned_transfer.preview_protected", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({previewProtectedGoalTransfer}) => previewProtectedGoalTransfer)], ["coordination.planned_transfer.preview_target", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({previewProtectedGoalTransferTarget}) => previewProtectedGoalTransferTarget)], ["coordination.planned_transfer.stage_protected", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({stageProtectedGoalTransfer}) => stageProtectedGoalTransfer)], + ["coordination.planned_transfer.inspect_target_history", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({inspectStagedGoalTransferHistory}) => inspectStagedGoalTransferHistory)], ["configuration.backup", lazyHandler(() => import("./configuration_backup.ts"), ({configurationBackupOperation}) => configurationBackupOperation)], ["runtime.checkpoint_artifacts", lazyHandler(() => import("./runtime/checkpoint_artifacts.ts"), ({checkpointArtifacts}) => checkpointArtifacts)], ["state.recovery.archive.verify", lazyHandler(() => import("./state_recovery.ts"), ({verifyStateRecoveryArchive}) => verifyStateRecoveryArchive)], diff --git a/loopx/control_plane/goals/planned_transfer_history.ts b/loopx/control_plane/goals/planned_transfer_history.ts new file mode 100644 index 0000000000..eb0b793be2 --- /dev/null +++ b/loopx/control_plane/goals/planned_transfer_history.ts @@ -0,0 +1,181 @@ +/** Goal transfer composition for original closed settlement content. + * + * Journal and quota owners interpret their own bytes. This private carrier + * keeps original receipts/paths intact; it never installs Host/session records, + * active accounting, or target execution authority. Destination admission must + * consume this history rather than interpret an empty live run index as zero. + */ +import {constants} from "node:fs"; +import {copyFile, lstat, mkdir, mkdtemp, open, readdir, rm, rmdir} from "node:fs/promises"; +import {dirname, isAbsolute, join, resolve} from "node:path"; +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; +import {durableWriteJson, publishNewDirectory} from "../effect_runtime_io.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; +import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys, requireAuthorityStoreId} from "../coordination/authority_store_codec.ts"; +import {readGoalTurnJournalsForTransfer} from "../turn_driver/turn_journal_query.ts"; +import {readGoalQuotaHistoryForTransfer} from "../quota/accounting_artifact_transaction.ts"; +import {inspectTransferFile, requirePhysicalTransferPath, type TransferContainerFile} from "./planned_transfer_container.ts"; + +const HISTORY_SCHEMA = "loopx_planned_transfer_runtime_history_v0"; +export interface GoalTransferHistoryFile extends JsonObject {relative_path: string; sha256: string; bytes: number} +export interface GoalTransferHistory extends JsonObject { + schema_version: typeof HISTORY_SCHEMA; goal_id: string; source_runtime_root: string; + turn_history: JsonObject; quota_history: JsonObject; files: GoalTransferHistoryFile[]; +} +export interface ProtectedGoalTransferHistory {manifest: GoalTransferHistory; files: TransferContainerFile[]} +function reject(message: string): never {throw new EffectRuntimeConflictError(message);} +function same(a: unknown, b: unknown): boolean {return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b));} + +export async function readGoalTransferHistoryJson(path: string): Promise { + await requirePhysicalTransferPath(path); + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const info = await handle.stat(); + if (!info.isFile() || info.size > 16 * 1024 * 1024) reject("Runtime history requires bounded regular metadata"); + const bytes = await handle.readFile(); + if (bytes.length > 16 * 1024 * 1024) reject("Runtime history metadata grew beyond its local capacity"); + return requireJsonObject(JSON.parse(new TextDecoder("utf-8", {fatal: true}).decode(bytes)), "runtime history metadata"); + } finally {await handle.close();} +} + +/** These are addresses already selected by their owners, not a runtime sweep. + * The closed set is local to this private transfer carrier; runtime semantics + * remain with the original journal and accounting vocabularies. */ +export function decodeGoalTransferHistory(raw: unknown): GoalTransferHistory { + const value = requireJsonObject(raw, "original runtime history"); + if (!hasExactAuthorityKeys(value, ["schema_version", "goal_id", "source_runtime_root", "turn_history", "quota_history", "files"]) || + value.schema_version !== HISTORY_SCHEMA || typeof value.source_runtime_root !== "string" || + !isAbsolute(value.source_runtime_root) || resolve(value.source_runtime_root) !== value.source_runtime_root || + value.source_runtime_root.includes("\0") || !Array.isArray(value.files) || value.files.length > 20_000) reject("Original runtime history is invalid or unsupported"); + const goal = requireAuthorityStoreId(value.goal_id, "original runtime Goal"); + if (/[\\/\0]/u.test(goal) || goal === "." || goal === "..") reject("Original runtime Goal must be one path segment"); + const turns = requireJsonObject(value.turn_history, "original Turn history"), quota = requireJsonObject(value.quota_history, "original quota history"); + if (turns.closed !== true || turns.turn_hosts_closed !== true || quota.closed !== true || !Array.isArray(turns.files) || !Array.isArray(quota.files)) reject("Original settlement history must be closed by its source owners"); + let previous = ""; + for (const rawFile of value.files) { + const file = requireJsonObject(rawFile, "original runtime file"); + if (!hasExactAuthorityKeys(file, ["relative_path", "sha256", "bytes"]) || typeof file.relative_path !== "string" || + !( /^turns\/[a-f0-9]{64}\.json$/u.test(file.relative_path) || + /^runs\/(?:index\.jsonl|\.transactions\/quota-(?:spend|void)\/[a-f0-9]{24}\.json|[0-9A-Za-z-]+\.(?:json|md))$/u.test(file.relative_path)) || + file.relative_path <= previous || !BARE_SHA256_PATTERN.test(String(file.sha256)) || + typeof file.bytes !== "number" || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > 128 * 1024 * 1024) reject("Original runtime file address or digest is invalid"); + previous = file.relative_path; + } + const owned = [...turns.files, ...quota.files].sort((a, b) => String((a as JsonObject).relative_path) < String((b as JsonObject).relative_path) ? -1 : 1); + if (!same(owned, value.files)) reject("Runtime content differs from its owning inventories"); + return value as GoalTransferHistory; +} + +export function goalTransferHistory(root: string, goal: string, turns: JsonObject, quota: JsonObject): GoalTransferHistory { + return decodeGoalTransferHistory({schema_version: HISTORY_SCHEMA, goal_id: goal, source_runtime_root: root, + turn_history: turns, quota_history: quota, + files: [...turns.files as JsonObject[], ...quota.files as JsonObject[]].sort((a, b) => String(a.relative_path) < String(b.relative_path) ? -1 : 1)}); +} + +export async function goalTransferHistoryInputs(history: GoalTransferHistory): Promise { + const result: TransferContainerFile[] = []; + for (const file of history.files) { + const path = join(history.source_runtime_root, "goals", history.goal_id, file.relative_path); + const observed = await inspectTransferFile(path); + if (!same(observed, {sha256: file.sha256, bytes: file.bytes})) reject("Original runtime history changed before export"); + result.push({path, ...observed}); + } + return result; +} + +async function physicalFiles(directory: string, allowedDirectories: ReadonlySet, suffix = ""): Promise { + const result: string[] = []; + for (const entry of await readdir(join(directory, suffix), {withFileTypes: true})) { + const path = suffix ? `${suffix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (!allowedDirectories.has(path)) reject("Retained runtime history contains unsupported later content"); + result.push(...await physicalFiles(directory, allowedDirectories, path)); + } + else if (entry.isFile()) result.push(path); + else reject("Retained runtime history contains a linked or unsupported entry"); + } + return result.sort(); +} + +/** Reinterpret the original content on the target. The quota owner validates + * source addresses but opens only corresponding retained local bytes; no PID + * is probed and no historical receipt or source path is rewritten. */ +export async function inspectGoalTransferHistory(directory: string, raw: unknown): Promise { + const history = decodeGoalTransferHistory(raw); + await requirePhysicalTransferPath(directory); + if (!(await lstat(directory)).isDirectory()) reject("Retained runtime history is unavailable"); + const manifest = await readGoalTransferHistoryJson(join(directory, "manifest.json")); + if (!same(manifest, history)) reject("Retained runtime history manifest changed"); + const expected = ["manifest.json", ...history.files.map(file => `goals/${history.goal_id}/${file.relative_path}`)].sort(); + const allowedDirectories = new Set(); + for (const path of expected) { + for (let parent = dirname(path); parent !== "."; parent = dirname(parent)) allowedDirectories.add(parent); + } + if (!same(await physicalFiles(directory, allowedDirectories), expected)) reject("Retained runtime history is missing or contains later content; recover its original operation"); + for (const file of history.files) { + const observed = await inspectTransferFile(join(directory, "goals", history.goal_id, file.relative_path)); + if (!same(observed, {sha256: file.sha256, bytes: file.bytes})) reject("Retained runtime history changed; preserve later work"); + } + const turns = await readGoalTurnJournalsForTransfer(directory, history.goal_id); + if (!same(turns, {turns: history.turn_history.turns, files: history.turn_history.files, closed: true})) reject("Original Turn history did not pass its journal owner at the destination"); + const quota = await readGoalQuotaHistoryForTransfer(history.source_runtime_root, history.goal_id, directory); + if (!same(quota, history.quota_history)) reject("Original quota history did not pass its accounting owner at the destination"); + return {history_sha256: canonicalAuthoritySha256(history), turn_history: turns, quota_history: quota, + historical_only: true, runtime_accounting_adopted: false, execution_authority_granted: false}; +} + +async function syncDirectory(path: string): Promise { + const handle = await open(path, constants.O_RDONLY); + try {await handle.sync();} finally {await handle.close();} +} + +/** Publish one complete immutable history, or read back the same operation. + * No missing/modified completed history is recreated from inbound bytes. The + * only unfinished publication we may remove is our exact empty reservation, + * bound to the retained intent and original pending transfer under maintenance. */ +export async function retainGoalTransferHistory(operationDirectory: string, input: ProtectedGoalTransferHistory, + pending: boolean, completed: boolean, execute: boolean): Promise { + const history = decodeGoalTransferHistory(input.manifest), directory = join(operationDirectory, "runtime-history"); + if (input.files.length !== history.files.length) reject("Authenticated runtime content inventory differs"); + await requirePhysicalTransferPath(directory); + let exists = false; + try {await lstat(directory); exists = true;} catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} + const intentPath = join(operationDirectory, "runtime-history-intent.json"); + const intent = {history_sha256: canonicalAuthoritySha256(history)}; + let originalIntent: unknown = null; + try {originalIntent = await readGoalTransferHistoryJson(intentPath);} catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} + if (originalIntent !== null && !same(originalIntent, intent)) reject("Original runtime history publication changed"); + let emptyReservation = false; + if (exists) { + if (!pending || !same(originalIntent, intent)) reject("Destination runtime history is occupied"); + emptyReservation = !completed && (await readdir(directory)).length === 0; + if (!emptyReservation) return inspectGoalTransferHistory(directory, history); + } + if (completed) reject("Completed runtime history is missing; recover the original operation"); + if (!execute) return null; + await durableWriteJson(intentPath, intent); + const temporary = await mkdtemp(join(operationDirectory, ".runtime-history-")); + try { + const directories = new Set([temporary]); + for (const [index, file] of history.files.entries()) { + const source = input.files[index]!, target = join(temporary, "goals", history.goal_id, file.relative_path); + if (source.sha256 !== file.sha256 || source.bytes !== file.bytes) reject("Authenticated runtime content identity differs"); + await requirePhysicalTransferPath(source.path); + await mkdir(dirname(target), {recursive: true, mode: 0o700}); + await copyFile(source.path, target, constants.COPYFILE_EXCL); + const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW); + try {await handle.sync();} finally {await handle.close();} + for (let parent = dirname(target); parent !== temporary; parent = dirname(parent)) directories.add(parent); + } + await durableWriteJson(join(temporary, "manifest.json"), history); + const observed = await inspectGoalTransferHistory(temporary, history); + for (const path of [...directories].sort((a, b) => b.length - a.length)) await syncDirectory(path); + if (emptyReservation) await rmdir(directory); + await publishNewDirectory(temporary, directory); + await syncDirectory(operationDirectory); + await inspectGoalTransferHistory(directory, history); + return observed; + } finally {await rm(temporary, {recursive: true, force: true});} +} diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index a839f48fae..54205cb34c 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -193,6 +193,21 @@ def stage_protected_goal_transfer( return _stage_goal_transfer("coordination.planned_transfer.stage_protected", payload, project, runtime_root, provider) +def inspect_staged_goal_transfer_history( + *, runtime_root: Path, goal_id: str, operation_id: str, +) -> dict[str, Any]: + """Read the original settlement content; this grants no target execution. + + The historical debit index stays in its immutable carrier until the target + accounting owner adopts it. A missing live index must not imply zero usage. + """ + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.inspect_target_history", { + "runtime_root": str(runtime_root.expanduser().absolute()), + "goal_id": goal_id, "operation_id": operation_id, + }, timeout=300.0, retry_safe=True) + return result + + def preview_protected_goal_transfer_target( *, directory: Path, private_key: Path, expected_artifact_sha256: str, project: Path, runtime_root: Path, provider: str, diff --git a/loopx/control_plane/goals/planned_transfer_payload.ts b/loopx/control_plane/goals/planned_transfer_payload.ts index 310cd976d0..1a3b3d9993 100644 --- a/loopx/control_plane/goals/planned_transfer_payload.ts +++ b/loopx/control_plane/goals/planned_transfer_payload.ts @@ -13,7 +13,9 @@ import {PREPARATION_SCHEMA, decodePlannedTransferMaterial, type ProtectedGoalMat import {inspectTransferFile, requirePhysicalTransferPath, withVerifiedTransferContainer, validatePlannedTransferRecipient, writeTransferContainer, type TransferContainerFile} from "./planned_transfer_container.ts"; -const PAYLOAD_SCHEMA = "loopx_planned_transfer_payload_v0"; +import {decodeGoalTransferHistory, goalTransferHistoryInputs, type ProtectedGoalTransferHistory} from "./planned_transfer_history.ts"; + +const PAYLOAD_SCHEMA = "loopx_planned_transfer_payload_v1"; function reject(message: string): never { throw new EffectRuntimeConflictError(message); } function same(a: unknown, b: unknown): boolean { return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b)); } function beneath(root: string, path: string): boolean { @@ -26,7 +28,7 @@ function version(value: unknown): string { } export interface VerifiedGoalTransferPayload { preparation: JsonObject; context: JsonObject; archive: string; artifactSha256: string; - materials: ProtectedGoalMaterial[]; sourceVersion: string; + materials: ProtectedGoalMaterial[]; sourceVersion: string; runtimeHistory: ProtectedGoalTransferHistory; } /** Called only within source preparation's maintenance/context locks, after its @@ -68,9 +70,11 @@ export async function writeProtectedGoalTransfer(reviewed: Awaited(value: JsonObject, const archive = verified.files.get("00000001")!; const summary = await verifyAuthorityArchive(archive); if (!same({...summary}, preparation.authority_archive) || summary.goal_id !== goal.id) reject("Protected transfer differs from its prepared original authority"); - if (!Array.isArray(data.materials) || data.materials.length !== verified.members.length - 1) reject("Protected transfer material inventory differs"); + const history = decodeGoalTransferHistory(preparation.runtime_history); + if (history.goal_id !== goal.id || history.source_runtime_root !== context.runtime_root || + !Array.isArray(data.materials) || data.materials.length + history.files.length !== verified.members.length - 1) reject("Protected transfer material or runtime inventory differs"); const materials: ProtectedGoalMaterial[] = [], seen = new Set(); for (const [index, rawMaterial] of data.materials.entries()) { const material = decodePlannedTransferMaterial(rawMaterial, index), member = verified.members[index + 1]!; @@ -108,6 +114,12 @@ export async function withVerifiedGoalTransferPayload(value: JsonObject, seen.add(material.source_relative_path); materials.push({...material, path: verified.files.get(member.id)!}); } - return consume({preparation, context, archive, materials, artifactSha256: verified.envelopeSha256, sourceVersion: targetVersion}); + const runtimeFiles = history.files.map((file, index) => { + const member = verified.members[index + materials.length + 1]!; + if (member.sha256 !== file.sha256 || member.bytes !== file.bytes) reject("Protected runtime history content differs"); + return {path: verified.files.get(member.id)!, sha256: member.sha256, bytes: member.bytes}; + }); + return consume({preparation, context, archive, materials, artifactSha256: verified.envelopeSha256, sourceVersion: targetVersion, + runtimeHistory: {manifest: history, files: runtimeFiles}}); }); } diff --git a/loopx/control_plane/quota/accounting_artifact_transaction.ts b/loopx/control_plane/quota/accounting_artifact_transaction.ts index 0a2168f048..60cda1b7f3 100644 --- a/loopx/control_plane/quota/accounting_artifact_transaction.ts +++ b/loopx/control_plane/quota/accounting_artifact_transaction.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; -import { access, lstat, readFile, readdir, realpath } from "node:fs/promises"; +import {constants} from "node:fs"; +import { access, lstat, open, readFile, readdir, realpath } from "node:fs/promises"; import { basename, dirname, extname, join, resolve } from "node:path"; import type { JsonObject } from "../effect_program.ts"; @@ -899,29 +900,56 @@ async function ensureReceiptArtifacts( * A prepared or incomplete debit/void must finish through its existing owner * before transfer; directory emptiness is not an external-effect certificate. */ -export async function readGoalQuotaHistoryForTransfer(root: string, goalId: string): Promise { - const runs = join(root, "goals", goalId, "runs"), index = join(runs, "index.jsonl"); - try { - if (await realpath(runs) !== runs) throw new EffectRuntimeRequestError("Transfer quota history contains a linked ancestor"); - } catch (error) { if (!isNodeErrorCode(error, "ENOENT")) throw error; } - await rejectSymlinkPath(index, "transfer quota index"); - const indexBytes = await readOptionalBytes(index), records = parseQuotaAccountingIndex(indexBytes?.toString("utf8") ?? null); +export async function readGoalQuotaHistoryForTransfer(root: string, goalId: string, retainedRoot = root): Promise { + // Receipt bodies keep their original absolute addresses. A retained snapshot + // is read only at the caller's private carrier root; no source path is opened + // or rewritten, and this does not install a target accounting/admission grant. + const originalRuns = join(root, "goals", goalId, "runs"); + const runs = join(retainedRoot, "goals", goalId, "runs"), index = join(runs, "index.jsonl"); + const files = new Map(); + const snapshot = async (path: string): Promise => { + await rejectSymlinkPath(path, "transfer quota history"); + let handle; + try { handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } + catch (error) { if (isNodeErrorCode(error, "ENOENT")) return null; throw error; } + try { + const info = await handle.stat(); + if (!info.isFile() || info.size > 128 * 1024 * 1024 || await realpath(path) !== path) { + throw new EffectRuntimeRequestError("Transfer quota history requires bounded physical regular files"); + } + const bytes = await handle.readFile(); + if (bytes.length > 128 * 1024 * 1024) throw new EffectRuntimeRequestError("Transfer quota history exceeds its local capacity"); + const suffix = path.slice(join(retainedRoot, "goals", goalId).length + 1); + const fact = {relative_path: suffix, sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length}; + const prior = files.get(suffix); + if (prior && canonicalJson(prior) !== canonicalJson(fact)) throw new EffectRuntimeRequestError("Transfer quota history changed during capture"); + files.set(suffix, fact); + return bytes; + } finally { await handle.close(); } + }; + const text = (bytes: Buffer | null) => bytes === null ? null : new TextDecoder("utf-8", {fatal: true}).decode(bytes); + const indexBytes = await snapshot(index), records = parseQuotaAccountingIndex(text(indexBytes)); const receipts: JsonObject[] = []; const seen = new Set(); for (const kind of ["spend", "void"] as const) { const contract = contractFor(kind), directory = join(runs, ".transactions", contract.transactionDirectory); await rejectSymlinkPath(directory, "transfer quota transactions"); let entries: string[]; - try { entries = await readdir(directory); } - catch (error) { if (isNodeErrorCode(error, "ENOENT")) entries = []; else throw error; } + try { + if (await realpath(directory) !== directory) throw new EffectRuntimeRequestError("Transfer quota transactions contain a linked ancestor"); + entries = await readdir(directory); + } catch (error) { if (isNodeErrorCode(error, "ENOENT")) entries = []; else throw error; } for (const name of entries.sort()) { if (!/^[a-f0-9]{24}\.json$/u.test(name)) throw new EffectRuntimeRequestError("Transfer quota transaction has an unattributable address"); - const path = join(directory, name); - await rejectSymlinkPath(path, "transfer quota receipt"); - const receipt = await readReceipt(contract, path, runs); - if (!receipt || receipt.record.goal_id !== goalId || path !== transactionPath(contract, runs, receipt.effect_id)) throw new EffectRuntimeRequestError("Transfer quota receipt identity is unavailable"); + const path = join(directory, name), raw = text(await snapshot(path)); + if (raw === null) throw new EffectRuntimeRequestError("Transfer quota receipt is unavailable"); + const receipt = receiptObject(contract, JSON.parse(raw)); + // Reuse the transaction owner's complete identity/path/GoalRef checks. + validateReceiptPaths(contract, originalRuns, receipt); + if (receipt.record.goal_id !== goalId || path !== transactionPath(contract, runs, receipt.effect_id)) throw new EffectRuntimeRequestError("Transfer quota receipt identity is unavailable"); const resolution = resolveQuotaAccountingEffect(kind, records, receipt.effect_id, receipt.goal_ref ?? null); - const body = await readOptionalText(receipt.json_path), markdown = await readOptionalText(receipt.markdown_path); + const body = text(await snapshot(join(runs, basename(receipt.json_path)))); + const markdown = text(await snapshot(join(runs, basename(receipt.markdown_path)))); let bodyMatches = false; if (body !== null) { try { bodyMatches = canonicalJson(JSON.parse(body)) === canonicalJson(receipt.record); } @@ -942,7 +970,8 @@ export async function readGoalQuotaHistoryForTransfer(root: string, goalId: stri } } const facts = {closed: receipts.every(row => row.closed === true), receipts, - index_sha256: indexBytes === null ? null : sha256Bytes(indexBytes)}; + index_sha256: indexBytes === null ? null : sha256Bytes(indexBytes), + files: [...files.values()].sort((a, b) => String(a.relative_path) < String(b.relative_path) ? -1 : 1)}; return {...facts, sha256: canonicalAuthoritySha256(facts)}; } diff --git a/loopx/control_plane/turn_driver/turn_journal_query.ts b/loopx/control_plane/turn_driver/turn_journal_query.ts index 737a3075d3..63efbc346a 100644 --- a/loopx/control_plane/turn_driver/turn_journal_query.ts +++ b/loopx/control_plane/turn_driver/turn_journal_query.ts @@ -58,7 +58,7 @@ function journalConflict(message: string): never { throw new EffectRuntimeConflictError(message, "journal_readback_conflict"); } -async function journalSnapshot(path: string): Promise<{value: JsonObject; sha256: string}> { +async function journalSnapshot(path: string): Promise<{value: JsonObject; sha256: string; bytes: number}> { const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { if (!(await handle.stat()).isFile()) journalConflict("Turn journal snapshot is not a regular file"); @@ -66,16 +66,15 @@ async function journalSnapshot(path: string): Promise<{value: JsonObject; sha256 let value: JsonObject; try { value = requireJsonObject(JSON.parse(new TextDecoder("utf-8", {fatal: true}).decode(bytes)), "Turn journal"); } catch { journalConflict("Turn journal history is unreadable; reconcile it before retrying"); } - return {value, sha256: createHash("sha256").update(bytes).digest("hex")}; + return {value, sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length}; } finally { await handle.close(); } } -/** Call while source preparation owns Goal maintenance. The existing journal - * interpreter decides terminal/uncertain effects. Actual process readback is a - * separate requirement: a terminal journal cannot certify its Host exited. - * This does not cover Hosts outside Turn execution or external effect owners. +/** Interpret original journal content without process observation. Source + * preparation owns maintenance; target readback uses its immutable carrier. + * Actual process drain belongs to the source-only composition below. */ -export async function readGoalTurnHistoryForTransfer(root: string, goalId: string): Promise { +export async function readGoalTurnJournalsForTransfer(root: string, goalId: string): Promise { const directory = join(root, "goals", goalId, "turns"); let entries: Dirent[]; try { @@ -83,11 +82,12 @@ export async function readGoalTurnHistoryForTransfer(root: string, goalId: strin entries = await readdir(directory, {withFileTypes: true}); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") entries = []; else throw error; } - const turns: JsonObject[] = []; + const turns: JsonObject[] = [], files: JsonObject[] = []; for (const entry of entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) { if (!entry.name.endsWith(".json")) continue; if (!/^[a-f0-9]{64}\.json$/u.test(entry.name) || !entry.isFile()) journalConflict("Transfer source has an unattributable Turn journal; reconcile its owning operation"); - const {value: journal, sha256} = await journalSnapshot(join(directory, entry.name)); + const {value: journal, sha256, bytes} = await journalSnapshot(join(directory, entry.name)); + files.push({relative_path: `turns/${entry.name}`, sha256, bytes}); const key = `sha256:${entry.name.slice(0, -5)}`; const envelope = jsonObject(jsonObject(journal.plan)?.turn_envelope); const agent = envelope?.agent_id; @@ -99,7 +99,20 @@ export async function readGoalTurnHistoryForTransfer(root: string, goalId: strin observed.recovery_decision.action === "return_existing", recorded_effects: observed.recorded_effects, violations: observed.violations}); } + return {turns, files, closed: turns.every(row => row.closed === true)}; +} + +/** Actual source-only Host observation. Retained target history is interpreted + * by the journal owner above; it never probes a copied PID on another machine. */ +export async function readGoalTurnHistoryForTransfer(root: string, goalId: string): Promise { + const observed = await readGoalTurnJournalsForTransfer(root, goalId); + const turns = observed.turns as JsonObject[]; let hosts: JsonObject[] = []; + // Also enumerate an otherwise orphan .hosts directory. Missing journal + // files must not turn known process evidence into an empty inventory. + let entries: Dirent[]; + try { entries = await readdir(join(root, "goals", goalId, "turns"), {withFileTypes: true}); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") entries = []; else throw error; } if (turns.length || entries.some(entry => entry.name.endsWith(".hosts"))) { const python = process.env.LOOPX_EFFECT_RUNTIME_PYTHON; if (!python || !isAbsolute(python) || python.includes("\0")) journalConflict("Host drain requires the source-selected process owner adapter"); @@ -135,7 +148,7 @@ export async function readGoalTurnHistoryForTransfer(root: string, goalId: strin return Number.isSafeInteger(count) && Number(count) > 0 && actual.length === count && actual.every(host => host.record_sha256 !== null && ["drained", "not_launched"].includes(String(host.drain))); }); - const facts = {turns, hosts, turn_hosts_closed: turnHostsClosed}; + const facts = {turns, files: observed.files, hosts, turn_hosts_closed: turnHostsClosed}; return {...facts, closed: turns.every(row => row.closed === true), sha256: canonicalAuthoritySha256(facts)}; } diff --git a/tests/control_plane/test_planned_transfer_source_boundary.py b/tests/control_plane/test_planned_transfer_source_boundary.py index 4c306eb722..e37d89f837 100644 --- a/tests/control_plane/test_planned_transfer_source_boundary.py +++ b/tests/control_plane/test_planned_transfer_source_boundary.py @@ -27,6 +27,7 @@ prepare_registered_goal_transfer, preview_registered_goal_transfer, preview_protected_goal_transfer, preview_protected_goal_transfer_target, read_goal_transfer_context, stage_protected_goal_transfer, + inspect_staged_goal_transfer_history, ) from loopx.control_plane.projects.registry_codec import load_project_registry, project_registry_transaction from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime @@ -418,6 +419,13 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: assert export_registered_goal_transfer(registry_path=global_registry, goal_id="report", operation_id="prepared-move", expected_preview_sha256=preview["preview_sha256"], recipient=recipient, destination=bundle, materials=[(project / "input.txt", material_hash)]) == exported + runtime_files = { + row["relative_path"]: (runtime / "goals/report" / row["relative_path"]).read_bytes() + for owner in (preview["turn_history"], preview["quota_history"]) for row in owner["files"] + } + assert journal_path.relative_to(runtime / "goals/report").as_posix() in runtime_files + assert "runs/index.jsonl" in runtime_files + assert len([name for name in runtime_files if "/quota-spend/" in name]) == 1 incoming = tmp_path / "target-local-container" shutil.copytree(bundle, incoming) shutil.rmtree(bundle) @@ -436,6 +444,8 @@ def update(operation: str, note: str, *, expected_code: int = 0) -> dict: assert observed["goal_id"] == "report" and observed["display_name"] == "Current original Goal" assert observed["original_authority"] == prepared["authority_archive"] assert observed["execution_authority_granted"] is False + assert observed["original_runtime_history"]["turn_history"] == preview["turn_history"] + assert observed["original_runtime_history"]["quota_history"] == preview["quota_history"] assert observed["materials"] == [{"id": "00000002", "source_relative_path": "input.txt", "sha256": material_hash, "bytes": 6}] with pytest.raises(EffectRuntimeRemoteError, match="same source and destination package version"): effect_runtime_result("coordination.planned_transfer.preview_protected", { @@ -510,6 +520,28 @@ def concurrent_registry_edit(method, payload, **kwargs): assert staged["execution_authority_granted"] is False assert staged["target_is_active"] is False and staged["fresh_admission_required"] is True assert staged["authority_readback"]["status"] == "matched" + assert staged["runtime_history_readback_verified"] is True + history_root = Path(staged["original_runtime_history_root"]) + history_goal = history_root / "goals/report" + assert {path.relative_to(history_goal).as_posix(): path.read_bytes() + for path in history_goal.rglob("*") if path.is_file()} == runtime_files + assert not (target_runtime / "goals/report/runs/index.jsonl").exists() + assert not (target_runtime / "goals/report/turns").exists() + assert not list(history_root.rglob("*.hosts")) + history_request = dict(runtime_root=target_runtime, goal_id="report", operation_id="prepared-move") + readback = inspect_staged_goal_transfer_history(**history_request) + assert readback["quota_history"] == preview["quota_history"] + assert readback["turn_history"] == {key: preview["turn_history"][key] for key in ("turns", "files", "closed")} + assert readback["historical_only"] is True + assert readback["runtime_accounting_adopted"] is False + assert readback["execution_authority_granted"] is False + # Exercise the ordinary original quota replay reader over these + # retained bytes with the original source unavailable. It must not + # debit, repair receipts, or try to open their old source paths. + original_replay = replay_quota_spend_by_effect_ref(history_root, goal_id="report", + effect_ref=debit["effect_id"], agent_id="agent-a", read_only=True) + assert original_replay["ok"] is True and original_replay["appended"] is False + assert original_replay["idempotent_replay"] is True assert staged["material_readback_verified"] is True and staged["original_material_count"] == 1 assert staged["workspace_materials_readback_verified"] is True assert target_input.read_bytes() == b"6\n7\n8\n" @@ -572,6 +604,22 @@ def target_cli(*arguments: str, expected_code: int = 0) -> dict: current_preview = preview_protected_goal_transfer_target(**target_request) assert current_preview["resuming_original_operation"] is True assert current_preview["reviewed_preview_sha256"] == request["expected_workspace_preview_sha256"] + retained_journal = history_goal / journal_path.relative_to(runtime / "goals/report") + original_journal = retained_journal.read_bytes() + retained_journal.write_bytes(original_journal + b"\n") + with pytest.raises(EffectRuntimeRemoteError, match="runtime history changed"): + inspect_staged_goal_transfer_history(**history_request) + with pytest.raises(EffectRuntimeRemoteError, match="runtime history changed"): + stage_protected_goal_transfer(**request) + assert retained_journal.read_bytes() == original_journal + b"\n" + assert target_registry.read_bytes() == original_registry + retained_journal.write_bytes(original_journal) + retained_journal.unlink() + with pytest.raises(EffectRuntimeRemoteError, match="runtime history is missing"): + stage_protected_goal_transfer(**request) + assert not retained_journal.exists() + retained_journal.write_bytes(original_journal) + assert inspect_staged_goal_transfer_history(**history_request) == readback target_input.write_bytes(b"Later workspace result must survive") with pytest.raises(EffectRuntimeRemoteError, match="workspace material changed or is occupied"): stage_protected_goal_transfer(**request) diff --git a/tests/control_plane_ts/planned_transfer_history.test.ts b/tests/control_plane_ts/planned_transfer_history.test.ts new file mode 100644 index 0000000000..4355049429 --- /dev/null +++ b/tests/control_plane_ts/planned_transfer_history.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import {copyFile, mkdtemp, mkdir, readFile, readdir, realpath, rename, rm, symlink, writeFile} from "node:fs/promises"; +import {join} from "node:path"; +import {tmpdir} from "node:os"; +import test, {type TestContext} from "node:test"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {evaluateQuotaSpendCommit, QUOTA_SPEND_COMMIT_REQUEST_SCHEMA} from "../../loopx/control_plane/quota/spend_commit.ts"; +import {evaluateQuotaVoidCommit, QUOTA_VOID_COMMIT_REQUEST_SCHEMA} from "../../loopx/control_plane/quota/void_commit.ts"; +import {readGoalQuotaHistoryForTransfer} from "../../loopx/control_plane/quota/accounting_artifact_transaction.ts"; +import {readGoalTurnHistoryForTransfer} from "../../loopx/control_plane/turn_driver/turn_journal_query.ts"; +import {decodeGoalTransferHistory, goalTransferHistory, goalTransferHistoryInputs, + inspectGoalTransferHistory, retainGoalTransferHistory} from "../../loopx/control_plane/goals/planned_transfer_history.ts"; + +async function fixture(t: TestContext) { + const directory = await realpath(await mkdtemp(join(tmpdir(), "loopx-transfer-history-"))); + t.after(() => rm(directory, {recursive: true, force: true})); + const root = join(directory, "source"), destination = join(directory, "destination"), incoming = join(directory, "incoming"); + await mkdir(incoming); await mkdir(destination); + const before = {should_run: true, normal_delivery_allowed: true, recovery_delivery_allowed: false, + effective_action: "advance", self_repair_allowed: false, capability_repair_allowed: false, + workspace_repair_allowed: false, state: "eligible", safe_bypass_allowed: false, safe_bypass_kind: null, + blocked_action_scope: null, compute: 1, window_hours: 24, slot_minutes: 1, spent_slots: 0, allowed_slots: 1440}; + const after = {...before, spent_slots: 1}; + const spend = await evaluateQuotaSpendCommit({schema_version: QUOTA_SPEND_COMMIT_REQUEST_SCHEMA, + effect_id: "original-effect#quota_spend", runtime_root: root, goal_id: "report", source: "heartbeat", + generated_at: "2026-08-25T12:00:00+08:00", execute: true, expected_index_digest: null, + resolved_agent_id: "agent-a", before, after, + preview: {ok: true, mode: "spend-slot", dry_run: true, goal_id: "report", slots: 1, agent_id: "agent-a", + before, after, appended: false, registry_mutated: false, delivery_completion_spend: false, + safe_bypass_spend: false, delivery_workspace_validated: false}}); + assert.equal(spend.status, "written"); + const index = String(spend.payload.index_path); + const voided = await evaluateQuotaVoidCommit({schema_version: QUOTA_VOID_COMMIT_REQUEST_SCHEMA, + operation: "commit", effect_id: "original-void", runtime_root: root, goal_id: "report", + voided_run_generated_at: "2026-08-25T12:00:00+08:00", source: "heartbeat", reason_summary: "Original correction", + generated_at: "2026-08-25T12:01:00+08:00", execute: true, + expected_index_digest: spend.index_digest, + before: {...after, quota: {spent_slots: 1, allowed_slots: 1440, window_hours: 24, compute: 1, slot_minutes: 1}}}); + assert.equal(voided.status, "written"); + const quota = await readGoalQuotaHistoryForTransfer(root, "report"); + assert.equal(quota.closed, true); assert.equal((quota.receipts as JsonObject[]).length, 2); + const manifest = goalTransferHistory(root, "report", await readGoalTurnHistoryForTransfer(root, "report"), quota); + const originalFiles = await goalTransferHistoryInputs(manifest); + const files = []; + for (const [n, file] of originalFiles.entries()) { + const path = join(incoming, String(n)); await copyFile(file.path, path); files.push({...file, path}); + } + const original = await readFile(index); + await rename(root, `${root}-unavailable`); + return {directory, root, destination, input: {manifest, files}, quota, original}; +} + +test("retained spend/void content passes original owners after source loss, with byte-identical receipts", async t => { + const f = await fixture(t); + const result = await retainGoalTransferHistory(f.destination, f.input, true, false, true); + assert.equal(result?.historical_only, true); + assert.equal(result?.runtime_accounting_adopted, false); + assert.equal(result?.execution_authority_granted, false); + const retained = join(f.destination, "runtime-history"); + assert.deepEqual(await readFile(join(retained, "goals/report/runs/index.jsonl")), f.original); + assert.deepEqual(await readGoalQuotaHistoryForTransfer(f.root, "report", retained), f.quota); + const replay = await evaluateQuotaSpendCommit({schema_version: QUOTA_SPEND_COMMIT_REQUEST_SCHEMA, + operation: "replay", runtime_root: retained, goal_id: "report", effect_id: "original-effect#quota_spend", + resolved_agent_id: "agent-a", read_only: true}); + assert.equal(replay.status, "replayed"); assert.equal(replay.payload.appended, false); + assert.deepEqual(await retainGoalTransferHistory(f.destination, f.input, true, true, true), result); + assert.deepEqual(await inspectGoalTransferHistory(retained, f.input.manifest), result); + // Historical bodies remain original, including their source addresses. + const transaction = f.input.manifest.files.find(row => row.relative_path.includes("quota-spend/"))!; + const bytes = await readFile(join(retained, "goals/report", transaction.relative_path)); + assert.match(bytes.toString(), new RegExp(f.root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.deepEqual(bytes, await readFile(join(`${f.root}-unavailable`, "goals/report", transaction.relative_path))); +}); + +test("missing, changed, linked and later history is never repaired from inbound bytes", async t => { + const f = await fixture(t); + await retainGoalTransferHistory(f.destination, f.input, true, false, true); + const root = join(f.destination, "runtime-history"), file = f.input.manifest.files.find(row => row.relative_path.endsWith(".json"))!; + const path = join(root, "goals/report", file.relative_path), original = await readFile(path); + await writeFile(path, Buffer.concat([original, Buffer.from("\n")])); + await assert.rejects(retainGoalTransferHistory(f.destination, f.input, true, true, true), /history changed/); + assert.deepEqual(await readFile(path), Buffer.concat([original, Buffer.from("\n")])); + await rm(path); + await assert.rejects(retainGoalTransferHistory(f.destination, f.input, true, true, true), /history is missing/); + await symlink(f.input.files[0].path, path); + await assert.rejects(inspectGoalTransferHistory(root, f.input.manifest), /linked/); + await rm(path); await writeFile(path, original); + await writeFile(join(root, "later-result.txt"), "Later result must survive"); + await assert.rejects(retainGoalTransferHistory(f.destination, f.input, true, true, true), /later content/); + assert.equal(await readFile(join(root, "later-result.txt"), "utf8"), "Later result must survive"); + await rm(join(root, "later-result.txt")); + await rm(root, {recursive: true}); + await assert.rejects(retainGoalTransferHistory(f.destination, f.input, true, true, true), /Completed runtime history is missing/); +}); + +test("an original empty publication reservation recovers; an unowned occupied directory does not", async t => { + const f = await fixture(t), root = join(f.destination, "runtime-history"); + await mkdir(root); + await assert.rejects(retainGoalTransferHistory(f.destination, f.input, true, false, true), /occupied/); + await writeFile(join(f.destination, "runtime-history-intent.json"), JSON.stringify({history_sha256: canonicalAuthoritySha256(f.input.manifest)})); + await retainGoalTransferHistory(f.destination, f.input, true, false, true); + assert.deepEqual((await readdir(root)).sort(), ["goals", "manifest.json"]); + await mkdir(join(root, "unknown-empty-directory")); + await assert.rejects(inspectGoalTransferHistory(root, f.input.manifest), /unsupported|later content/); +}); + +test("carrier rejects false closure and addresses outside the original owner inventories", async t => { + const f = await fixture(t), manifest = f.input.manifest; + for (const relative_path of ["../registry.json", "/etc/passwd", "turns/attempt-1.json", "turns/" + "a".repeat(64) + ".hosts/attempt-1.json"]) { + assert.throws(() => decodeGoalTransferHistory({...manifest, files: [{relative_path, sha256: "a".repeat(64), bytes: 1}]}), /address/); + } + assert.throws(() => decodeGoalTransferHistory({...manifest, quota_history: {...manifest.quota_history, closed: false}}), /closed/); + assert.throws(() => decodeGoalTransferHistory({...manifest, files: []}), /inventories/); + assert.throws(() => decodeGoalTransferHistory({...manifest, files: [manifest.files[0], manifest.files[0]]}), /address/); +}); From bce33263055696b4014113cf15022d70486ae3d7 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:20:19 +0800 Subject: [PATCH 09/55] fix(goals): recover cancelled source transfer preparation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../coordination/legacy_writer_fence.ts | 24 ++ .../planned_transfer_authority.ts | 139 ++++++- .../control_plane/effect_runtime_handlers.ts | 1 + loopx/control_plane/goals/planned_transfer.ts | 25 +- .../goals/planned_transfer_io.py | 14 + .../test_planned_transfer_cancellation.py | 376 ++++++++++++++++++ .../planned_transfer_authority.test.ts | 5 +- .../planned_transfer_cancellation.test.ts | 52 +++ 8 files changed, 610 insertions(+), 26 deletions(-) create mode 100644 tests/control_plane/test_planned_transfer_cancellation.py create mode 100644 tests/control_plane_ts/planned_transfer_cancellation.test.ts diff --git a/loopx/control_plane/coordination/legacy_writer_fence.ts b/loopx/control_plane/coordination/legacy_writer_fence.ts index 07cf33e6ea..c50d0755d3 100644 --- a/loopx/control_plane/coordination/legacy_writer_fence.ts +++ b/loopx/control_plane/coordination/legacy_writer_fence.ts @@ -230,6 +230,30 @@ export async function preparePlannedTransferWriterFenceUnderLock(root: string, g }); } +/** Preparation-only cancellation after the Goal owner durably records its + * decision under maintenance/source locks. Restore the exact qualified normal + * source fence, never remove a fence or manufacture a new creation grant. */ +export async function restorePlannedTransferWriterFenceUnderLock(root: string, goalId: string, + prepared: PlannedTransferWriterFence, prior: JsonObject): Promise { + decodeLegacyCoordinationWriterFence(prepared); + decodeLegacyCoordinationWriterFence(prior); + if (prepared.goal_id !== goalId || prepared.schema_version !== PLANNED_TRANSFER_WRITER_FENCE_SCHEMA || + prepared.role !== "source" || prior.goal_id !== goalId || prior.schema_version !== NEW_GOAL_WRITER_FENCE_SCHEMA || + prior.creation_completed !== true) throw new Error("Invalid source preparation cancellation fence"); + const path = legacyCoordinationWriterFencePath(root, goalId); + await withFileMutationLock(path, async () => { + const current = await loadLegacyCoordinationWriterFence(root, goalId); + if (current.status !== "loaded") throw new Error("Original source writer fence is unavailable"); + if (canonicalAuthorityBytes(current.fence).equals(canonicalAuthorityBytes(prior))) return; + if (!canonicalAuthorityBytes(current.fence).equals(canonicalAuthorityBytes(prepared))) throw new Error("Source writer fence changed before cancellation"); + await atomicWriteJson(path, prior); + const after = await loadLegacyCoordinationWriterFence(root, goalId); + if (after.status !== "loaded" || !canonicalAuthorityBytes(after.fence).equals(canonicalAuthorityBytes(prior))) { + throw new Error("Cancelled source writer fence readback failed"); + } + }); +} + export async function loadLegacyCoordinationWriterFence( root: string, goalId: string, diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index 2f9b5b4343..eb8f42c8ab 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -15,24 +15,29 @@ import {requireJsonObject} from "../runtime_decode.ts"; import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys, requireAuthorityStoreId} from "./authority_store_codec.ts"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import {exportAuthorityArchive, verifyAuthorityArchive, type AuthorityArchiveSummary} from "./authority_archive.ts"; +import {AUTHORITY_ARCHIVE_SCHEMA} from "./authority_archive_read.ts"; import {indexCoordinationProjection} from "./coordination_projection.ts"; import {canonicalTaskLease} from "./task_lease_state.ts"; import {openLocalAuthorityStoreHandle, requireLocalAuthorityRuntimeRoot} from "./local_authority_provider.ts"; import {loadLegacyCoordinationWriterFence, preparePlannedTransferWriterFenceUnderLock, - PLANNED_TRANSFER_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; + restorePlannedTransferWriterFenceUnderLock, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, + type PlannedTransferWriterFence} from "./legacy_writer_fence.ts"; import {readShadowManagementState, shadowManagementDirectory, withShadowMaintenanceLock} from "./shadow_management.ts"; -import {PREPARATION_SCHEMA, withVerifiedGoalTransferContext} from "../goals/planned_transfer.ts"; +import {PREPARATION_SCHEMA, readGoalTransferSnapshot, withVerifiedGoalTransferContext} from "../goals/planned_transfer.ts"; import {reviewProtectedGoalTransferInputs, writeProtectedGoalTransfer} from "../goals/planned_transfer_payload.ts"; import type {AuthorityStore} from "./authority_store.ts"; import {newGoalCreationBinding} from "./new_goal_initialization.ts"; import {readPromotionReceipt} from "./promotion_receipt.ts"; import {NEW_GOAL_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; -import {readGoalTurnHistoryForTransfer} from "../turn_driver/turn_journal_query.ts"; -import {goalTransferHistory} from "../goals/planned_transfer_history.ts"; +import {readGoalTurnHistoryForTransfer, readGoalTurnJournalsForTransfer} from "../turn_driver/turn_journal_query.ts"; +import {goalTransferHistory, readGoalTransferHistoryJson} from "../goals/planned_transfer_history.ts"; import {readGoalQuotaHistoryForTransfer} from "../quota/accounting_artifact_transaction.ts"; export {PREPARATION_SCHEMA} from "../goals/planned_transfer.ts"; const REGISTERED_SOURCE_PREVIEW_SCHEMA = "loopx_planned_transfer_source_preview_v0"; +// Local to this Goal-owned preparation protocol; no destination grant or new +// runtime lifecycle vocabulary is introduced by this retained decision. +const CANCELLATION_SCHEMA = "loopx_planned_transfer_source_cancellation_v0"; /** The codec calls while holding its original registry transaction. Ordinary * metadata can no longer reopen, remove or redirect a prepared Goal. Changes @@ -88,7 +93,7 @@ export async function checkPlannedTransferRegistryWrite(value: JsonObject): Prom function reject(message: string): never { throw new EffectRuntimeConflictError(message); } function same(a: unknown, b: unknown): boolean { return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b)); } async function optionalJson(path: string): Promise { - try { return requireJsonObject(JSON.parse(await readFile(path, "utf8")), "transfer preparation"); } + try { return await readGoalTransferHistoryJson(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } } async function retain(path: string, value: JsonObject): Promise { @@ -107,9 +112,8 @@ async function retain(path: string, value: JsonObject): Promise { * Imported/legacy origins need their own original-registration qualification; * the lower-level archive/preparation owners remain available without claiming it. */ -async function registeredSourcePreview(context: JsonObject, originalFence: JsonObject, - store: AuthorityStore, provider: string, head: {head: JsonObject; cursor: string; provider_revision: string}, - storeIdentity: string, goalId: string, operationId: string): Promise { +async function requireRegisteredSourceOrigin(context: JsonObject, originalFence: JsonObject, + store: AuthorityStore, goalId: string): Promise { const goal = requireJsonObject(context.goal, "original registered Goal"); if (goal.status !== "active") reject("Transfer requires the current registered Goal; recover its original lifecycle before preparing a move"); const coordination = requireJsonObject(goal.coordination, "original Goal coordination"); @@ -124,6 +128,12 @@ async function registeredSourcePreview(context: JsonObject, originalFence: JsonO !(await readPromotionReceipt(store, binding.identity)).matched) { reject("Transfer source differs from its original registered creation and retained receipt; repair that source without recreating it"); } +} + +async function registeredSourcePreview(context: JsonObject, originalFence: JsonObject, + store: AuthorityStore, provider: string, head: {head: JsonObject; cursor: string; provider_revision: string}, + storeIdentity: string, goalId: string, operationId: string): Promise { + await requireRegisteredSourceOrigin(context, originalFence, store, goalId); const index = indexCoordinationProjection(head.head, goalId); const retainedLeases = [...index.leases].map(([todo, lease]) => canonicalTaskLease(lease, goalId, todo)) .filter(lease => lease.status !== "released"); @@ -149,6 +159,7 @@ export async function previewRegisteredAuthorityForPlannedTransfer(value: JsonOb const operationId = requireAuthorityStoreId(value.operation_id, "transfer operation id"); return withShadowMaintenanceLock(root, goalId, () => withVerifiedGoalTransferContext( value.source_context, value.registry_witness, goalId, root, async context => { + await requireUncancelledPreparation(join(shadowManagementDirectory(root, goalId), "planned-transfers", canonicalAuthoritySha256(operationId))); const managed = await readShadowManagementState(root, goalId); if (managed && managed.status !== "inactive") reject("Source capture requires disposition before transfer preview"); const current = await loadLegacyCoordinationWriterFence(root, goalId); @@ -196,6 +207,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro expected_writer_fence_sha256: value.expected_writer_fence_sha256}; const directory = join(shadowManagementDirectory(root, goal), "planned-transfers", canonicalAuthoritySha256(operation)); return withShadowMaintenanceLock(root, goal, async () => { + await requireUncancelledPreparation(directory); const prepare = async (context?: JsonObject) => { const managed = await readShadowManagementState(root, goal); if (managed && managed.status !== "inactive") reject("Source capture requires disposition before planned transfer"); @@ -233,8 +245,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro if (!same(pending, {...facts, original_writer_fence: priorFence})) reject("Transfer source or original operation changed"); // A carrier is written before the fence, so an interrupted first call may // still find the exact original fence. Neither case permits other bytes. - const expectedFence = {schema_version: PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, state: "engaged", goal_id: goal, - role: "source", fence_id: `planned-transfer:${operation}`, operation_id: operation, source_identity_sha256: canonicalAuthoritySha256(pending)}; + const expectedFence = sourcePreparationFence(goal, operation, pending); if (!same(current.fence, priorFence) && !same(current.fence, expectedFence)) reject("Source writer fence changed during transfer preparation"); } else { if (current.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) reject("Source is already prepared by another transfer operation"); @@ -250,11 +261,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro const protectedInputs = protectedExport ? await reviewProtectedGoalTransferInputs(value.protected_transport, context!, value.source_package_version) : undefined; await mkdir(directory, {recursive: true, mode: 0o700}); await retain(pendingPath, preparation); - await preparePlannedTransferWriterFenceUnderLock(root, goal, priorFence, { - schema_version: PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, state: "engaged", goal_id: goal, - role: "source", fence_id: `planned-transfer:${operation}`, operation_id: operation, - source_identity_sha256: canonicalAuthoritySha256(preparation), - }); + await preparePlannedTransferWriterFenceUnderLock(root, goal, priorFence, sourcePreparationFence(goal, operation, preparation)); // Export reuses the native retained-journal owner and its independent audit. // The fence stays closed on every error, including interrupted publication. const archive = join(directory, "authority.ndjson"); @@ -280,3 +287,105 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro return contextBound ? withVerifiedGoalTransferContext(value.source_context, value.registry_witness, goal, root, prepare) : prepare(); }); } + +function sourcePreparationFence(goal: string, operation: string, pending: JsonObject): PlannedTransferWriterFence { + return {schema_version: PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, state: "engaged", goal_id: goal, + role: "source", fence_id: `planned-transfer:${operation}`, operation_id: operation, + source_identity_sha256: canonicalAuthoritySha256(pending)}; +} + +async function requireUncancelledPreparation(directory: string): Promise { + if (await optionalJson(join(directory, "cancelled.json"))) reject("This source preparation was cancelled; review a new transfer operation"); +} + +/** Abandon only a qualified, unchanged local preparation. There is no cutover + * grant in this protocol. Future cutover must be mutually exclusive with this + * decision under the same maintenance owner; a preparation receipt alone must + * never authorize a destination. + * + * Retain the cancellation BEFORE restoring the original fence. Cold recovery + * may finish that exact CAS, but a historical retry never rewinds later source + * work, a different transfer, registry metadata, receipts or provider history. + */ +export async function cancelSourcePreparation(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id", "execute"]) || value.execute !== true) { + reject("Source preparation cancellation requires its original address and explicit execution"); + } + const root = requireLocalAuthorityRuntimeRoot(value.runtime_root); + const goal = requireAuthorityStoreId(value.goal_id, "source Goal id"), operation = requireAuthorityStoreId(value.operation_id, "transfer operation id"); + const directory = join(shadowManagementDirectory(root, goal), "planned-transfers", canonicalAuthoritySha256(operation)); + return withShadowMaintenanceLock(root, goal, async () => { + const pending = await optionalJson(join(directory, "preparation.json")); + if (!pending || pending.schema_version !== PREPARATION_SCHEMA || !hasExactAuthorityKeys(pending, + ["schema_version", "request", "source_provider", "source_store_identity", "source_cursor", "source_projection_sha256", + "source_turn_history", "source_quota_history", "source_context", "source_context_sha256", "original_writer_fence"])) { + reject("Cancellation requires the retained original registered source preparation"); + } + const request = requireJsonObject(pending.request, "original preparation request"); + const context = requireJsonObject(pending.source_context, "original registered source context"); + const prior = requireJsonObject(pending.original_writer_fence, "original source fence"); + if (!hasExactAuthorityKeys(request, ["goal_id", "operation_id", "expected_provider_revision", "expected_writer_fence_sha256"]) || + request.goal_id !== goal || request.operation_id !== operation || context.runtime_root !== root || + pending.source_context_sha256 !== canonicalAuthoritySha256(context) || request.expected_writer_fence_sha256 !== canonicalAuthoritySha256(prior)) { + reject("Cancellation source identity differs from its original preparation"); + } + const receipt = await optionalJson(join(directory, "receipt.json")); + if (receipt && (!hasExactAuthorityKeys(receipt, ["schema_version", "operation_id", "goal_id", "authority_write_fenced", + "authority_archive", "archive", "runtime_history", "source_context_sha256", "original_registration_verified", + "host_drain_qualified", "portable_artifact_qualified", "execution_authority_granted"]) || + receipt.schema_version !== PREPARATION_SCHEMA || receipt.operation_id !== operation || receipt.goal_id !== goal || + receipt.authority_write_fenced !== true || receipt.original_registration_verified !== true || + receipt.source_context_sha256 !== pending.source_context_sha256 || receipt.host_drain_qualified !== false || + receipt.portable_artifact_qualified !== false || receipt.execution_authority_granted !== false)) { + reject("Cancellation requires a registered preparation-only receipt; preserve any later transfer phase"); + } + if (receipt) { + const archive = requireJsonObject(receipt.authority_archive, "original preparation archive identity"); + if (!hasExactAuthorityKeys(archive, ["schema_version", "goal_id", "source_provider", "source_store_identity", + "source_provider_revision", "commits", "projection_sha256", "archive_sha256"]) || + archive.schema_version !== AUTHORITY_ARCHIVE_SCHEMA || archive.goal_id !== goal || archive.source_provider !== pending.source_provider || + archive.source_store_identity !== pending.source_store_identity || archive.source_provider_revision !== request.expected_provider_revision || + archive.commits !== pending.source_cursor || archive.projection_sha256 !== pending.source_projection_sha256 || + !BARE_SHA256_PATTERN.test(String(archive.archive_sha256)) || receipt.archive !== join(directory, "authority.ndjson") || + !same(receipt.runtime_history, goalTransferHistory(root, goal, + requireJsonObject(pending.source_turn_history, "original Turn history"), requireJsonObject(pending.source_quota_history, "original quota history")))) { + reject("Preparation receipt differs from its original source identity; preserve the original operation"); + } + } + // Missing receipt is an interrupted preparation, not a completed move. + // Its native pending identity and current source fence still have to match. + const decision = {schema_version: CANCELLATION_SCHEMA, goal_id: goal, operation_id: operation, + source_preparation_sha256: canonicalAuthoritySha256(pending), + source_receipt_sha256: receipt ? canonicalAuthoritySha256(receipt) : null, + original_writer_fence_sha256: canonicalAuthoritySha256(prior), preparation_cancelled: true, execution_authority_granted: false}; + const retained = await optionalJson(join(directory, "cancelled.json")); + if (retained && !same(retained, decision)) reject("Original source cancellation decision changed; preserve its original operation"); + const prepared = sourcePreparationFence(goal, operation, pending); + const current = await loadLegacyCoordinationWriterFence(root, goal); + const readback = async () => { + const observed = await loadLegacyCoordinationWriterFence(root, goal); + return {decision, readback: {writer_fence: observed, + original_writer_fence_restored: observed.status === "loaded" && same(observed.fence, prior)}}; + }; + if (retained && (current.status !== "loaded" || !same(current.fence, prepared))) return readback(); + if (current.status !== "loaded" || !same(current.fence, prepared)) reject("Source is not owned by this prepared operation; cancellation cannot replace its writer fence"); + const snapshot = await readGoalTransferSnapshot(String(context.registry_path), goal); + return withVerifiedGoalTransferContext(context, snapshot.registry_witness, goal, root, async () => { + const managed = await readShadowManagementState(root, goal); + if (managed && managed.status !== "inactive") reject("Source capture requires disposition before preparation cancellation"); + const opened = await openLocalAuthorityStoreHandle(root, goal, {}, {existingOnly: true}); + const head = await opened.store.loadAuthority(), identity = await opened.store.storeIdentity(); + if ((opened.provider !== "file" && opened.provider !== "sqlite") || opened.provider !== pending.source_provider || + head.status !== "loaded" || identity.status !== "available" || identity.store_identity !== pending.source_store_identity || + head.provider_revision !== request.expected_provider_revision || head.cursor !== pending.source_cursor || + canonicalAuthoritySha256(head.head) !== pending.source_projection_sha256) reject("Prepared source authority changed; cancellation must not restore over later work"); + await requireRegisteredSourceOrigin(context, prior, opened.store, goal); + const turns = await readGoalTurnJournalsForTransfer(root, goal), originalTurns = requireJsonObject(pending.source_turn_history, "original Turn history"); + if (!same(turns, {turns: originalTurns.turns, files: originalTurns.files, closed: originalTurns.closed}) || + !same(await readGoalQuotaHistoryForTransfer(root, goal), pending.source_quota_history)) reject("Prepared source settlement history changed; recover its original owner before cancellation"); + await retain(join(directory, "cancelled.json"), decision); + await restorePlannedTransferWriterFenceUnderLock(root, goal, prepared, prior); + return readback(); + }); + }); +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index e7a12ce019..f5f9d24b23 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -418,6 +418,7 @@ export function createEffectRuntimeHandlers( ["coordination.authority_archive.manage", lazyHandler(() => import("./coordination/local_authority_archive.ts"), ({manageLocalAuthorityArchive}) => manageLocalAuthorityArchive)], ["coordination.planned_transfer.preview_source", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({previewRegisteredAuthorityForPlannedTransfer}) => previewRegisteredAuthorityForPlannedTransfer)], ["coordination.planned_transfer.prepare_authority", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({prepareAuthorityForPlannedTransfer}) => prepareAuthorityForPlannedTransfer)], + ["coordination.planned_transfer.cancel_source_preparation", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({cancelSourcePreparation}) => cancelSourcePreparation)], ["coordination.planned_transfer.registry_write_check", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({checkPlannedTransferRegistryWrite}) => checkPlannedTransferRegistryWrite)], ["coordination.planned_transfer.runtime_write_check", lazyHandler(() => import("./coordination/local_authority_write.ts"), ({checkGoalRuntimeWrite}) => checkGoalRuntimeWrite)], ["coordination.planned_transfer.stage_target", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({stageAuthorityForPlannedTransfer}) => stageAuthorityForPlannedTransfer)], diff --git a/loopx/control_plane/goals/planned_transfer.ts b/loopx/control_plane/goals/planned_transfer.ts index ffd065c660..97483eb2cc 100644 --- a/loopx/control_plane/goals/planned_transfer.ts +++ b/loopx/control_plane/goals/planned_transfer.ts @@ -67,6 +67,20 @@ export interface PreparedGoalStage { workspaceMaterials?: GoalTransferWorkspacePlan; } +/** Fixed codec IO, shared with recovery at the retained original route. A + * cancellation readback must not rediscover or repair a later registration. */ +export async function readGoalTransferSnapshot(registry: string, goalId: string): Promise { + const python = process.env.LOOPX_EFFECT_RUNTIME_PYTHON; + if (!python || !isAbsolute(python) || python.includes("\0")) reject("Registered Goal context requires the source-selected Python codec adapter"); + try { + const result = await executeFile(python, ["-m", "loopx.control_plane.goals.planned_transfer_io", registry, goalId], { + env: {...process.env, PYTHONPATH: fileURLToPath(new URL("../../../", import.meta.url))}, + timeout: 30_000, maxBuffer: 16 * 1024 * 1024, + }); + return requireJsonObject(JSON.parse(result.stdout), "registered context codec readback"); + } catch { reject("Registered Goal context codec readback is unavailable; preserve the original operation and retry through its installed source"); } +} + /** Capture Goal intent through its actual registered source while the caller * holds maintenance. Read the registered Goal through the existing Python * registry codec using the source-selected interpreter, never another registry @@ -85,16 +99,7 @@ export async function withVerifiedGoalTransferContext(raw: unknown, rawWitnes if (!hasExactAuthorityKeys(witness, ["path", "sha256"]) || witness.path !== registry || typeof witness.sha256 !== "string" || !BARE_SHA256_PATTERN.test(witness.sha256) || state === registry) reject("Invalid registered Goal context witness"); const verify = async () => { - const python = process.env.LOOPX_EFFECT_RUNTIME_PYTHON; - if (!python || !isAbsolute(python) || python.includes("\0")) reject("Registered Goal context requires the source-selected Python codec adapter"); - let observed: JsonObject; - try { - const result = await executeFile(python, ["-m", "loopx.control_plane.goals.planned_transfer_io", registry, goalId], { - env: {...process.env, PYTHONPATH: fileURLToPath(new URL("../../../", import.meta.url))}, - timeout: 30_000, maxBuffer: 16 * 1024 * 1024, - }); - observed = requireJsonObject(JSON.parse(result.stdout), "registered context codec readback"); - } catch { reject("Registered Goal context codec readback is unavailable; preserve the original operation and retry through its installed source"); } + const observed = await readGoalTransferSnapshot(registry, goalId); if (observed.registry_profile !== null) reject("This source context profile requires the normal project registry; lifecycle-only adoption remains held"); if (observed.registry_role === "global-local") reject("A global registry is a read model; resolve the original project registration before transfer"); if (!canonicalAuthorityBytes(observed.registry_witness).equals(canonicalAuthorityBytes(witness))) reject("Registered Goal context changed; review its original transfer"); diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index 54205cb34c..c15794383a 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -100,6 +100,20 @@ def prepare_registered_goal_transfer( return result +def cancel_prepared_goal_transfer(*, runtime_root: Path, goal_id: str, operation_id: str) -> dict[str, Any]: + """Recover the retained pre-cutover operation, without rediscovering a route. + + These fields are lookup addresses, not source proof. The native Goal owner + loads the original preparation, registry context and fence. Retried readback + must preserve later registration changes and never issue a target grant. + """ + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.cancel_source_preparation", { + "runtime_root": str(runtime_root.expanduser().absolute()), "goal_id": goal_id, + "operation_id": operation_id, "execute": True, + }, timeout=60.0, retry_safe=False) + return result + + def _registered_preparation_request( registry_path: Path, goal_id: str, operation_id: str, expected_preview_sha256: str, ) -> dict[str, Any]: diff --git a/tests/control_plane/test_planned_transfer_cancellation.py b/tests/control_plane/test_planned_transfer_cancellation.py new file mode 100644 index 0000000000..b80b2634fb --- /dev/null +++ b/tests/control_plane/test_planned_transfer_cancellation.py @@ -0,0 +1,376 @@ +"""A failed pre-cutover move must not strand the original unfinished work. + +Exercise the real CLI, codec and File/SQLite owners. Cancelling preparation +returns the unchanged source to its original owner; cached destination input +still cannot grant execution. No model or active Goal is used by these tests. +""" +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from loopx.control_plane.coordination.legacy_writer_fence import legacy_coordination_writer_fence_path +from loopx.control_plane.coordination.local_authority_shadow_projection import canonical_bytes +from loopx.control_plane.coordination.shadow_management import shadow_management_directory +from loopx.control_plane.effect_runtime import EffectRuntimeRemoteError, effect_runtime_result, restart_effect_runtime +from loopx.control_plane.goals.first_party_host_admission import FirstPartyHostGoalAdmission +from loopx.control_plane.goals.planned_transfer_io import ( + cancel_prepared_goal_transfer, create_planned_transfer_recipient, export_registered_goal_transfer, + prepare_registered_goal_transfer, preview_registered_goal_transfer, + read_goal_transfer_context, stage_prepared_goal_transfer, +) +from loopx.control_plane.projects.registry_codec import project_registry_transaction +from loopx.control_plane.quota.spend_commit import record_quota_slot_spend_from_preview, replay_quota_spend_by_effect_ref +from loopx.control_plane.turn_driver.codex_sessions import _session_path, _store_codex_cli_session +from loopx.file_lock import exclusive_cross_runtime_file_lock +from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime +from tests.control_plane.test_quota_spend_commit_runtime import _preview as quota_preview + + +@pytest.fixture(params=["file", "sqlite"]) +def source(tmp_path: Path, monkeypatch, request): + isolate_sqlite_runtime(tmp_path, monkeypatch) + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.setenv("LOOPX_USAGE_PING", "0") + project, worktree, runtime = (tmp_path / name for name in ("project", "agent", "runtime")) + project.mkdir() + registry = project / ".loopx/registry.json" + machine = runtime / "machine/configuration.json" + machine.parent.mkdir(parents=True) + machine.write_text(json.dumps({"schema_version": "loopx_machine_configuration_v0", "namespaces": { + "goal_storage": {"schema_version": "loopx_goal_storage_defaults_v1", "new_goal_provider": request.param, + "canonical_creation": True, "new_goal_handoff_mode": "hard_lease"}}})) + + def git(*arguments: str) -> None: + subprocess.run(["git", "-C", str(project), *arguments], check=True, capture_output=True) + + git("init", "-q") + git("remote", "add", "origin", "https://example.org/fixtures/agent-transfer.git") + (project / "input.txt").write_text("6\n7\n8\n") + git("add", "input.txt") + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.org", "commit", "-qm", "Input") + git("worktree", "add", "-qb", "agent", str(worktree)) + + def cli(*arguments: str, expected_code: int = 0) -> dict: + result = subprocess.run([sys.executable, "-I", "-m", "loopx.cli", "--format", "json", + "--registry", str(registry), "--runtime-root", str(runtime), *arguments], + cwd=worktree, capture_output=True, text=True, timeout=60) + assert result.returncode == expected_code, result.stdout + result.stderr + return json.loads(result.stdout) + + try: + cli("bootstrap", "--project", str(project), "--goal-id", "report", + "--objective", "Produce report.txt from input.txt", "--no-global-sync") + cli("configure-goal", "--goal-id", "report", "--registered-agent", "agent-a", "--write-scope", "report.txt", "--execute") + todo = cli("todo", "add", "--goal-id", "report", "--role", "agent", "--claimed-by", "agent-a", + "--text", "Write the input sum to report.txt", "--task-repository", "git:example.org/fixtures/agent-transfer", + "--required-write-scope", "report.txt", "--operation-id", "pending-report")["todo_id"] + + def acquire(key: str, *, expected_code: int = 0) -> dict: + return cli("task-lease", "acquire", "--goal-id", "report", "--todo-id", todo, + "--owner", "agent-a", "--idempotency-key", key, "--write-scope", "report.txt", + "--write-worktree", str(worktree), "--ttl-seconds", "7200", expected_code=expected_code) + + def release(lease: dict) -> dict: + return cli("task-lease", "release", "--goal-id", "report", "--todo-id", todo, "--owner", "agent-a", + "--idempotency-key", lease["idempotency_key"], "--expected-version", str(lease["version"])) + + original_lease = acquire("original-work")["lease"] + release(original_lease) + debit = record_quota_slot_spend_from_preview( + quota_preview(goal_id="report", agent_id="agent-a", effect_ref="original-effect#quota_spend"), + {"runtime_root": str(runtime)}, goal_id="report", execute=True, registry_path=registry) + assert debit["appended"] is True and debit["slots"] == 1 + lineage = {"goal_id": "report", "agent_id": "agent-a", "todo_id": todo} + admission = FirstPartyHostGoalAdmission.for_plan(registry_path=registry, goal_id="report", planned_goal_ref=None) + _store_codex_cli_session(runtime, lineage=lineage, session_id="original-context") + fence = legacy_coordination_writer_fence_path(runtime_root=runtime, goal_id="report") + yield {"cli": cli, "acquire": acquire, "release": release, "runtime": runtime, "registry": registry, + "project": project, "todo": todo, "debit": debit, "lineage": lineage, "admission": admission, + "original_fence": json.loads(fence.read_bytes()), "fence": fence, "provider": request.param} + finally: + restart_effect_runtime(wait_for_exit=True) + + +def operation_directory(source: dict, operation: str) -> Path: + return shadow_management_directory(source["runtime"], "report") / "planned-transfers" / hashlib.sha256(canonical_bytes(operation)).hexdigest() + + +def cancel(source: dict, operation: str = "move-a") -> dict: + return cancel_prepared_goal_transfer(runtime_root=source["runtime"], goal_id="report", operation_id=operation) + + +def test_failed_outbound_publication_can_resume_the_original_source(source: dict, tmp_path: Path) -> None: + registry, runtime = source["registry"], source["runtime"] + snapshot = read_goal_transfer_context(registry_path=registry, goal_id="report") + context = snapshot["source_context"] + preview = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-a") + assert preview["ready_for_authority_preparation"] is True + recipient = create_planned_transfer_recipient(destination=tmp_path / "recipient") + occupied = tmp_path / "occupied-export" + occupied.mkdir() + (occupied / "later-user-file.txt").write_text("Keep this unrelated file.") + before_registry = registry.read_bytes() + before_index = Path(source["debit"]["index_path"]).read_bytes() + before_task = source["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] + with pytest.raises(EffectRuntimeRemoteError): + export_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-a", + expected_preview_sha256=preview["preview_sha256"], recipient=recipient, destination=occupied, materials=[]) + directory = operation_directory(source, "move-a") + prepared_bytes = (directory / "receipt.json").read_bytes() + prepared = json.loads(prepared_bytes) + assert prepared["authority_write_fenced"] is True + assert prepared["execution_authority_granted"] is False + assert "prepared for transfer" in source["acquire"]("stranded", expected_code=1)["error"] + with pytest.raises(EffectRuntimeRemoteError, match="prepared for transfer"): + source["admission"].require_current() + + cancelled = cancel(source) + assert cancelled["decision"]["preparation_cancelled"] is True + assert cancelled["decision"]["execution_authority_granted"] is False + assert cancelled["readback"]["original_writer_fence_restored"] is True + assert json.loads(source["fence"].read_bytes()) == source["original_fence"] + assert registry.read_bytes() == before_registry + assert Path(source["debit"]["index_path"]).read_bytes() == before_index + assert (directory / "receipt.json").read_bytes() == prepared_bytes + assert source["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] == before_task + assert source["cli"]("task-lease", "inspect", "--goal-id", "report", "--todo-id", source["todo"])["provider_revision"] == preview["provider_revision"] + # A cached direct execution request must also fail, independently of the + # convenience wrapper's fresh preview. The old source is otherwise exactly + # unchanged here, so unrelated drift cannot mask an accidental reprepare. + with pytest.raises(EffectRuntimeRemoteError, match="cancelled"): + effect_runtime_result("coordination.planned_transfer.prepare_authority", { + "runtime_root": str(runtime), "goal_id": "report", "operation_id": "move-a", + "expected_provider_revision": preview["provider_revision"], + "expected_writer_fence_sha256": preview["original_writer_fence_sha256"], + "registered_source_preview_sha256": preview["preview_sha256"], "execute": True, **snapshot, + }, timeout=60, retry_safe=False) + assert (occupied / "later-user-file.txt").read_text() == "Keep this unrelated file." + source["admission"].require_current() + assert source["admission"].accept_result(lambda: "original-owner-result") == "original-owner-result" + _store_codex_cli_session(runtime, lineage=source["lineage"], session_id="continued-context") + assert json.loads(_session_path(runtime, source["lineage"]).read_bytes())["session_id"] == "continued-context" + lease = source["acquire"]("continue-original")["lease"] + updated = source["cli"]("todo", "update", "--goal-id", "report", "--todo-id", source["todo"], + "--agent-id", "agent-a", "--task-lease-idempotency-key", lease["idempotency_key"], + "--task-lease-expected-version", str(lease["version"]), "--note", "Continue the original unfinished work.", + "--update-operation-id", "continue-original-note", "--execute") + assert updated["ok"] is True + original = source["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] + assert original["status"] == "open" and original["claimed_by"] == "agent-a" + assert original["note"] == "Continue the original unfinished work." + assert lease["lease_epoch"] > 1 + source["release"](lease) + replay = replay_quota_spend_by_effect_ref(runtime, goal_id="report", effect_ref=source["debit"]["effect_id"], + agent_id="agent-a", read_only=True, registry_path=registry) + assert replay["replay_found"] is True and replay["appended"] is False + with project_registry_transaction(registry, operation="later-source-name") as tx: + changed = tx.payload_copy() + changed["goals"][0]["display_name"] = "Later source work" + tx.commit(changed) + later_registry = registry.read_bytes() + restart_effect_runtime(wait_for_exit=True) + assert cancel(source)["decision"] == cancelled["decision"] + assert registry.read_bytes() == later_registry + assert source["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] == original + with pytest.raises(EffectRuntimeRemoteError, match="cancelled"): + preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-a") + + # A stale carrier can still be inspected/staged, but remains an inert read + # model. Cancellation never issues destination execution authority. + target_project, target_runtime = tmp_path / "target-project", tmp_path / "target-runtime" + staged = stage_prepared_goal_transfer(source_preparation=prepared, source_context=context, + archive=Path(prepared["archive"]), project=target_project, runtime_root=target_runtime, provider=source["provider"]) + assert staged["target_is_active"] is False and staged["execution_authority_granted"] is False + target_registry = target_project / ".loopx/registry.json" + with pytest.raises(EffectRuntimeRemoteError, match="prepared for transfer"): + FirstPartyHostGoalAdmission.for_plan(registry_path=target_registry, goal_id="report", planned_goal_ref=None).require_current() + + next_preview = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-b") + prepare_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-b", + expected_preview_sha256=next_preview["preview_sha256"]) + later_fence = source["fence"].read_bytes() + historical = cancel(source) + assert historical["decision"] == cancelled["decision"] + assert historical["readback"]["original_writer_fence_restored"] is False + assert source["fence"].read_bytes() == later_fence and registry.read_bytes() == later_registry + + +def test_process_loss_after_cancellation_decision_recovers_the_original_fence(source: dict) -> None: + # A real source Host and terminal journal are retained, rather than using a + # copied PID or a caller-supplied source_closed flag. + from loopx.control_plane.turn_driver.host_process_transport import run_host_process + from loopx.control_plane.turn_driver.journal_store import write_turn_journal_checkpoint + from loopx.control_plane.turn_driver.turn_host_process import turn_host_process_scope + from tests.control_plane.test_planned_transfer_source_boundary import _original_turn + + runtime, registry = source["runtime"], source["registry"] + journal = _original_turn("report", source["todo"]) + journal["host_attempt_count"] = 1 + journal_path = runtime / "goals/report/turns" / ("a" * 64 + ".json") + write_turn_journal_checkpoint(journal_path, journal) + with turn_host_process_scope(runtime, goal_id="report", turn_key=journal["turn_key"], attempt=1): + assert run_host_process([sys.executable, "-c", "pass"], project=source["project"], + input_text="", timeout_seconds=10)["outcome"] == "exited" + phases = ["host_execute", "typed_result", "validation", "durable_writeback", "quota_spend", "scheduler_apply", "scheduler_ack"] + for count in (2, 3, 4, 5, 7): + journal["completed_phases"] = phases[:count] + if count == 7: + journal["status"] = "committed" + write_turn_journal_checkpoint(journal_path, journal) + preview = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-a") + assert preview["turn_history"]["closed"] is True and preview["turn_history"]["turn_hosts_closed"] is True + prepare_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-a", + expected_preview_sha256=preview["preview_sha256"]) + prepared_fence, original_journal = source["fence"].read_bytes(), journal_path.read_bytes() + decision_path = operation_directory(source, "move-a") / "cancelled.json" + module = Path(__file__).resolve().parents[2] / "loopx/control_plane/coordination/planned_transfer_authority.ts" + script = f"import {{cancelSourcePreparation}} from {json.dumps(module.as_uri())};\n" + "\n".join([ + "let input=''; for await(const chunk of process.stdin) input+=chunk;", + "console.log(JSON.stringify(await cancelSourcePreparation(JSON.parse(input))));", + ]) + # This process is directly owned by the test. Kill its Popen handle only + # after the durable decision and before the blocked fence CAS; never signal + # a discovered runtime or an arbitrary PID. + with exclusive_cross_runtime_file_lock(source["fence"], operation="fixture_hold_original_fence"): + process = subprocess.Popen(["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", script], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env={**os.environ, "LOOPX_EFFECT_RUNTIME_PYTHON": sys.executable}) + try: + assert process.stdin is not None + process.stdin.write(json.dumps({"runtime_root": str(runtime), "goal_id": "report", "operation_id": "move-a", "execute": True}).encode()) + process.stdin.close() + deadline = time.monotonic() + 10 + while not decision_path.is_file() and process.poll() is None and time.monotonic() < deadline: + time.sleep(.02) + assert decision_path.is_file(), "cancellation did not durably decide before source restoration" + decision = decision_path.read_bytes() + assert source["fence"].read_bytes() == prepared_fence + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + assert process.stdout is not None and process.stderr is not None + process.stdout.close() + process.stderr.close() + assert source["fence"].read_bytes() == prepared_fence + restart_effect_runtime(wait_for_exit=True) + # A retained decision is not permission to ignore changes made before its + # interrupted CAS finishes. Alter only this synthetic fixture, prove the + # refusal, then put back its original bytes for the recovery case. + original_registry = registry.read_bytes() + changed = json.loads(original_registry) + changed["goals"][0]["display_name"] = "Changed during interrupted cancellation" + registry.write_text(json.dumps(changed)) + with pytest.raises(EffectRuntimeRemoteError, match="intent differs"): + cancel(source) + assert source["fence"].read_bytes() == prepared_fence and decision_path.read_bytes() == decision + registry.write_bytes(original_registry) + recovered = cancel(source) + assert recovered["readback"]["original_writer_fence_restored"] is True + assert decision_path.read_bytes() == decision and journal_path.read_bytes() == original_journal + assert json.loads(source["fence"].read_bytes()) == source["original_fence"] + assert cancel(source)["decision"] == recovered["decision"] + source["admission"].require_current() + source["release"](source["acquire"]("continue-after-recovery")["lease"]) + + +def test_interrupted_archive_without_receipt_can_be_cancelled(source: dict) -> None: + preview = preview_registered_goal_transfer(registry_path=source["registry"], goal_id="report", operation_id="move-a") + directory = operation_directory(source, "move-a") + occupied = directory / "authority.ndjson" + occupied.mkdir(parents=True) + (occupied / "unrelated.txt").write_text("Preserve the occupied path.") + with pytest.raises(EffectRuntimeRemoteError): + prepare_registered_goal_transfer(registry_path=source["registry"], goal_id="report", operation_id="move-a", + expected_preview_sha256=preview["preview_sha256"]) + assert not (directory / "receipt.json").exists() + assert "prepared for transfer" in source["acquire"]("blocked-without-receipt", expected_code=1)["error"] + result = cancel(source) + assert result["decision"]["source_receipt_sha256"] is None + assert result["readback"]["original_writer_fence_restored"] is True + assert (occupied / "unrelated.txt").read_text() == "Preserve the occupied path." + assert not (directory / "receipt.json").exists() + assert cancel(source)["decision"] == result["decision"] + source["release"](source["acquire"]("continue-after-archive-failure")["lease"]) + + +def test_cancellation_refuses_changed_context_receipt_fence_and_history(source: dict) -> None: + registry = source["registry"] + original = source["fence"].read_bytes() + with pytest.raises(EffectRuntimeRemoteError, match="retained original registered"): + cancel(source, "unknown-move") + assert source["fence"].read_bytes() == original + preview = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-a") + prepare_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-a", + expected_preview_sha256=preview["preview_sha256"]) + directory = operation_directory(source, "move-a") + frozen_fence, frozen_registry = source["fence"].read_bytes(), registry.read_bytes() + receipt_path = directory / "receipt.json" + frozen_receipt = receipt_path.read_bytes() + + # Controlled corruption of this disposable fixture cannot be translated + # into reopening. Each refused call leaves the observed bytes untouched. + for path, changed, message in [ + (registry, {**json.loads(frozen_registry), "goals": []}, "codec readback is unavailable"), + (receipt_path, {**json.loads(frozen_receipt), "execution_authority_granted": True}, "preparation-only receipt"), + (receipt_path, {**json.loads(frozen_receipt), "runtime_history": {}}, "receipt differs from its original source identity"), + (source["fence"], {**json.loads(frozen_fence), "operation_id": "other-move"}, "not owned by this prepared operation"), + ]: + before = path.read_bytes() + bad = json.dumps(changed).encode() + path.write_bytes(bad) + with pytest.raises(EffectRuntimeRemoteError, match=message): + cancel(source) + assert path.read_bytes() == bad and not (directory / "cancelled.json").exists() + path.write_bytes(before) + quota = Path(source["debit"]["index_path"]) + quota_bytes = quota.read_bytes() + quota.write_bytes(quota_bytes + b"\n") + with pytest.raises(EffectRuntimeRemoteError, match="settlement history changed"): + cancel(source) + assert quota.read_bytes() == quota_bytes + b"\n" and source["fence"].read_bytes() == frozen_fence + quota.write_bytes(quota_bytes) + linked = directory / "cancelled.json" + linked.symlink_to(receipt_path) + with pytest.raises(EffectRuntimeRemoteError, match="linked ancestor"): + cancel(source) + assert linked.is_symlink() and source["fence"].read_bytes() == frozen_fence + linked.unlink() + assert cancel(source)["readback"]["original_writer_fence_restored"] is True + + # Simulate an unsupported old writer through the actual provider, outside + # its ordinary fenced business facade. Cancellation must not hide that new + # native history by restoring the prior execution boundary. + next_preview = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-b") + prepare_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-b", + expected_preview_sha256=next_preview["preview_sha256"]) + module = Path(__file__).resolve().parents[2] / "loopx/control_plane/coordination/local_authority_provider.ts" + script = f"import {{openLocalAuthorityStoreHandle}} from {json.dumps(module.as_uri())};\n" + "\n".join([ + "let input=''; for await(const chunk of process.stdin) input+=chunk;", + "const {root}=JSON.parse(input); const {store}=await openLocalAuthorityStoreHandle(root,'report',{}, {existingOnly:true});", + "const head=await store.loadAuthority(); if(head.status!=='loaded')throw Error('source missing');", + "const result=await store.commitAuthority({expected_provider_revision:head.provider_revision,operation_id:'unsupported-old-writer',", + "next_projection:head.head,events:[],receipts:[{result:'later native fact'}]});", + "if(result.status!=='applied')throw Error(JSON.stringify(result)); console.log(JSON.stringify(result));", + ]) + result = subprocess.run(["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", script], + input=json.dumps({"root": str(source["runtime"])}), capture_output=True, text=True, timeout=20) + assert result.returncode == 0, result.stderr + later_revision = json.loads(result.stdout)["provider_revision"] + later_fence = source["fence"].read_bytes() + with pytest.raises(EffectRuntimeRemoteError, match="Prepared source authority changed"): + cancel(source, "move-b") + assert source["fence"].read_bytes() == later_fence + assert not (operation_directory(source, "move-b") / "cancelled.json").exists() + assert source["cli"]("task-lease", "inspect", "--goal-id", "report", "--todo-id", source["todo"])["provider_revision"] == later_revision diff --git a/tests/control_plane_ts/planned_transfer_authority.test.ts b/tests/control_plane_ts/planned_transfer_authority.test.ts index 29a508cb0c..2db94cd28d 100644 --- a/tests/control_plane_ts/planned_transfer_authority.test.ts +++ b/tests/control_plane_ts/planned_transfer_authority.test.ts @@ -8,7 +8,7 @@ import {promisify} from "node:util"; import {fileURLToPath} from "node:url"; import test, {type TestContext} from "node:test"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; -import {prepareAuthorityForPlannedTransfer as prepare} from "../../loopx/control_plane/coordination/planned_transfer_authority.ts"; +import {prepareAuthorityForPlannedTransfer as prepare, cancelSourcePreparation} from "../../loopx/control_plane/coordination/planned_transfer_authority.ts"; import {stageAuthorityForPlannedTransfer as stage} from "../../loopx/control_plane/coordination/planned_transfer_target.ts"; import {localAuthorityProviderPaths, selectLocalAuthorityTarget, openLocalAuthorityStoreHandle} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; import {withFileMutationLock, acquireFileMutationLock, mutationLockOwner, releaseFileMutationLock} from "../../loopx/control_plane/effect_runtime_io.ts"; @@ -436,6 +436,9 @@ for (const provider of ["file", "sqlite"] as const) { assert.deepEqual(await f.store.loadAuthority(), before, "preparation must not change native history or original receipts"); const originalFence = await readFile(f.fencePath); const originalReceipt = await readFile(join(f.directory, "receipt.json")); + await assert.rejects(cancelSourcePreparation({runtime_root: f.root, goal_id: "goal-a", operation_id: "move-original", execute: true}), + /retained original registered source preparation/, "an archive-only import has no qualified normal-source cancellation grant"); + assert.deepEqual(await readFile(f.fencePath), originalFence); assert.deepEqual(await prepare(f.request), prepared); assert.deepEqual(await readFile(f.fencePath), originalFence); assert.deepEqual(await readFile(join(f.directory, "receipt.json")), originalReceipt); diff --git a/tests/control_plane_ts/planned_transfer_cancellation.test.ts b/tests/control_plane_ts/planned_transfer_cancellation.test.ts new file mode 100644 index 0000000000..c8f2303ad2 --- /dev/null +++ b/tests/control_plane_ts/planned_transfer_cancellation.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import {mkdtemp, mkdir, readFile, realpath, rm, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {dirname, join} from "node:path"; +import test from "node:test"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {cancelSourcePreparation} from "../../loopx/control_plane/coordination/planned_transfer_authority.ts"; +import {legacyCoordinationWriterFencePath, loadLegacyCoordinationWriterFence, NEW_GOAL_WRITER_FENCE_SCHEMA, + PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, restorePlannedTransferWriterFenceUnderLock, + type PlannedTransferWriterFence} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; + +test("cancellation fence CAS restores only the exact prepared source to its original normal owner", async t => { + const root = await realpath(await mkdtemp(join(tmpdir(), "loopx-transfer-cancel-"))); + t.after(() => rm(root, {recursive: true, force: true})); + const goal = "report", path = legacyCoordinationWriterFencePath(root, goal); + await mkdir(dirname(path), {recursive: true}); + // This unit check qualifies only the fence CAS. The real registered origin, + // creation receipt, provider and context proof are exercised through Python. + const prior = {schema_version: NEW_GOAL_WRITER_FENCE_SCHEMA, state: "engaged", goal_id: goal, + fence_id: "original-create", creation_operation_id: "original-create", creation_identity_sha256: "a".repeat(64), creation_completed: true}; + const prepared: PlannedTransferWriterFence = {schema_version: PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, + state: "engaged", goal_id: goal, role: "source", fence_id: "planned-transfer:move-a", operation_id: "move-a", + source_identity_sha256: canonicalAuthoritySha256({original: "preparation"})}; + for (const observed of [{...prepared, role: "target"}, {...prepared, operation_id: "later-move"}, {...prior, fence_id: "later-create"}]) { + await writeFile(path, JSON.stringify(observed)); + const bytes = await readFile(path); + await assert.rejects(restorePlannedTransferWriterFenceUnderLock(root, goal, prepared, prior), /writer fence changed/); + assert.deepEqual(await readFile(path), bytes); + } + await writeFile(path, JSON.stringify(prepared)); + for (const invalid of [{...prior, creation_completed: false}, {...prior, goal_id: "other"}, {...prior, extra: true}]) { + await assert.rejects(restorePlannedTransferWriterFenceUnderLock(root, goal, prepared, invalid)); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")), prepared); + } + await restorePlannedTransferWriterFenceUnderLock(root, goal, prepared, prior); + assert.deepEqual(await loadLegacyCoordinationWriterFence(root, goal), {status: "loaded", fence: prior}); + const bytes = await readFile(path); + await restorePlannedTransferWriterFenceUnderLock(root, goal, prepared, prior); + assert.deepEqual(await readFile(path), bytes, "exact idempotent recovery does not rewrite the fence"); +}); + +test("native cancellation rejects unsupported execution or unknown preparation without creating a replacement", async t => { + const root = await realpath(await mkdtemp(join(tmpdir(), "loopx-transfer-cancel-input-"))); + t.after(() => rm(root, {recursive: true, force: true})); + const request: JsonObject = {runtime_root: root, goal_id: "report", operation_id: "move-a", execute: true}; + for (const patch of [{execute: false}, {unexpected: true}, {source_closed: true}]) { + await assert.rejects(cancelSourcePreparation({...request, ...patch}), /original address and explicit execution/); + } + await assert.rejects(cancelSourcePreparation(request), /retained original registered source preparation/); + assert.deepEqual(await loadLegacyCoordinationWriterFence(root, "report"), {status: "missing"}); +}); From e558a8b31dac2ac5a959bc5cf8aad956f32b51f3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:52:37 +0800 Subject: [PATCH 10/55] fix(goals): preserve transferred accounting in target readback Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/configuration-backup.md | 25 +++ .../coordination/planned_transfer_target.ts | 47 +++++ .../control_plane/effect_runtime_handlers.ts | 1 + .../goals/planned_transfer_io.py | 11 ++ .../quota/accounting_artifact_transaction.ts | 80 +++++++- loopx/history.py | 47 +++-- .../test_planned_transfer_accounting.py | 180 ++++++++++++++++++ .../planned_transfer_history.test.ts | 36 +++- 8 files changed, 408 insertions(+), 19 deletions(-) create mode 100644 tests/control_plane/test_planned_transfer_accounting.py diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md index b8c8310a8e..fbfe643e63 100644 --- a/docs/reference/configuration-backup.md +++ b/docs/reference/configuration-backup.md @@ -326,6 +326,31 @@ migration together manually. 外部操作已完成。File/SQLite 及冷启动回归只验证这些现有边界,不代表完整迁移 已交付;这也不是要求用户手工拼接迁移步骤。 +The planned-transfer implementation under development retains original indexed +run artifacts with the closed Turn and quota history. Ordinary destination +history and same-effect spend replay read those verified local copies, including +original spends and voids; an empty live destination index does not erase prior +usage. Original receipt bodies and source addresses remain unchanged. Artifact +addresses in the read model point to the retained local copies and carry their +original paths separately. Missing, changed, linked or unbound history holds +instead of reporting zero usage or repairing from the old source. Unrecognized +or missing indexed source artifacts must be reconciled before preparation. +Existing destination accounting also rejects staging before its Goal/provider +is published; later accounting is preserved and requires its recovery owner. +These are historical projections while the destination remains stopped; they +do not adopt live accounting, configuration, credentials or execution authority. +Final cutover, destination admission and the packaged continuation journey are +still separate, unqualified parts of this implementation. + +中文:开发中的计划内迁移将原索引所指的运行产物与已结算 Turn、扣额和冲销 +历史一起保留。目标普通历史和同 effect 扣额重放读取经核验的本地产物,不能 +因为目标当前索引为空就丢掉已有用量。原 receipt 正文和源地址不改写;读模型 +提供本地保留地址,并另存原地址。历史缺失、改变、链接或绑定损坏时明确拒绝, +不回报零用量、不从旧源修补;源端不支持或缺失的索引产物须先由原 owner 处理。 +目标已有账务时,在发布 Goal/provider 前拒绝导入;后写账务保留,交原 owner 恢复。 +目标仍停止,这些历史投影不授予当前扣额、配置、凭证或执行权限;最终切换、 +目标准入及打包后的继续工作旅程尚未资格化。 + ## Full-state recovery after cold import Keep the reviewed pre-import backup as well as a new full-state backup after diff --git a/loopx/control_plane/coordination/planned_transfer_target.ts b/loopx/control_plane/coordination/planned_transfer_target.ts index c7e6732546..512db132b0 100644 --- a/loopx/control_plane/coordination/planned_transfer_target.ts +++ b/loopx/control_plane/coordination/planned_transfer_target.ts @@ -25,6 +25,7 @@ import {readShadowManagementState, shadowManagementDirectory, withShadowMaintena import {decodeGoalTransferHistory, inspectGoalTransferHistory, readGoalTransferHistoryJson, retainGoalTransferHistory, type ProtectedGoalTransferHistory} from "../goals/planned_transfer_history.ts"; import {PREPARATION_SCHEMA} from "./planned_transfer_authority.ts"; import {legacyCoordinationTodoLockPath} from "./legacy_writer_lock_paths.ts"; +import {projectGoalQuotaHistoryForTransfer, readGoalQuotaHistoryForTransfer} from "../quota/accounting_artifact_transaction.ts"; function reject(message: string): never { throw new EffectRuntimeConflictError(message); } function same(a: unknown, b: unknown): boolean { return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b)); } @@ -243,6 +244,10 @@ export async function stageAuthorityForPlannedTransfer(value: JsonObject): Promi const priorAudit = await auditAuthorityArchive(value.archive as string, selected, archive.archive_sha256); if (priorAudit.status !== "matched") reject("Completed destination authority changed or is missing; use its recovery owner"); } + const liveAccounting = await readGoalQuotaHistoryForTransfer(root, plan.goalId); + if (liveAccounting.closed !== true || (liveAccounting.files as JsonObject[]).length !== 0) { + reject("Destination runtime accounting is occupied; preserve its original owner before staging"); + } // State/context are immutable original input, not authority reconstructed // from Markdown. No credential-bearing source config enters the live Goal. const originalState = await optionalBytes(plan.statePath), originalContext = await optionalBytes(plan.contextPath); @@ -313,3 +318,45 @@ export async function inspectStagedGoalTransferHistory(value: JsonObject): Promi if (manifest.goal_id !== goalId || canonicalAuthoritySha256(manifest) !== receipt.runtime_history_sha256) reject("Original staged runtime history identity changed"); return inspectGoalTransferHistory(join(directory, "runtime-history"), manifest); } + +/** Ordinary history and same-effect quota replay consume the same verified + * original content before cutover. Never publish it into the live index or + * interpret retained transaction receipts as new destination write authority. + * This reader does not acquire maintenance after a caller's run-index lock; + * exact before/after observations reject a changing staged lineage instead. */ +export async function readStagedGoalTransferRuns(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id"])) reject("Original staged accounting identity is required"); + const root = await requirePhysicalTransferPath(value.runtime_root); + const goal = requireAuthorityStoreId(value.goal_id, "original transfer Goal"); + const operation = requireAuthorityStoreId(value.operation_id, "original transfer operation"); + if (/[\\/\0]/u.test(goal) || goal === "." || goal === "..") reject("Original transfer Goal must be one path segment"); + const fence = await loadLegacyCoordinationWriterFence(root, goal); + if (fence.status !== "loaded" || fence.fence.schema_version !== PLANNED_TRANSFER_WRITER_FENCE_SCHEMA || + fence.fence.role !== "target" || fence.fence.operation_id !== operation) reject("Original staged target accounting binding is unavailable"); + const directory = join(shadowManagementDirectory(root, goal), "planned-transfer-targets", canonicalAuthoritySha256(operation)); + const pending = await readGoalTransferHistoryJson(join(directory, "preparation.json")); + const receipt = await readGoalTransferHistoryJson(join(directory, "receipt.json")); + const registration = await readGoalTransferHistoryJson(join(directory, "registration.json")); + if (canonicalAuthoritySha256(pending) !== fence.fence.source_identity_sha256 || receipt.identity_sha256 !== fence.fence.source_identity_sha256 || + registration.identity_sha256 !== receipt.identity_sha256 || pending.goal_id !== goal || pending.operation_id !== operation || + requireJsonObject(pending.target, "staged target").runtime_root !== root || + receipt.target_is_active !== false || receipt.execution_authority_granted !== false || receipt.runtime_accounting_adopted !== false) { + reject("Original staged target accounting identity changed"); + } + await inspectStagedGoalTransferHistory(value); + const historyRoot = join(directory, "runtime-history"); + const manifest = decodeGoalTransferHistory(await readGoalTransferHistoryJson(join(historyRoot, "manifest.json"))); + const records = await projectGoalQuotaHistoryForTransfer(manifest.source_runtime_root, goal, historyRoot, manifest.quota_history); + const live = await readGoalQuotaHistoryForTransfer(root, goal); + if (live.closed !== true || (live.receipts as JsonObject[]).length !== 0) { + reject("Staged target contains unadopted live accounting; preserve it for recovery"); + } + const after = await loadLegacyCoordinationWriterFence(root, goal); + if (!same(after, fence) || !same(receipt, await readGoalTransferHistoryJson(join(directory, "receipt.json")))) { + reject("Staged target accounting changed during readback"); + } + return {records, raw_count: records.length, live_index_sha256: live.index_sha256, + provenance: {operation_id: operation, history_sha256: receipt.runtime_history_sha256, + original_index_sha256: manifest.quota_history.index_sha256, historical_only: true, + runtime_accounting_adopted: false, execution_authority_granted: false}}; +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index f5f9d24b23..2e385a31c5 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -427,6 +427,7 @@ export function createEffectRuntimeHandlers( ["coordination.planned_transfer.preview_target", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({previewProtectedGoalTransferTarget}) => previewProtectedGoalTransferTarget)], ["coordination.planned_transfer.stage_protected", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({stageProtectedGoalTransfer}) => stageProtectedGoalTransfer)], ["coordination.planned_transfer.inspect_target_history", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({inspectStagedGoalTransferHistory}) => inspectStagedGoalTransferHistory)], + ["coordination.planned_transfer.read_target_runs", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({readStagedGoalTransferRuns}) => readStagedGoalTransferRuns)], ["configuration.backup", lazyHandler(() => import("./configuration_backup.ts"), ({configurationBackupOperation}) => configurationBackupOperation)], ["runtime.checkpoint_artifacts", lazyHandler(() => import("./runtime/checkpoint_artifacts.ts"), ({checkpointArtifacts}) => checkpointArtifacts)], ["state.recovery.archive.verify", lazyHandler(() => import("./state_recovery.ts"), ({verifyStateRecoveryArchive}) => verifyStateRecoveryArchive)], diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index c15794383a..2c39fc7f0f 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -222,6 +222,17 @@ def inspect_staged_goal_transfer_history( return result +def read_staged_goal_transfer_runs( + *, runtime_root: Path, goal_id: str, operation_id: str, +) -> dict[str, Any]: + """Transport the Goal/accounting owners' verified historical projection.""" + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.read_target_runs", { + "runtime_root": str(runtime_root.expanduser().absolute()), + "goal_id": goal_id, "operation_id": operation_id, + }, timeout=300.0, retry_safe=True) + return result + + def preview_protected_goal_transfer_target( *, directory: Path, private_key: Path, expected_artifact_sha256: str, project: Path, runtime_root: Path, provider: str, diff --git a/loopx/control_plane/quota/accounting_artifact_transaction.ts b/loopx/control_plane/quota/accounting_artifact_transaction.ts index 60cda1b7f3..c2fb00d375 100644 --- a/loopx/control_plane/quota/accounting_artifact_transaction.ts +++ b/loopx/control_plane/quota/accounting_artifact_transaction.ts @@ -14,6 +14,8 @@ import { import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; import {withGoalRuntimeWriter} from "../coordination/local_authority_write.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; +import {loadLegacyCoordinationWriterFence, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA} from "../coordination/legacy_writer_fence.ts"; +import {shadowManagementDirectory} from "../coordination/shadow_management.ts"; import { jsonObject, optionalNonEmptyString as optionalString, @@ -524,10 +526,34 @@ export async function lookupQuotaAccountingReplay( }> { const lookup = async () => { const content = await readOptionalText(indexPath); + let records = parseQuotaAccountingIndex(content); + const runs = dirname(indexPath), goal = dirname(runs), goals = dirname(goal); + if (basename(runs) === "runs" && basename(goals) === "goals") { + const root = dirname(goals), goalId = basename(goal); + const fence = await loadLegacyCoordinationWriterFence(root, goalId); + let retainedTarget = false; + try {await lstat(join(shadowManagementDirectory(root, goalId), "planned-transfer-targets")); retainedTarget = true;} + catch (error) {if (!isNodeErrorCode(error, "ENOENT")) throw error;} + const targetFence = fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA && fence.fence.role === "target"; + if (retainedTarget || targetFence) { + // Retained lineage without its fence is damaged state, not an absent + // old debit. Unrelated ordinary/legacy replay retains its prior rule. + if (!targetFence || fence.status !== "loaded") throw new EffectRuntimeRequestError("Original staged target accounting binding is unavailable"); + // Goal owns the staged lineage. Its readback verifies original content + // through this accounting owner; it grants neither repair nor dispatch. + const {readStagedGoalTransferRuns} = await import("../coordination/planned_transfer_target.ts"); + const original = await readStagedGoalTransferRuns({runtime_root: root, goal_id: goalId, + operation_id: fence.fence.operation_id}); + if (original.live_index_sha256 !== (content === null ? null : sha256Bytes(Buffer.from(content)))) { + throw new EffectRuntimeRequestError("Staged target run index changed during quota replay"); + } + records = [...original.records as JsonObject[], ...records]; + } + } return { resolution: resolveQuotaAccountingEffect( kind, - parseQuotaAccountingIndex(content), + records, effectId, goalRef, ), @@ -900,7 +926,7 @@ async function ensureReceiptArtifacts( * A prepared or incomplete debit/void must finish through its existing owner * before transfer; directory emptiness is not an external-effect certificate. */ -export async function readGoalQuotaHistoryForTransfer(root: string, goalId: string, retainedRoot = root): Promise { +async function loadGoalQuotaHistoryForTransfer(root: string, goalId: string, retainedRoot: string): Promise<{history: JsonObject; records: JsonObject[]}> { // Receipt bodies keep their original absolute addresses. A retained snapshot // is read only at the caller's private carrier root; no source path is opened // or rewritten, and this does not install a target accounting/admission grant. @@ -929,6 +955,36 @@ export async function readGoalQuotaHistoryForTransfer(root: string, goalId: stri }; const text = (bytes: Buffer | null) => bytes === null ? null : new TextDecoder("utf-8", {fatal: true}).decode(bytes); const indexBytes = await snapshot(index), records = parseQuotaAccountingIndex(text(indexBytes)); + const projected: JsonObject[] = []; + let indexArtifactsClosed = true; + for (const record of records) { + if (record.goal_id !== goalId) throw new EffectRuntimeRequestError("Transfer run index belongs to another Goal"); + const local = {...record}; + // Every indexed artifact is needed by ordinary history/settlement readers, + // including delivery runs that are not debit/void transactions. Resolve + // only original owner-selected direct children, never arbitrary source IO. + for (const [field, extension] of [["json_path", ".json"], ["markdown_path", ".md"]] as const) { + const path = record[field]; + if (typeof path !== "string" || dirname(path) !== originalRuns || resolve(path) !== path || + !/^[0-9A-Za-z-]+\.(?:json|md)$/u.test(basename(path)) || extname(path) !== extension) { + throw new EffectRuntimeRequestError("Transfer run artifact address is outside its original owner"); + } + const retainedPath = join(runs, basename(path)), bytes = await snapshot(retainedPath); + if (bytes === null) {indexArtifactsClosed = false; continue;} + if (extension === ".json") { + const body = requiredObject(JSON.parse(text(bytes)!), "original run artifact"); + if (body.goal_id !== goalId || body.generated_at !== record.generated_at || body.classification !== record.classification) { + throw new EffectRuntimeRequestError("Original indexed run artifact identity differs"); + } + // Reuse the existing ledger reducer without opening a retired source + // address later. This is an observed projection, not a rewritten row. + if (body.quota_event !== undefined) local.quota_event = body.quota_event; + } + local[`original_${field}`] = path; + local[field] = retainedPath; + } + projected.push(local); + } const receipts: JsonObject[] = []; const seen = new Set(); for (const kind of ["spend", "void"] as const) { @@ -969,10 +1025,26 @@ export async function readGoalQuotaHistoryForTransfer(root: string, goalId: stri } } } - const facts = {closed: receipts.every(row => row.closed === true), receipts, + const facts = {closed: indexArtifactsClosed && receipts.every(row => row.closed === true), receipts, index_sha256: indexBytes === null ? null : sha256Bytes(indexBytes), files: [...files.values()].sort((a, b) => String(a.relative_path) < String(b.relative_path) ? -1 : 1)}; - return {...facts, sha256: canonicalAuthoritySha256(facts)}; + return {history: {...facts, sha256: canonicalAuthoritySha256(facts)}, records: projected}; +} + +export async function readGoalQuotaHistoryForTransfer(root: string, goalId: string, retainedRoot = root): Promise { + return (await loadGoalQuotaHistoryForTransfer(root, goalId, retainedRoot)).history; +} + +/** Original accounting and local artifact addresses, without active adoption. + * The expected inventory belongs to the authenticated transfer, not a caller's + * claim that an empty or damaged history means zero prior usage. */ +export async function projectGoalQuotaHistoryForTransfer(root: string, goalId: string, retainedRoot: string, + expected: JsonObject): Promise { + const result = await loadGoalQuotaHistoryForTransfer(root, goalId, retainedRoot); + if (canonicalJson(result.history) !== canonicalJson(expected) || result.history.closed !== true) { + throw new EffectRuntimeRequestError("Original quota history changed or is incomplete"); + } + return result.records; } export async function commitQuotaAccountingArtifactTransaction( diff --git a/loopx/history.py b/loopx/history.py index ce19d489b2..823085d2a9 100644 --- a/loopx/history.py +++ b/loopx/history.py @@ -274,6 +274,21 @@ def _observe_run_artifacts( record[target] = observed[path] +def _merge_index_record( + records: list[dict[str, Any]], positions: dict[tuple[str, str, str], int], item: dict[str, Any], +) -> None: + key = index_identity(item) + item = dict(item) + # These are observations, never persisted index authority. + item.pop("json_exists", None) + item.pop("markdown_exists", None) + if key in positions: + records[positions[key]].update(item) + else: + positions[key] = len(records) + records.append(item) + + def load_index_snapshot( path: Path, *, @@ -309,20 +324,7 @@ def load_index_snapshot( if not isinstance(item, dict): continue - key = ( - str(item.get("generated_at") or ""), - str(item.get("json_path") or ""), - str(item.get("markdown_path") or ""), - ) - item = dict(item) - # These are observations, never persisted index authority. - item.pop("json_exists", None) - item.pop("markdown_exists", None) - if key in positions: - records[positions[key]].update(item) - else: - positions[key] = len(records) - records.append(item) + _merge_index_record(records, positions, item) if include_artifact_status: _observe_run_artifacts(records, artifact_root=artifact_root) return RunIndexSnapshot( @@ -418,6 +420,21 @@ def collect_history( ) runs = index_snapshot.records raw_count = index_snapshot.raw_count + transfer_history = None + if isinstance(meta.get("planned_transfer"), dict): + from .control_plane.goals.planned_transfer_io import read_staged_goal_transfer_runs + + transferred = read_staged_goal_transfer_runs(runtime_root=runtime_root, goal_id=current_goal_id, + operation_id=meta["planned_transfer"]["operation_id"]) + if transferred["live_index_sha256"] != index_snapshot.digest: + raise ValueError("Staged target run index changed during history readback; retry its original operation") + merged: list[dict[str, Any]] = [] + positions: dict[tuple[str, str, str], int] = {} + for run in [*transferred["records"], *runs]: + _merge_index_record(merged, positions, run) + runs = merged + raw_count += transferred["raw_count"] + transfer_history = transferred["provenance"] runs = [ run for _, run in sorted( @@ -487,6 +504,8 @@ def collect_history( ), "semantic_history": goal_semantic_history_from_runs(runs), } + if transfer_history is not None: + goal_record["planned_transfer_history"] = transfer_history # Reduce the full index first. Old semantic evidence and lane windows # may survive outside --limit; every returned Run still needs fresh # flags. These references also cover the global recent_runs window. diff --git a/tests/control_plane/test_planned_transfer_accounting.py b/tests/control_plane/test_planned_transfer_accounting.py new file mode 100644 index 0000000000..f4b3fc8756 --- /dev/null +++ b/tests/control_plane/test_planned_transfer_accounting.py @@ -0,0 +1,180 @@ +"""Original accounting remains visible before destination execution is admitted. + +Real CLI-created File/SQLite Goals, protected transfer and native replay are +used here. A retained history is not a grant to dispatch or spend at the target. +""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from loopx.control_plane.effect_runtime import EffectRuntimeRemoteError, restart_effect_runtime +from loopx.control_plane.goals.planned_transfer_io import ( + create_planned_transfer_recipient, export_registered_goal_transfer, + preview_registered_goal_transfer, stage_protected_goal_transfer, read_staged_goal_transfer_runs, +) +from loopx.control_plane.coordination.legacy_writer_fence import legacy_coordination_writer_fence_path +from loopx.control_plane.quota.spend_commit import ( + record_quota_slot_spend_from_preview, replay_quota_spend_by_effect_ref, +) +from loopx.control_plane.quota.void_commit import commit_quota_slot_void +from loopx.control_plane.runtime.time import now_local_iso +from loopx.file_lock import exclusive_run_index_lock +from loopx.history import append_run_index_record, collect_history, write_reserved_run_artifacts +from tests.control_plane.test_planned_transfer_cancellation import source as source +from tests.control_plane.test_quota_spend_commit_runtime import _preview as quota_preview + + +def _stage(source: dict, tmp_path: Path, target_provider: str) -> tuple[dict, Path, Path]: + registry = source["registry"] + recipient = create_planned_transfer_recipient(destination=tmp_path / "recipient") + preview = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-accounting") + exported = export_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-accounting", + expected_preview_sha256=preview["preview_sha256"], recipient=recipient, destination=tmp_path / "portable", materials=[]) + target, target_runtime = tmp_path / "destination", tmp_path / "destination-runtime" + target.mkdir() + staged = stage_protected_goal_transfer(directory=tmp_path / "portable", private_key=tmp_path / "recipient/private-key.pem", + expected_artifact_sha256=exported["protected_artifact"]["artifact_sha256"], + project=target, runtime_root=target_runtime, provider=target_provider) + target_registry = target / ".loopx/registry.json" + return staged, target_runtime, target_registry + + +@pytest.mark.parametrize("target_provider", ["file", "sqlite"]) +@pytest.mark.parametrize("void_original", [False, True]) +def test_target_history_and_original_spend_replay_use_the_retained_accounting( + source: dict, tmp_path: Path, target_provider: str, void_original: bool, +) -> None: + runtime, registry = source["runtime"], source["registry"] + index = runtime / "goals/report/runs/index.jsonl" + # The existing read model merges duplicate historical rows. Transfer must + # preserve those bytes and the same one-debit interpretation. + with exclusive_run_index_lock(index, operation="fixture-original-duplicate"): + append_run_index_record(index, json.loads(index.read_text().splitlines()[0])) + record = {"generated_at": now_local_iso(), "goal_id": "report", "classification": "input_inspected", + "agent_id": "agent-a", "recommended_action": "Continue the original unfinished task"} + write_reserved_run_artifacts(runs_dir=index.parent, generated_at=record["generated_at"], + record=record, index_record=dict(record), payload=dict(record), render_markdown=lambda _payload: "# Original input observation") + if void_original: + corrected = commit_quota_slot_void({"runtime_root": str(runtime)}, goal_id="report", + voided_run_generated_at=source["debit"]["generated_at"], before=quota_preview()["after"], + effect_id="original-correction", reason_summary="Original accepted correction", execute=True, registry_path=registry) + assert corrected["appended"] is True + expected_spent = 0 if void_original else 1 + original = collect_history(registry_path=registry, runtime_root=runtime, goal_id="report", limit=50) + assert original["goals"][0]["quota"]["spent_slots"] == expected_spent + staged, target_runtime, target_registry = _stage(source, tmp_path, target_provider) + retained = Path(staged["original_runtime_history_root"]) + original_bytes = {p.relative_to(retained): p.read_bytes() for p in retained.rglob("*") if p.is_file()} + # The actual source is unavailable, so neither the accounting view nor a + # replay may quietly read the old absolute artifact paths. + restart_effect_runtime(wait_for_exit=True) + hidden_project, hidden_runtime = tmp_path / "source-project-offline", tmp_path / "source-runtime-offline" + source["project"].rename(hidden_project) + runtime.rename(hidden_runtime) + try: + history = collect_history(registry_path=target_registry, runtime_root=target_runtime, goal_id="report", limit=50) + goal = history["goals"][0] + assert goal["quota"]["spent_slots"] == expected_spent + assert goal["quota"]["spend_event_count"] == 1 + assert goal["quota"].get("void_event_count", 0) == int(void_original) + assert goal["raw_index_records"] == original["goals"][0]["raw_index_records"] + assert goal["unique_runs"] == original["goals"][0]["unique_runs"] + spent = [run for run in goal["latest_runs"] if run["classification"] == "quota_slot_spent"] + assert len(spent) == 1 + assert Path(spent[0]["json_path"]).is_relative_to(retained) + assert json.loads(Path(spent[0]["json_path"]).read_text())["quota_event"]["slots"] == 1 + assert goal["activation_state"] == "stopped" + assert goal["latest_status_run"]["classification"] == "input_inspected" + assert Path(goal["latest_status_run"]["json_path"]).is_relative_to(retained) + native_history = subprocess.run([sys.executable, "-I", "-m", "loopx.cli", "--format", "json", + "--registry", str(target_registry), "--runtime-root", str(target_runtime), + "history", "--goal-id", "report", "--limit", "1"], capture_output=True, text=True, timeout=60) + assert native_history.returncode == 0, native_history.stdout + native_history.stderr + assert json.loads(native_history.stdout)["goals"][0]["quota"]["spent_slots"] == expected_spent + for read_only in (True, False): + replay = replay_quota_spend_by_effect_ref(target_runtime, goal_id="report", + effect_ref=source["debit"]["effect_id"], agent_id="agent-a", read_only=read_only, registry_path=target_registry) + assert replay["ok"] is True and replay["replay_found"] is True + assert replay["appended"] is False and replay["idempotent_replay"] is True + wrong_agent = replay_quota_spend_by_effect_ref(target_runtime, goal_id="report", + effect_ref=source["debit"]["effect_id"], agent_id="another-agent", read_only=True, registry_path=target_registry) + assert wrong_agent["ok"] is False + # Even a matching old request cannot be repaired/committed as a fresh + # transaction in the stopped destination. + with pytest.raises((ValueError, EffectRuntimeRemoteError), match="prepared for transfer"): + record_quota_slot_spend_from_preview(quota_preview(goal_id="report", agent_id="agent-a", + effect_ref=source["debit"]["effect_id"]), {"runtime_root": str(target_runtime)}, + goal_id="report", execute=True, registry_path=target_registry) + assert not (target_runtime / "goals/report/runs/index.jsonl").exists() + assert original_bytes == {p.relative_to(retained): p.read_bytes() for p in retained.rglob("*") if p.is_file()} + restart_effect_runtime(wait_for_exit=True) + assert collect_history(registry_path=target_registry, runtime_root=target_runtime, goal_id="report", limit=1)["goals"][0]["quota"]["spent_slots"] == expected_spent + finally: + hidden_project.rename(source["project"]) + hidden_runtime.rename(runtime) + + +def test_corrupt_or_unbound_target_history_never_falls_back_to_zero_or_the_source(source: dict, tmp_path: Path) -> None: + staged, runtime, registry = _stage(source, tmp_path, source["provider"]) + retained = Path(staged["original_runtime_history_root"]) + + def inspect(): + return collect_history(registry_path=registry, runtime_root=runtime, goal_id="report", limit=10) + + def replay(): + return replay_quota_spend_by_effect_ref(runtime, goal_id="report", effect_ref=source["debit"]["effect_id"], + agent_id="agent-a", read_only=True, registry_path=registry) + + assert inspect()["goals"][0]["quota"]["spent_slots"] == 1 + + receipt = next((retained / "goals/report/runs/.transactions/quota-spend").glob("*.json")) + original = receipt.read_bytes() + receipt.write_bytes(original + b"\n") + for read in (inspect, replay): + with pytest.raises((ValueError, EffectRuntimeRemoteError), match="history changed"): + read() + assert receipt.read_bytes() == original + b"\n" + receipt.unlink() + receipt.symlink_to(Path(source["debit"]["json_path"])) + for read in (inspect, replay): + with pytest.raises((ValueError, EffectRuntimeRemoteError), match="linked"): + read() + receipt.unlink() + receipt.write_bytes(original) + fence = legacy_coordination_writer_fence_path(runtime_root=runtime, goal_id="report") + binding = fence.read_bytes() + fence.unlink() + for read in (inspect, replay): + with pytest.raises((ValueError, EffectRuntimeRemoteError), match="binding is unavailable"): + read() + fence.write_bytes(binding) + with pytest.raises(EffectRuntimeRemoteError, match="binding is unavailable"): + read_staged_goal_transfer_runs(runtime_root=runtime, goal_id="report", operation_id="another-operation") + live = runtime / "goals/report/runs/index.jsonl" + live.parent.mkdir(parents=True, exist_ok=True) + later = Path(source["debit"]["index_path"]).read_bytes() + live.write_bytes(later) + for read in (inspect, replay): + with pytest.raises((ValueError, EffectRuntimeRemoteError), match="outside its original owner"): + read() + assert live.read_bytes() == later + live.unlink() + assert inspect()["goals"][0]["quota"]["spent_slots"] == 1 + + +def test_existing_target_accounting_is_rejected_before_staging(source: dict, tmp_path: Path) -> None: + runtime = tmp_path / "destination-runtime" + written = record_quota_slot_spend_from_preview(quota_preview(goal_id="report", agent_id="agent-a", + effect_ref="already-owned-target#quota_spend"), {"runtime_root": str(runtime)}, goal_id="report", execute=True) + assert written["appended"] is True + original = {p: p.read_bytes() for p in (runtime / "goals/report/runs").rglob("*") if p.is_file()} + with pytest.raises(EffectRuntimeRemoteError, match="runtime accounting is occupied"): + _stage(source, tmp_path, source["provider"]) + assert original == {p: p.read_bytes() for p in original} + assert not (tmp_path / "destination/.loopx/registry.json").exists() + assert not legacy_coordination_writer_fence_path(runtime_root=runtime, goal_id="report").exists() diff --git a/tests/control_plane_ts/planned_transfer_history.test.ts b/tests/control_plane_ts/planned_transfer_history.test.ts index 4355049429..da9650ee82 100644 --- a/tests/control_plane_ts/planned_transfer_history.test.ts +++ b/tests/control_plane_ts/planned_transfer_history.test.ts @@ -7,7 +7,7 @@ import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import {evaluateQuotaSpendCommit, QUOTA_SPEND_COMMIT_REQUEST_SCHEMA} from "../../loopx/control_plane/quota/spend_commit.ts"; import {evaluateQuotaVoidCommit, QUOTA_VOID_COMMIT_REQUEST_SCHEMA} from "../../loopx/control_plane/quota/void_commit.ts"; -import {readGoalQuotaHistoryForTransfer} from "../../loopx/control_plane/quota/accounting_artifact_transaction.ts"; +import {projectGoalQuotaHistoryForTransfer, readGoalQuotaHistoryForTransfer} from "../../loopx/control_plane/quota/accounting_artifact_transaction.ts"; import {readGoalTurnHistoryForTransfer} from "../../loopx/control_plane/turn_driver/turn_journal_query.ts"; import {decodeGoalTransferHistory, goalTransferHistory, goalTransferHistoryInputs, inspectGoalTransferHistory, retainGoalTransferHistory} from "../../loopx/control_plane/goals/planned_transfer_history.ts"; @@ -38,6 +38,12 @@ async function fixture(t: TestContext) { expected_index_digest: spend.index_digest, before: {...after, quota: {spent_slots: 1, allowed_slots: 1440, window_hours: 24, compute: 1, slot_minutes: 1}}}); assert.equal(voided.status, "written"); + const run = {goal_id: "report", generated_at: "2026-08-25T12:02:00+08:00", classification: "input_inspected", + recommended_action: "Continue the original unfinished task"}; + const jsonPath = join(root, "goals/report/runs/20260825T120200-input-inspected.json"); + const markdownPath = join(root, "goals/report/runs/20260825T120200-input-inspected.md"); + await writeFile(jsonPath, JSON.stringify(run)); await writeFile(markdownPath, "# Original input observation\n"); + await writeFile(index, `${await readFile(index, "utf8")}${JSON.stringify({...run, json_path: jsonPath, markdown_path: markdownPath})}\n`); const quota = await readGoalQuotaHistoryForTransfer(root, "report"); assert.equal(quota.closed, true); assert.equal((quota.receipts as JsonObject[]).length, 2); const manifest = goalTransferHistory(root, "report", await readGoalTurnHistoryForTransfer(root, "report"), quota); @@ -60,6 +66,16 @@ test("retained spend/void content passes original owners after source loss, with const retained = join(f.destination, "runtime-history"); assert.deepEqual(await readFile(join(retained, "goals/report/runs/index.jsonl")), f.original); assert.deepEqual(await readGoalQuotaHistoryForTransfer(f.root, "report", retained), f.quota); + const projected = await projectGoalQuotaHistoryForTransfer(f.root, "report", retained, f.quota); + assert.equal(projected.length, 3); + assert.deepEqual(projected.map(row => row.classification), ["quota_slot_spent", "quota_slot_voided", "input_inspected"]); + assert.equal((projected[0]!.quota_event as JsonObject).slots, 1); + assert.equal((projected[1]!.quota_event as JsonObject).voided_run_generated_at, "2026-08-25T12:00:00+08:00"); + for (const row of projected) { + assert.ok(String(row.json_path).startsWith(retained)); + assert.ok(String(row.original_json_path).startsWith(f.root)); + assert.equal(JSON.parse(await readFile(String(row.json_path), "utf8")).goal_id, "report"); + } const replay = await evaluateQuotaSpendCommit({schema_version: QUOTA_SPEND_COMMIT_REQUEST_SCHEMA, operation: "replay", runtime_root: retained, goal_id: "report", effect_id: "original-effect#quota_spend", resolved_agent_id: "agent-a", read_only: true}); @@ -114,3 +130,21 @@ test("carrier rejects false closure and addresses outside the original owner inv assert.throws(() => decodeGoalTransferHistory({...manifest, files: []}), /inventories/); assert.throws(() => decodeGoalTransferHistory({...manifest, files: [manifest.files[0], manifest.files[0]]}), /address/); }); + +test("missing indexed delivery content holds source preparation without repairing or dropping the row", async t => { + const f = await fixture(t); + await rename(`${f.root}-unavailable`, f.root); + const path = join(f.root, "goals/report/runs/20260825T120200-input-inspected.json"); + const bytes = await readFile(path), index = join(f.root, "goals/report/runs/index.jsonl"); + await rm(path); + assert.equal((await readGoalQuotaHistoryForTransfer(f.root, "report")).closed, false); + assert.deepEqual(await readFile(index), f.original); + await assert.rejects(readFile(path), {code: "ENOENT"}); + await writeFile(path, bytes); + assert.deepEqual(await readGoalQuotaHistoryForTransfer(f.root, "report"), f.quota); + const rows = f.original.toString("utf8").trim().split("\n").map(line => JSON.parse(line)); + rows.at(-1).json_path = join(f.directory, "unrelated-private-input.json"); + await writeFile(index, `${rows.map(row => JSON.stringify(row)).join("\n")}\n`); + await assert.rejects(readGoalQuotaHistoryForTransfer(f.root, "report"), /outside its original owner/); + assert.deepEqual(await readFile(path), bytes); +}); From 44a90fb22ec5a75dcbf94e720da389b78a8b7eae Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:15:07 +0800 Subject: [PATCH 11/55] Preserve prepared Goal registrations during codec compensation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/configuration-backup.md | 15 ++++ .../goals/planned_transfer_io.py | 6 +- .../control_plane/projects/registry_codec.py | 67 ++++++++++++++--- .../test_planned_transfer_cancellation.py | 74 +++++++++++++++++++ .../test_project_registry_codec.py | 73 ++++++++++++++++++ 5 files changed, 223 insertions(+), 12 deletions(-) diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md index fbfe643e63..eae5e4418c 100644 --- a/docs/reference/configuration-backup.md +++ b/docs/reference/configuration-backup.md @@ -351,6 +351,21 @@ still separate, unqualified parts of this implementation. 目标仍停止,这些历史投影不授予当前扣额、配置、凭证或执行权限;最终切换、 目标准入及打包后的继续工作旅程尚未资格化。 +Registry compensation in this implementation also obeys the prepared Goal +owner. A transaction cannot restore an old registration after releasing its +lock or overwrite unrecognized later bytes. Ordinary compensation within the +original transaction still restores its exact prior bytes and format. If a +staged destination registration was published but its readback failed, a +refused compensation preserves that stopped route and reports recovery of the +original operation. Cold retry uses the retained operation; it does not delete +the route, reopen the source, or admit destination execution. + +中文:开发中的 registry 补偿也服从原 prepared Goal owner:事务释放锁后不能用 +旧对象恢复注册信息,也不能覆盖无法识别的后写字节。原事务内的普通补偿仍恢复 +准确原字节及格式。目标停止注册已发布而读回失败时,若 owner 拒绝补偿,就保留 +停止的目标路由,明确要求恢复原 operation;冷启动重试复用它,不删除路由、 +不重开源端,也不授予目标执行权限。 + ## Full-state recovery after cold import Keep the reviewed pre-import backup as well as a new full-state backup after diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index 2c39fc7f0f..6f5b1aad50 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -12,7 +12,7 @@ from ... import __version__ from ..effect_runtime import effect_runtime_result -from ..projects.registry_codec import load_project_registry, project_registry_transaction +from ..projects.registry_codec import load_registry, project_registry_transaction def require_goal_transfer_registry_write( @@ -48,7 +48,7 @@ def _read_goal_transfer_snapshot(*, registry_path: Path, goal_id: str) -> dict[s registry_path = registry_path.expanduser().resolve() with authority_registry_source(registry_path) as witness: - registry = load_project_registry(registry_path) + registry = load_registry(registry_path) goal = find_registry_goal(registry, goal_id) if goal is None: raise ValueError("Original Goal is not registered") @@ -263,7 +263,7 @@ def _stage_goal_transfer( if transaction.payload_copy() != registry: raise ValueError("Destination registry changed before publication; retry the original transfer without reverting later work") transaction.commit(proposed) - if load_project_registry(registry_path) != proposed: + if load_registry(registry_path) != proposed: raise ValueError("Planned transfer registry readback changed; recover the original operation") return {**result, "registry_readback_verified": True} diff --git a/loopx/control_plane/projects/registry_codec.py b/loopx/control_plane/projects/registry_codec.py index c6cb71e3c6..0db4208995 100644 --- a/loopx/control_plane/projects/registry_codec.py +++ b/loopx/control_plane/projects/registry_codec.py @@ -40,7 +40,7 @@ class ProjectRegistryProtocolError(ProjectRegistryError): class ProjectRegistryMutationError(ProjectRegistryError): - """A registry replacement could not be verified and was compensated.""" + """A registry replacement could not be verified.""" class ProjectRegistryRestoreError(ProjectRegistryMutationError): @@ -349,8 +349,16 @@ def __init__( self._existed = existed self._mode = mode self._finished = False + self._closed = False + self._publication_bytes = document.raw_bytes if existed else None + self._publication_payload = copy.deepcopy(document.payload) + + def _require_open(self) -> None: + if self._closed: + raise RuntimeError("project registry transaction is closed; open its current owner again") def payload_copy(self) -> dict[str, Any]: + self._require_open() return copy.deepcopy(self._document.payload) def source_read_witness(self) -> dict[str, str]: @@ -359,12 +367,14 @@ def source_read_witness(self) -> dict[str, str]: Consumers must recheck this witness before their independent commit; the digest is an observation, not a registry mutation permission. """ + self._require_open() if not self._existed or self._finished: raise RuntimeError("source read witness requires an open existing registry transaction") return {"path": str(self._path.resolve()), "sha256": hashlib.sha256(self._document.raw_bytes).hexdigest()} def commit(self, payload: dict[str, Any]) -> bool: + self._require_open() if self._finished: raise RuntimeError("project registry transaction is already finished") if not isinstance(payload, dict): @@ -374,13 +384,18 @@ def commit(self, payload: dict[str, Any]) -> bool: self._finished = True return False - self._require_transfer_write(payload) + self._require_transfer_write(before=self._document.payload, after=payload, + raw_bytes=self._document.raw_bytes if self._existed else None) encoded = _encode_document( payload, format=self._document.format, minimum_writer_protocol=self._document.minimum_writer_protocol, ) + # Compensation is authorized only for this exact attempted publication, + # never whatever happens to occupy the path after a failed readback. + self._publication_bytes = encoded + self._publication_payload = copy.deepcopy(payload) try: _atomic_write_bytes(self._path, encoded, mode=self._mode) readback = _read_document(self._path) @@ -398,7 +413,8 @@ def commit(self, payload: dict[str, Any]) -> bool: self.restore() except Exception as restore_exc: raise ProjectRegistryRestoreError( - "project registry readback failed and exact-byte restore failed" + "project registry readback failed; exact-byte compensation was refused or could not verify; " + "preserve current bytes and recover the original operation" ) from restore_exc raise ProjectRegistryMutationError( "project registry write readback failed; exact preimage restored" @@ -407,13 +423,17 @@ def commit(self, payload: dict[str, Any]) -> bool: return True def remove(self) -> bool: + self._require_open() if self._finished: raise RuntimeError("project registry transaction is already finished") if not self._path.exists(): self._finished = True return False - self._require_transfer_write({}) + self._require_transfer_write(before=self._document.payload, after={}, + raw_bytes=self._document.raw_bytes if self._existed else None) self._path.unlink() + self._publication_bytes = None + self._publication_payload = {} if self._path.exists(): raise ProjectRegistryMutationError( "project registry removal readback failed" @@ -421,17 +441,40 @@ def remove(self) -> bool: self._finished = True return True - def _require_transfer_write(self, payload: dict[str, Any]) -> None: + def _require_transfer_write(self, *, before: dict[str, Any], after: dict[str, Any], + raw_bytes: bytes | None) -> None: # The format-preserving codec retains serialization, locking and # compensation. The existing typed Goal owner decides prepared state. from ..goals.planned_transfer_io import require_goal_transfer_registry_write require_goal_transfer_registry_write(registry_path=self._path, - before=self._document.payload, after=payload, - registry_witness=(self.source_read_witness() if self._existed else - {"path": str(self._path.resolve()), "sha256": None})) + before=before, after=after, registry_witness={"path": str(self._path.resolve()), + "sha256": hashlib.sha256(raw_bytes).hexdigest() if raw_bytes is not None else None}) def restore(self) -> None: + self._require_open() + original = self._document.raw_bytes if self._existed else None + + def observed_bytes() -> bytes | None: + try: + return self._path.read_bytes() + except FileNotFoundError: + return None + + current = observed_bytes() + if current == original: + self._finished = True + return + if current != self._publication_bytes: + raise ProjectRegistryRestoreError( + "project registry changed after publication; preserve later bytes and recover its original operation" + ) + # Python owns byte compensation, not a second prepared-Goal policy. + # Use the same typed owner against the actual publication witness. + self._require_transfer_write(before=self._publication_payload, + after=self._document.payload if self._existed else {}, raw_bytes=current) + if observed_bytes() != current: + raise ProjectRegistryRestoreError("project registry changed during compensation; preserve later bytes") if self._existed: _atomic_write_bytes( self._path, @@ -478,12 +521,18 @@ def _registry_transaction( document = create_document() mode = None require_writer(document) - yield ProjectRegistryTransaction( + transaction = ProjectRegistryTransaction( expanded, document=document, existed=existed, mode=mode, ) + try: + yield transaction + finally: + # A retained object is an observation, not permission to mutate + # after this context releases the cross-runtime registry lock. + transaction._closed = True def _created_document( diff --git a/tests/control_plane/test_planned_transfer_cancellation.py b/tests/control_plane/test_planned_transfer_cancellation.py index b80b2634fb..e194577567 100644 --- a/tests/control_plane/test_planned_transfer_cancellation.py +++ b/tests/control_plane/test_planned_transfer_cancellation.py @@ -27,6 +27,7 @@ read_goal_transfer_context, stage_prepared_goal_transfer, ) from loopx.control_plane.projects.registry_codec import project_registry_transaction +from loopx.control_plane.projects import registry_codec from loopx.control_plane.quota.spend_commit import record_quota_slot_spend_from_preview, replay_quota_spend_by_effect_ref from loopx.control_plane.turn_driver.codex_sessions import _session_path, _store_codex_cli_session from loopx.file_lock import exclusive_cross_runtime_file_lock @@ -110,6 +111,79 @@ def cancel(source: dict, operation: str = "move-a") -> dict: return cancel_prepared_goal_transfer(runtime_root=source["runtime"], goal_id="report", operation_id=operation) +def test_old_registry_compensation_cannot_replace_a_prepared_source(source: dict) -> None: + registry = source["registry"] + with project_registry_transaction(registry, operation="old_compensation") as old: + pass + with project_registry_transaction(registry, operation="reviewed_source_name") as current: + payload = current.payload_copy() + payload["goals"][0]["display_name"] = "Reviewed source before transfer" + current.commit(payload) + preview = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-a") + prepare_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-a", + expected_preview_sha256=preview["preview_sha256"]) + before = registry.read_bytes(), source["fence"].read_bytes() + with pytest.raises(RuntimeError, match="closed"): + old.restore() + assert (registry.read_bytes(), source["fence"].read_bytes()) == before + # The Goal is prepared, not the whole registry. Compensation of an + # unrelated change remains legal under the original codec transaction. + with project_registry_transaction(registry, operation="unrelated_compensation") as tx: + tx.commit({**tx.payload_copy(), "local_display_note": "unrelated"}) + tx.restore() + assert (registry.read_bytes(), source["fence"].read_bytes()) == before + assert cancel(source)["readback"]["original_writer_fence_restored"] is True + assert registry.read_bytes() == before[0] + lease = source["acquire"]("continue-after-rejected-compensation")["lease"] + source["release"](lease) + + +def test_target_registration_readback_failure_retains_the_prepared_route_for_cold_retry( + source: dict, tmp_path: Path, monkeypatch, +) -> None: + snapshot = read_goal_transfer_context(registry_path=source["registry"], goal_id="report") + preview = preview_registered_goal_transfer(registry_path=source["registry"], goal_id="report", operation_id="move-a") + prepared = prepare_registered_goal_transfer(registry_path=source["registry"], goal_id="report", operation_id="move-a", + expected_preview_sha256=preview["preview_sha256"]) + project, runtime = tmp_path / "target-project", tmp_path / "target-runtime" + registry = project / ".loopx/registry.json" + real_read = registry_codec._read_document + failed = False + + def fail_publication_readback(path: Path): + nonlocal failed + if path == registry and not failed: + failed = True + raise OSError("interrupted target registration readback") + return real_read(path) + + monkeypatch.setattr(registry_codec, "_read_document", fail_publication_readback) + + def stage(): + return stage_prepared_goal_transfer(source_preparation=prepared, source_context=snapshot["source_context"], + archive=Path(prepared["archive"]), project=project, runtime_root=runtime, provider=source["provider"]) + + with pytest.raises(registry_codec.ProjectRegistryRestoreError) as failure: + stage() + assert failed and "prepared for transfer" in str(failure.value.__cause__) + # The native prepared owner, not an earlier Python preimage, determines + # which registration may remain. Its stopped route is recoverable in place. + retained = registry.read_bytes() + assert registry_codec.load_project_registry(registry)["goals"][0]["activation"]["state"] == "stopped" + fence = legacy_coordination_writer_fence_path(runtime_root=runtime, goal_id="report") + retained_fence = fence.read_bytes() + restart_effect_runtime(wait_for_exit=True) + recovered = stage() + assert recovered["registry_readback_verified"] is True + assert recovered["target_is_active"] is False and recovered["execution_authority_granted"] is False + assert registry.read_bytes() == retained and fence.read_bytes() == retained_fence + with pytest.raises(EffectRuntimeRemoteError, match="prepared for transfer"): + FirstPartyHostGoalAdmission.for_plan(registry_path=registry, goal_id="report", planned_goal_ref=None).require_current() + assert cancel(source)["readback"]["original_writer_fence_restored"] is True + lease = source["acquire"]("continue-after-target-registration-failure")["lease"] + source["release"](lease) + + def test_failed_outbound_publication_can_resume_the_original_source(source: dict, tmp_path: Path) -> None: registry, runtime = source["registry"], source["runtime"] snapshot = read_goal_transfer_context(registry_path=registry, goal_id="report") diff --git a/tests/control_plane/test_project_registry_codec.py b/tests/control_plane/test_project_registry_codec.py index dc06753b26..27358f0aa6 100644 --- a/tests/control_plane/test_project_registry_codec.py +++ b/tests/control_plane/test_project_registry_codec.py @@ -12,9 +12,11 @@ ProjectRegistryError, ProjectRegistryMutationError, ProjectRegistryProtocolError, + ProjectRegistryRestoreError, decode_project_registry, load_project_registry, mutate_project_registry, + project_registry_transaction, source_session_registry_transaction, ) from loopx.global_registry import ( @@ -363,3 +365,74 @@ def fail_first_readback(candidate: Path) -> registry_codec._ProjectRegistryDocum ) assert path.read_bytes() == before + + +@pytest.mark.parametrize("remove", [False, True]) +@pytest.mark.parametrize("format", ["legacy", "strict", "source_session"]) +def test_compensation_inside_original_transaction_restores_exact_bytes( + tmp_path: Path, remove: bool, format: str, +) -> None: + path = tmp_path / "registry.json" + initial: dict[str, object] = {"schema_version": "0.1", "goals": []} + if format == "source_session": + initial["profile_id"] = "source_session_v1" + transaction = source_session_registry_transaction + _write(path, [{"schema_version": "loopx_project_registry_envelope_v2", + "minimum_writer_protocol": "goal_instance_v2", "payload_sha256": _digest(initial)}, initial]) + else: + transaction = project_registry_transaction + _write(path, _strict(initial) if format == "strict" else initial) + path.chmod(0o640) + before = path.read_bytes() + with transaction(path, operation="compensate_owned_write") as tx: + if remove: + tx.remove() + else: + tx.commit({**tx.payload_copy(), "changed": True}) + tx.restore() + assert path.read_bytes() == before + assert path.stat().st_mode & 0o777 == 0o640 + + +def test_new_registry_compensation_removes_only_its_own_publication(tmp_path: Path) -> None: + path = tmp_path / "registry.json" + with project_registry_transaction(path, operation="compensate_creation", create=dict) as tx: + tx.commit({"goals": []}) + tx.restore() + assert not path.exists() + + +@pytest.mark.parametrize("method", ["commit", "remove", "restore", "source_read_witness"]) +def test_escaped_transaction_cannot_write_after_releasing_its_lock( + tmp_path: Path, method: str, +) -> None: + path = tmp_path / "registry.json" + _write(path, {"goals": []}) + with project_registry_transaction(path, operation="old_read") as tx: + pass + _write(path, {"goals": [], "later_work": "preserve"}) + later = path.read_bytes() + with pytest.raises(RuntimeError, match="closed"): + if method == "commit": + tx.commit({"goals": []}) + else: + getattr(tx, method)() + assert path.read_bytes() == later + + +@pytest.mark.parametrize("created", [False, True]) +def test_compensation_preserves_unrecognized_later_registry_bytes( + tmp_path: Path, created: bool, +) -> None: + path = tmp_path / "registry.json" + if not created: + _write(path, {"goals": []}) + with project_registry_transaction(path, operation="interrupted_publication", create=dict) as tx: + tx.commit({"goals": [], "owned": True}) + # An out-of-protocol write must remain visible for recovery, even if + # the ordinary transaction still owns its lock. + _write(path, {"goals": [], "later_work": "preserve"}) + later = path.read_bytes() + with pytest.raises(ProjectRegistryRestoreError, match="changed"): + tx.restore() + assert path.read_bytes() == later From 919770491d3b54f49d2f233de76c2e2b1ac5900a Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:05:23 +0800 Subject: [PATCH 12/55] feat(goals): expose transfer work and source cancellation in settings Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 26 ++++ .../goal-transfer-settings.css | 12 ++ .../goal-transfer-settings.tsx | 71 ++++++++++ .../workspace-settings-page.tsx | 2 + docs/reference/configuration-backup.md | 28 ++++ loopx/cli_commands/support_control.py | 8 ++ .../cli_commands/support_control_transfer.py | 51 +++++++ .../planned_transfer_authority.ts | 60 +++++++- .../control_plane/effect_runtime_handlers.ts | 1 + loopx/control_plane/goals/planned_transfer.ts | 7 +- .../goals/planned_transfer_contract.ts | 8 ++ .../goals/planned_transfer_io.py | 23 +++- loopx/help_surface.py | 1 + loopx/presentation/configuration_api.py | 3 + loopx/presentation/goal_transfer_api.py | 41 ++++++ .../test_planned_transfer_operation.py | 128 ++++++++++++++++++ 16 files changed, 461 insertions(+), 9 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.css create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx create mode 100644 loopx/cli_commands/support_control_transfer.py create mode 100644 loopx/control_plane/goals/planned_transfer_contract.ts create mode 100644 loopx/presentation/goal_transfer_api.py create mode 100644 tests/control_plane/test_planned_transfer_operation.py diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 62fd4f375e..9f366f5a62 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1,4 +1,5 @@ import type {OracleEvidence} from "./goal-acceptance-observation"; +import type {GoalTransferHoldCode} from "../../../../../loopx/control_plane/goals/planned_transfer_contract.js"; import { HANDOFF_MODES, EXECUTION_HANDOFF_MODES, type ExecutionHandoffMode } from "../../../../../loopx/control_plane/coordination/handoff_mode_vocabulary.js"; import { normalizeGoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js"; import { parseTurnStep, type TurnStep } from "./turn-steps.js"; @@ -38,6 +39,31 @@ export function restoreConfigurationCheckpoint(backup: Record, }); } +const goalTransferHoldSchema: z.ZodType = z.enum([ + "retained_leases", "turn_or_host_unsettled", "quota_unsettled", "destination_admission_unavailable", +]); +const goalTransferViewSchema = z.object({ + ok: z.literal(true), goal_id: z.string(), source_state: z.enum(["available", "prepared"]), + operation_id: z.string().nullable(), cancel_available: z.boolean(), continuation_available: z.literal(false), + work: z.object({objective: z.string(), agents: z.array(z.string()), + todos: z.array(z.object({todo_id: z.string(), text: z.string(), status: z.string()}))}), + holds: z.array(z.object({code: goalTransferHoldSchema, owner: z.string()})), +}); +export type GoalTransferView = z.infer; + +export async function inspectGoalTransfer(goalId: string) { + return goalTransferViewSchema.parse(await requestJson("/api/chat/goal-transfer", { + method: "POST", body: JSON.stringify({action: "inspect", goal_id: goalId}), + })); +} + +export async function cancelGoalTransfer(goalId: string, operationId: string) { + return z.object({ok: z.literal(true), decision: z.object({preparation_cancelled: z.literal(true)}), + readback: z.object({original_writer_fence_restored: z.boolean()})}).parse( + await requestJson("/api/chat/goal-transfer", {method: "POST", + body: JSON.stringify({action: "cancel", goal_id: goalId, operation_id: operationId})})); +} + function chatApiUrl(path: string) { if (!configuredChatOrigin || /^https?:\/\//.test(path)) { return path; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.css new file mode 100644 index 0000000000..be9b4aef7f --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.css @@ -0,0 +1,12 @@ +.personal-goal-transfer { + min-height: 0; + max-height: min(28rem, 40dvh); + overflow: auto; + overscroll-behavior: contain; + overflow-wrap: anywhere; +} +.personal-goal-transfer p, +.personal-goal-transfer li { + font-size: var(--text-body); + line-height: var(--leading-body); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx new file mode 100644 index 0000000000..79f7fe993d --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx @@ -0,0 +1,71 @@ +import {useRef, useState} from "react"; +import {cancelGoalTransfer, inspectGoalTransfer, type GoalTransferView} from "../../data/chat"; +import {useWorkspaceI18n} from "./i18n"; +import "./goal-transfer-settings.css"; + +/** Source inspection/recovery is a partial step of the same transfer journey. + * There is intentionally no cutover control before destination admission exists. */ +export function GoalTransferSettings({goalId}: {goalId: string}) { + const {locale} = useWorkspaceI18n(); + const zh = locale === "zh-CN"; + const [view, setView] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [cancelled, setCancelled] = useState(null); + const inFlight = useRef(false); + const holds: Record = { + retained_leases: zh ? "仍有未释放的任务租约,需由原任务结算。" : "Original tasks still hold unreleased leases.", + turn_or_host_unsettled: zh ? "原执行或 Host 进程尚未确认结束。" : "Original work or Host processes are not confirmed settled.", + quota_unsettled: zh ? "原执行扣额尚未结算。" : "Original accounting still needs settlement.", + destination_admission_unavailable: zh ? "目标环境的执行准入及原任务续办尚未接通,当前不能完成迁移。" : "Destination admission and original-task continuation are not connected yet. Moving this Agent is unavailable.", + }; + + async function perform(cancel = false) { + if (inFlight.current) return; + inFlight.current = true; setBusy(true); setError(""); + try { + if (cancel && view?.operation_id) { + const result = await cancelGoalTransfer(goalId, view.operation_id); + setCancelled(result.readback.original_writer_fence_restored); + // A later read failure must not describe a recorded cancellation as + // failed, nor keep an old confirmation button active after it committed. + setView(null); + } + setView(await inspectGoalTransfer(goalId)); + } catch { + setError(zh ? "无法读取原迁移状态,请重试。" : "The original transfer state could not be read. Retry."); + } finally {inFlight.current = false; setBusy(false);} + } + + return
{ + if (event.currentTarget.open && !view && !busy && !error) void perform(); + }}> + {zh ? "迁移 Agent" : "Move Agent"} + {busy ?

{zh ? "读取原工作与迁移状态…" : "Reading original work and transfer state…"}

: null} + {cancelled !== null ?

{cancelled + ? (zh ? "准备已取消,源环境的原写入边界已恢复。" : "Preparation cancelled. The original source write boundary is restored.") + : (zh ? "取消决定已记录;源环境已有后续状态,请读取当前状态。" : "Cancellation is recorded. The source has later state; read its current status.")}

: null} + {view ? <> +

{view.source_state === "prepared" + ? (zh ? "源环境已准备迁移,当前停止写入。" : "The source is prepared for transfer and rejects writes.") + : (zh ? "源环境可供检查;尚未切换到目标环境。" : "The source is available for inspection. No destination cutover has occurred.")}

+

{view.work.objective}

+
    {view.holds.map(hold =>
  • {holds[hold.code]}
  • )}
+
+ {zh ? "原工作" : "Original work"} · {view.work.todos.length} + {view.work.agents.length ?

{view.work.agents.join(" · ")}

: null} +
    {view.work.todos.map(todo =>
  • {todo.text}
  • )}
+
+ {view.cancel_available && view.operation_id ? <> +

{zh ? "取消本次准备,恢复源环境的原写入边界,以便重新领取并继续原任务。" : "Cancel this preparation to restore source writes so the original tasks can be claimed and continued."}

+ + : null} + : null} + {error ?

{error}

: null} + +
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx index 4d3850175b..fb128225cc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx @@ -14,6 +14,7 @@ import { GoalStorageSettings } from "./goal-storage-settings"; import { AutomationCadenceSettings } from "./automation-cadence-settings"; import { MachineConfigurationSettings } from "./machine-configuration-settings"; import { ConfigurationBackupSettings } from "./configuration-backup-settings"; +import { GoalTransferSettings } from "./goal-transfer-settings"; import { OperatorCredentialSettings } from "./operator-credential-settings"; import type { PersonalWorkspaceCallbacks, WorkspaceGoal, WorkspaceGoalNotification } from "./personal-workspace-model"; import type { WorkspaceTheme } from "./workspace-theme"; @@ -250,6 +251,7 @@ export function WorkspaceSettingsPage({ : null} {tab === "capabilities" && (capabilityScope === "machine" || capabilityGoalId) ? : null} + {tab === "capabilities" && capabilityScope === "goal" && capabilityGoalId ? : null} {tab === "cadence" && selectedGoal ? : null} {tab === "appearance" ? ( diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md index eae5e4418c..4476456074 100644 --- a/docs/reference/configuration-backup.md +++ b/docs/reference/configuration-backup.md @@ -366,6 +366,34 @@ the route, reopen the source, or admit destination execution. 停止的目标路由,明确要求恢复原 operation;冷启动重试复用它,不删除路由、 不重开源端,也不授予目标执行权限。 +### Original work and prepared-source recovery (in development) + +The Goal's existing Settings / Capability Center includes **Move Agent** for +original-work and preparation readback. Goal entry points preselect that Goal; +the device entry retains device scope. The shared `goal-transfer inspect +--goal-id GOAL` reads the same native owner, full original Todo declarations +and archive relations. It discovers a prepared operation without asking the +user for runtime paths, registry edits or separate owner commands. + +When the source is already prepared, **Cancel move preparation** records its +original cancellation and reads back the source fence in place. Cold retry and +duplicate confirmation retain that decision; later source work is preserved. +The CLI counterpart is `goal-transfer cancel --goal-id GOAL --operation-id +OPERATION --execute`. A recorded cancellation whose follow-up read failed +remains recorded; retry the read instead of treating it as an uncommitted move. +These entries expose current holds. Destination admission and original-task +continuation remain unavailable; this source recovery is not a completed Agent +migration or a qualification of installed/cross-machine operation. + +中文:开发中的 Goal 设置 / 能力中心新增“迁移 Agent”,与 `goal-transfer inspect +--goal-id GOAL` 共用原生 owner,读取原任务全文、归档和关系及当前准备状态。 +从 Goal 进入时自动选中该 Goal;设备入口仍为设备范围。原操作由 owner 发现, +不要求本人找 runtime 路径、修改 registry 或拼接多套命令。已准备的源端可在原处 +“取消迁移准备”,记录原取消决定并读回源写入边界;冷启动、重复确认和后写均保留。 +取消已记录但后续读取失败时,重试读取,不把已提交决定说成未发生。 +目标准入和原任务在目标续办仍未接通,当前显示明确 hold;这只是源端读回和恢复, +不是完整迁移、正常安装态或跨机资格。 + ## Full-state recovery after cold import Keep the reviewed pre-import backup as well as a new full-state backup after diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index 56c5b354b6..3dfc40f845 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -80,6 +80,7 @@ AddFormat = Callable[[argparse.ArgumentParser], None] SUPPORT_CONTROL_COMMANDS = { + "goal-transfer", "research-checkpoint", "configuration-backup", "automation-prompts", @@ -111,6 +112,8 @@ def register_support_control_commands( register_research_checkpoint(subparsers, add_subcommand_format) from .configuration_backup import register_configuration_backup register_configuration_backup(subparsers, add_subcommand_format) + from .support_control_transfer import register_goal_transfer + register_goal_transfer(subparsers, add_subcommand_format) register_heartbeat_control_commands(subparsers, add_subcommand_format) register_supervisor_control_commands(subparsers, add_subcommand_format) @@ -246,6 +249,11 @@ def handle_support_control_command( return handle_configuration_backup(args, registry_path=registry_path, print_payload=print_payload, output_format=output_format) + if args.command == "goal-transfer": + from .support_control_transfer import handle_goal_transfer + return handle_goal_transfer(args, registry_path=registry_path, + print_payload=print_payload, output_format=output_format) + if args.command == "research-checkpoint": from .research_checkpoint import handle_research_checkpoint return handle_research_checkpoint(args, registry_path=registry_path, diff --git a/loopx/cli_commands/support_control_transfer.py b/loopx/cli_commands/support_control_transfer.py new file mode 100644 index 0000000000..ae6b6226d8 --- /dev/null +++ b/loopx/cli_commands/support_control_transfer.py @@ -0,0 +1,51 @@ +"""CLI transport for the existing Goal-owned planned transfer operation.""" +from __future__ import annotations + +import argparse +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from ..control_plane.goals.planned_transfer_io import ( + cancel_registered_goal_transfer, + inspect_registered_goal_transfer, +) + + +def register_goal_transfer(subparsers: argparse._SubParsersAction[argparse.ArgumentParser], add_format: Callable[..., Any]) -> None: + parser = subparsers.add_parser("goal-transfer", help="Inspect original Agent work or cancel its prepared move; destination continuation is not available yet.") + actions = parser.add_subparsers(dest="goal_transfer_action", required=True) + for action in ("inspect", "cancel"): + command = actions.add_parser(action) + add_format(command) + command.add_argument("--goal-id", required=True) + if action == "cancel": + command.add_argument("--operation-id", required=True) + command.add_argument("--execute", required=True, action="store_true") + + +def render_goal_transfer(payload: dict[str, Any]) -> str: + lines = ["# Agent transfer", ""] + for key in ("goal_id", "source_state", "cancel_available", "continuation_available", "error"): + if key in payload: + lines.append(f"- {key}: {payload[key]}") + if "work" in payload: + lines.append(f"- Original work: {payload['work']['objective']}") + lines.extend(f" - {row['text']}" for row in payload["work"]["todos"]) + lines.extend(f"- Hold: {row['code']} ({row['owner']})" for row in payload.get("holds", [])) + if "readback" in payload: + lines.append(f"- Original source restored: {payload['readback']['original_writer_fence_restored']}") + return "\n".join(lines) + "\n" + + +def handle_goal_transfer(args: argparse.Namespace, *, registry_path: Path, + print_payload: Callable[..., Any], output_format: Callable[..., str]) -> int: + try: + payload = (inspect_registered_goal_transfer(registry_path=registry_path, goal_id=args.goal_id) + if args.goal_transfer_action == "inspect" else + {"ok": True, **cancel_registered_goal_transfer(registry_path=registry_path, goal_id=args.goal_id, + operation_id=args.operation_id)}) + except Exception as error: + payload = {"ok": False, "error": str(error)} + print_payload(payload, output_format(args), render_goal_transfer) + return 0 if payload.get("ok") else 1 diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index eb8f42c8ab..6a0bdc126e 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -9,6 +9,7 @@ import {mkdir, readFile} from "node:fs/promises"; import {isAbsolute, join, resolve} from "node:path"; import {createHash} from "node:crypto"; import type {JsonObject} from "../effect_program.ts"; +import type {GoalTransferHoldCode} from "../goals/planned_transfer_contract.ts"; import {durableWriteJson} from "../effect_runtime_io.ts"; import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; import {requireJsonObject} from "../runtime_decode.ts"; @@ -154,17 +155,35 @@ export async function previewRegisteredAuthorityForPlannedTransfer(value: JsonOb if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id", "source_context", "registry_witness"])) { reject("Registered source preview requires its complete original Goal context"); } + const operationId = requireAuthorityStoreId(value.operation_id, "transfer operation id"); + return visitRegisteredTransferSource(value, operationId, async source => source.preview); +} + +interface RegisteredTransferSource { + context: JsonObject; + objective: string; + preview: JsonObject; + projection: JsonObject; + preparedOperation: string | null; +} + +/** A product read is bound to the same source/maintenance observation as + * preparation. It does not rediscover authority from a persisted UI carrier. */ +async function visitRegisteredTransferSource(value: JsonObject, requestedOperation: string | null, + consume: (source: RegisteredTransferSource) => Promise): Promise { const root = requireLocalAuthorityRuntimeRoot(value.runtime_root); const goalId = requireAuthorityStoreId(value.goal_id, "source Goal id"); - const operationId = requireAuthorityStoreId(value.operation_id, "transfer operation id"); return withShadowMaintenanceLock(root, goalId, () => withVerifiedGoalTransferContext( - value.source_context, value.registry_witness, goalId, root, async context => { - await requireUncancelledPreparation(join(shadowManagementDirectory(root, goalId), "planned-transfers", canonicalAuthoritySha256(operationId))); + value.source_context, value.registry_witness, goalId, root, async (context, snapshot) => { const managed = await readShadowManagementState(root, goalId); if (managed && managed.status !== "inactive") reject("Source capture requires disposition before transfer preview"); const current = await loadLegacyCoordinationWriterFence(root, goalId); if (current.status !== "loaded") reject("Original source writer fence is unavailable"); let original = current.fence; + const preparedOperation = original.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA + ? requireAuthorityStoreId(original.operation_id, "prepared transfer operation") : null; + const operationId = requestedOperation ?? preparedOperation ?? `inspect:${goalId}`; + await requireUncancelledPreparation(join(shadowManagementDirectory(root, goalId), "planned-transfers", canonicalAuthoritySha256(operationId))); if (original.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) { const pending = await optionalJson(join(shadowManagementDirectory(root, goalId), "planned-transfers", canonicalAuthoritySha256(operationId), "preparation.json")); @@ -176,10 +195,43 @@ export async function previewRegisteredAuthorityForPlannedTransfer(value: JsonOb if (opened.provider !== "file" && opened.provider !== "sqlite") reject("Transfer preview requires a qualified local File/SQLite source"); const head = await opened.store.loadAuthority(), identity = await opened.store.storeIdentity(); if (head.status !== "loaded" || identity.status !== "available") reject("Original source history is unavailable; restore it before transfer"); - return registeredSourcePreview(context, original, opened.store, opened.provider, head, identity.store_identity, goalId, operationId); + const preview = await registeredSourcePreview(context, original, opened.store, opened.provider, head, identity.store_identity, goalId, operationId); + if (typeof snapshot.state_objective !== "string") reject("Original Goal objective is unreadable"); + return consume({context, objective: snapshot.state_objective, preview, projection: head.head, preparedOperation}); })); } +/** The caller-facing continuation view. This local vocabulary describes this + * Goal's actual preparation and its remaining continuation gap; it is not a + * replacement lifecycle, provider decision or execution grant. Full native + * Todo records retain original declarations, relations and acceptance fields. */ +export async function inspectRegisteredGoalTransfer(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "source_context", "registry_witness"])) { + reject("Transfer inspection requires its registered source observation"); + } + return visitRegisteredTransferSource(value, null, async source => { + const {context, preview, projection, preparedOperation} = source; + const goal = requireJsonObject(context.goal, "original Goal"); + const coordination = requireJsonObject(goal.coordination, "original coordination"); + const todos = indexCoordinationProjection(projection, String(goal.id)); + const holds: Array<{code: GoalTransferHoldCode; owner: string}> = []; + if ((preview.retained_leases as JsonObject[]).length) holds.push({code: "retained_leases", owner: "task_lease"}); + const turns = requireJsonObject(preview.turn_history, "original Turn history"); + if (turns.closed !== true || turns.turn_hosts_closed !== true) holds.push({code: "turn_or_host_unsettled", owner: "turn_host"}); + if (requireJsonObject(preview.quota_history, "original quota history").closed !== true) holds.push({code: "quota_unsettled", owner: "quota"}); + // Staging still has no lawful destination grant. A green source preview, + // a copied Task or a released lease must never erase that product gap. + holds.push({code: "destination_admission_unavailable", owner: "goal_admission"}); + return {ok: true, schema_version: "loopx_planned_transfer_operation_view_v0", goal_id: goal.id, + source_state: preparedOperation === null ? "available" : "prepared", operation_id: preparedOperation, + source_provider: preview.provider, source_revision: preview.provider_revision, + authority_preparation_ready: preview.ready_for_authority_preparation, + cancel_available: preparedOperation !== null, continuation_available: false, + work: {objective: source.objective, requirements: context.state_text, + agents: coordination.registered_agents ?? [], todos: todos.todo_ids.map(id => todos.todos.get(id)!)}, holds}; + }); +} + /** The maintenance guard serializes preparation against every ordinary native * writer. Expiration does not settle a lease: every retained token must have * been released through its original owner before this operation can fence it. diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 2e385a31c5..1a8cae0eca 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -417,6 +417,7 @@ export function createEffectRuntimeHandlers( ["coordination.local_authority.promote", lazyHandler(() => import("./coordination/local_authority_runtime.ts"), ({promoteLocalCoordinationAuthority}) => promoteLocalCoordinationAuthority)], ["coordination.authority_archive.manage", lazyHandler(() => import("./coordination/local_authority_archive.ts"), ({manageLocalAuthorityArchive}) => manageLocalAuthorityArchive)], ["coordination.planned_transfer.preview_source", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({previewRegisteredAuthorityForPlannedTransfer}) => previewRegisteredAuthorityForPlannedTransfer)], + ["coordination.planned_transfer.inspect_operation", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({inspectRegisteredGoalTransfer}) => inspectRegisteredGoalTransfer)], ["coordination.planned_transfer.prepare_authority", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({prepareAuthorityForPlannedTransfer}) => prepareAuthorityForPlannedTransfer)], ["coordination.planned_transfer.cancel_source_preparation", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({cancelSourcePreparation}) => cancelSourcePreparation)], ["coordination.planned_transfer.registry_write_check", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({checkPlannedTransferRegistryWrite}) => checkPlannedTransferRegistryWrite)], diff --git a/loopx/control_plane/goals/planned_transfer.ts b/loopx/control_plane/goals/planned_transfer.ts index 97483eb2cc..7ae11ad462 100644 --- a/loopx/control_plane/goals/planned_transfer.ts +++ b/loopx/control_plane/goals/planned_transfer.ts @@ -87,7 +87,7 @@ export async function readGoalTransferSnapshot(registry: string, goalId: string) * parser. Python owns codec/byte IO; Goal identity and profile decisions remain * here. The lifecycle-only profile cannot become a generic runtime grant. */ export async function withVerifiedGoalTransferContext(raw: unknown, rawWitness: unknown, - goalId: string, root: string, operation: (context: JsonObject) => Promise): Promise { + goalId: string, root: string, operation: (context: JsonObject, snapshot: JsonObject) => Promise): Promise { const context = requireJsonObject(raw, "original Goal context"); if (!hasExactAuthorityKeys(context, ["goal", "state_text", "runtime_root", "registry_path"]) || context.runtime_root !== root || typeof context.state_text !== "string" || !context.state_text.trim()) reject("Complete registered Goal context is required"); @@ -104,11 +104,12 @@ export async function withVerifiedGoalTransferContext(raw: unknown, rawWitnes if (observed.registry_role === "global-local") reject("A global registry is a read model; resolve the original project registration before transfer"); if (!canonicalAuthorityBytes(observed.registry_witness).equals(canonicalAuthorityBytes(witness))) reject("Registered Goal context changed; review its original transfer"); if (!canonicalAuthorityBytes(observed.source_context).equals(canonicalAuthorityBytes(context))) reject("Registered Goal intent differs from its reviewed transfer context"); + return observed; }; return withFileMutationLock(legacyCoordinationTodoLockPath(root, goalId), () => withFileMutationLock(state, () => withFileMutationLock(registry, async () => { - await verify(); - const result = await operation(context); + const snapshot = await verify(); + const result = await operation(context, snapshot); await verify(); return result; }))); diff --git a/loopx/control_plane/goals/planned_transfer_contract.ts b/loopx/control_plane/goals/planned_transfer_contract.ts new file mode 100644 index 0000000000..f3d53ce7d5 --- /dev/null +++ b/loopx/control_plane/goals/planned_transfer_contract.ts @@ -0,0 +1,8 @@ +/** Browser-safe projection vocabulary of the existing planned-transfer owner. + * These are inspection holds, not lifecycle states or execution permissions. + * Keep Node/provider dependencies in the owning implementation. */ +export type GoalTransferHoldCode = + | "retained_leases" + | "turn_or_host_unsettled" + | "quota_unsettled" + | "destination_admission_unavailable"; diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index 6f5b1aad50..63f60818da 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -45,6 +45,7 @@ def _read_goal_transfer_snapshot(*, registry_path: Path, goal_id: str) -> dict[s """Codec/byte IO only; the native Goal owner decides supported admission.""" from ...registry import find_registry_goal, resolve_state_file from ..coordination.authority_source_capture import authority_registry_source + from .active_state_metadata import active_state_section_text registry_path = registry_path.expanduser().resolve() with authority_registry_source(registry_path) as witness: @@ -55,10 +56,12 @@ def _read_goal_transfer_snapshot(*, registry_path: Path, goal_id: str) -> dict[s state = resolve_state_file(Path(goal["repo"]), goal["state_file"]) if state is None: raise ValueError("Original Goal has no state route") - return {"source_context": {"goal": goal, "state_text": state.read_bytes().decode("utf-8"), + state_text = state.read_bytes().decode("utf-8") + return {"source_context": {"goal": goal, "state_text": state_text, "runtime_root": registry["common_runtime_root"], "registry_path": str(registry_path)}, "registry_witness": witness, "registry_profile": registry.get("profile_id"), - "registry_role": registry.get("registry_role")} + "registry_role": registry.get("registry_role"), + "state_objective": active_state_section_text(state_text, "Objective")} def read_goal_transfer_context(*, registry_path: Path, goal_id: str) -> dict[str, Any]: @@ -85,6 +88,22 @@ def preview_registered_goal_transfer(*, registry_path: Path, goal_id: str, opera return result +def inspect_registered_goal_transfer(*, registry_path: Path, goal_id: str) -> dict[str, Any]: + """Shared Settings/CLI transport; the native owner discovers preparation.""" + context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.inspect_operation", { + "runtime_root": context["source_context"]["runtime_root"], "goal_id": goal_id, **context, + }, timeout=60.0, retry_safe=True) + return result + + +def cancel_registered_goal_transfer(*, registry_path: Path, goal_id: str, operation_id: str) -> dict[str, Any]: + """An address is not a grant; cancellation checks the retained native owner.""" + context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) + return cancel_prepared_goal_transfer(runtime_root=Path(context["source_context"]["runtime_root"]), + goal_id=goal_id, operation_id=operation_id) + + def prepare_registered_goal_transfer( *, registry_path: Path, goal_id: str, operation_id: str, expected_preview_sha256: str, ) -> dict[str, Any]: diff --git a/loopx/help_surface.py b/loopx/help_surface.py index 55abccdf52..3a099504e6 100644 --- a/loopx/help_surface.py +++ b/loopx/help_surface.py @@ -394,6 +394,7 @@ def register_command_reference(subparsers: argparse._SubParsersAction) -> None: "global-summary", "global-todos", "goal-actions", + "goal-transfer", "goal-alignment", "amendment-proposal", "goal-amendment-proposal", diff --git a/loopx/presentation/configuration_api.py b/loopx/presentation/configuration_api.py index fac088c452..99ecd7445d 100644 --- a/loopx/presentation/configuration_api.py +++ b/loopx/presentation/configuration_api.py @@ -5,6 +5,7 @@ from . import codex_attached_api as attached_api from . import workspace_preferences_api as preferences_api from . import configuration_backup_api as backup_api +from . import goal_transfer_api as transfer_api from . import state_recovery_api as recovery_api from . import goal_ownership_api as ownership_api from . import goal_amendment_api as amendment_api @@ -32,6 +33,7 @@ class ChatConfigurationRequestMixin( machine_api.MachineConfigurationRequestMixin, operator_api.OperatorProviderRequestMixin, backup_api.ConfigurationBackupRequestMixin, + transfer_api.GoalTransferRequestMixin, recovery_api.StateRecoveryRequestMixin, ): """Expose machine and Goal configuration through one route registry.""" @@ -57,6 +59,7 @@ def _configuration_get_routes(self) -> dict[str, Callable[[], None]]: def _configuration_post_routes(self) -> dict[str, Callable[[], None]]: return { + transfer_api.GOAL_TRANSFER_PATH: self._goal_transfer, attached_api.CODEX_ATTACHED_PATH: self._codex_attached_send, attached_api.CODEX_ATTACHED_OPEN_PATH: self._codex_attached_open, request_api.REQUEST_CONTROL_PATH: self._request_control_write, diff --git a/loopx/presentation/goal_transfer_api.py b/loopx/presentation/goal_transfer_api.py new file mode 100644 index 0000000000..6092954e32 --- /dev/null +++ b/loopx/presentation/goal_transfer_api.py @@ -0,0 +1,41 @@ +"""Path-free Settings transport for original Goal work and source recovery.""" +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +from ..control_plane.goals.planned_transfer_io import ( + cancel_registered_goal_transfer, + inspect_registered_goal_transfer, +) + +GOAL_TRANSFER_PATH = "/api/chat/goal-transfer" + + +class GoalTransferRequestMixin: + server: Any + + if TYPE_CHECKING: + def _read_json(self) -> dict[str, Any]: ... + def _send_json(self, payload: dict[str, Any], *, status: int = 200) -> None: ... + def _send_error(self, message: str, *, status: int, error_code: str) -> None: ... + + def _goal_transfer(self) -> None: + try: + body = self._read_json() + action = body.get("action") + fields = {"action", "goal_id", "operation_id"} if action == "cancel" else {"action", "goal_id"} + if set(body) != fields or action not in ("inspect", "cancel"): + raise ValueError("Unsupported transfer request") + if any(not isinstance(value, str) or not value.strip() for value in body.values()): + raise ValueError("Transfer request requires original identifiers") + # Both surfaces resolve the project source through the existing + # registry codec. Browser data cannot redirect filesystem owners. + if action == "inspect": + result = inspect_registered_goal_transfer(registry_path=self.server.registry_path, goal_id=body["goal_id"]) + else: + result = {"ok": True, **cancel_registered_goal_transfer(registry_path=self.server.registry_path, + goal_id=body["goal_id"], operation_id=body["operation_id"])} + self._send_json(result) + except Exception: + self._send_error("The original Agent transfer is unavailable. Retry its current source; no destination admission was issued.", + status=400, error_code="goal_transfer_unavailable") diff --git a/tests/control_plane/test_planned_transfer_operation.py b/tests/control_plane/test_planned_transfer_operation.py new file mode 100644 index 0000000000..01b3e89453 --- /dev/null +++ b/tests/control_plane/test_planned_transfer_operation.py @@ -0,0 +1,128 @@ +"""The product caller reads original work and recovers its prepared source. + +This is the source half of the transfer continuation journey. It deliberately +requires a visible destination-admission hold instead of a false move result. +""" +from __future__ import annotations + +import http.client +import json +import threading +from pathlib import Path + +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.effect_runtime import restart_effect_runtime +from loopx.control_plane.goals.planned_transfer_io import ( + prepare_registered_goal_transfer, + preview_registered_goal_transfer, +) +from tests.control_plane.test_planned_transfer_cancellation import source as source_fixture + +source = source_fixture + + +def _http(source: dict): + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.runtime_root = source["runtime"] + server.registry_path = source["registry"] + server.runtime_root_override = str(source["runtime"]) + server.verbose = False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def call(body: dict, *, origin: str | None = None): + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=90) + headers = {"Content-Type": "application/json"} + if origin: + headers["Origin"] = origin + connection.request("POST", "/api/chat/goal-transfer", json.dumps(body), headers) + response = connection.getresponse() + result = response.status, json.loads(response.read()) + connection.close() + return result + + return server, thread, call + + +def test_shared_caller_preserves_the_original_work_and_never_claims_cutover(source: dict): + registry, cli = source["registry"], source["cli"] + accepted = cli("todo", "add", "--goal-id", "report", "--role", "user", "--task-class", "user_action", + "--text", "Keep input.txt and accept the original sum report", + "--operation-id", "original-report-context")["todo_id"] + cli("todo", "complete", "--goal-id", "report", "--role", "user", "--todo-id", accepted, + "--agent-id", "agent-a", "--note", "Keep the original input and acceptance", "--evidence", "input.txt", + "--successor-todo-id", source["todo"]) + cli("todo", "archive-completed", "--goal-id", "report", "--role", "user", "--max-active-done", "0", "--execute") + before = registry.read_bytes(), source["fence"].read_bytes() + expected = cli("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] + original = read_canonical_todos_if_promoted(runtime_root=source["runtime"], goal_id="report") + assert original is not None + inspected = cli("goal-transfer", "inspect", "--goal-id", "report") + assert inspected["work"]["todos"] == original["todos"] + archived = next(row for row in inspected["work"]["todos"] if row["todo_id"] == accepted) + assert archived["archive_state"] == "archive" and archived["successor_todo_ids"] == [source["todo"]] + assert inspected["work"]["objective"] == "Produce report.txt from input.txt" + assert inspected["work"]["agents"] == ["agent-a"] + assert inspected["source_state"] == "available" and inspected["operation_id"] is None + assert inspected["continuation_available"] is False + assert any(row["code"] == "destination_admission_unavailable" for row in inspected["holds"]) + assert before == (registry.read_bytes(), source["fence"].read_bytes()) + + prepared = preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-a") + prepare_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-a", + expected_preview_sha256=prepared["preview_sha256"]) + server, thread, call = _http(source) + try: + status, app = call({"action": "inspect", "goal_id": "report"}) + assert status == 200 and app == cli("goal-transfer", "inspect", "--goal-id", "report") + assert app["source_state"] == "prepared" and app["operation_id"] == "move-a" + assert app["cancel_available"] is True + assert app["work"]["todos"] == original["todos"] + # Refusal is a real native-writer refusal, not a disabled UI button. + assert "prepared for transfer" in source["acquire"]("while-prepared", expected_code=1)["error"] + # A browser reload/cold transport does not require the user to find an + # operation id, runtime path, source receipt or owner command. + restart_effect_runtime(wait_for_exit=True) + assert call({"action": "inspect", "goal_id": "report"})[1] == app + status, cancelled = call({"action": "cancel", "goal_id": "report", "operation_id": "move-a"}) + assert status == 200 and cancelled["readback"]["original_writer_fence_restored"] is True + # Duplicate confirmation reads the original decision; no second grant. + assert call({"action": "cancel", "goal_id": "report", "operation_id": "move-a"})[1] == cancelled + resumed = call({"action": "inspect", "goal_id": "report"})[1] + assert resumed["source_state"] == "available" and resumed["work"]["todos"] == original["todos"] + lease = source["acquire"]("resume-original-report")["lease"] + # Independent acceptance is fixed by the original request/data, not by + # anything produced by the transfer implementation. This proves source + # recovery only; the destination has not produced this report. + report = Path(source["project"]) / "report.txt" + report.write_text(str(sum(map(int, (Path(source["project"]) / "input.txt").read_text().split()))) + "\n") + assert report.read_text() == "21\n" + source["release"](lease) + assert registry.read_bytes() == before[0] + assert cli("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] == expected + finally: + server.shutdown() + thread.join(5) + server.server_close() + + +def test_browser_cannot_supply_owner_paths_or_waive_destination_admission(source: dict): + server, thread, call = _http(source) + try: + before = source["registry"].read_bytes(), source["fence"].read_bytes() + for body in [ + {"action": "inspect", "goal_id": "report", "runtime_root": "/elsewhere"}, + {"action": "inspect", "goal_id": "report", "source_context": {}}, + {"action": "activate", "goal_id": "report"}, + {"action": "cancel", "goal_id": "report"}, + ]: + status, value = call(body) + assert status == 400 and value["ok"] is False + assert str(source["project"]) not in json.dumps(value) + assert call({"action": "inspect", "goal_id": "report"}, origin="https://untrusted.invalid")[0] == 403 + assert before == (source["registry"].read_bytes(), source["fence"].read_bytes()) + finally: + server.shutdown() + thread.join(5) + server.server_close() From c3acddc2fc52d3982980dbfda697aa53403a9945 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:06:45 +0800 Subject: [PATCH 13/55] feat: coordinate local task cutover and original-work readback Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 8 +- .../goal-transfer-settings.tsx | 15 +- docs/reference/configuration-backup.md | 61 +++- .../cli_commands/support_control_transfer.py | 28 +- .../coordination/legacy_writer_fence.ts | 38 ++ .../coordination/local_authority_write.ts | 26 +- .../planned_transfer_authority.ts | 61 +++- .../coordination/planned_transfer_target.ts | 20 +- .../control_plane/effect_runtime_handlers.ts | 2 + .../goals/planned_transfer_contract.ts | 3 +- .../goals/planned_transfer_cutover.ts | 324 ++++++++++++++++++ .../goals/planned_transfer_io.py | 36 ++ .../quota/accounting_artifact_transaction.ts | 7 +- .../work_items/task_lease_workspace.ts | 12 +- loopx/presentation/goal_transfer_api.py | 2 +- .../test_planned_transfer_cutover.py | 308 +++++++++++++++++ 16 files changed, 910 insertions(+), 41 deletions(-) create mode 100644 loopx/control_plane/goals/planned_transfer_cutover.ts create mode 100644 tests/control_plane/test_planned_transfer_cutover.py diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 9f366f5a62..f3d76d8a0d 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -40,13 +40,15 @@ export function restoreConfigurationCheckpoint(backup: Record, } const goalTransferHoldSchema: z.ZodType = z.enum([ - "retained_leases", "turn_or_host_unsettled", "quota_unsettled", "destination_admission_unavailable", + "retained_leases", "turn_or_host_unsettled", "quota_unsettled", "destination_admission_unavailable", "transfer_journey_incomplete", ]); const goalTransferViewSchema = z.object({ - ok: z.literal(true), goal_id: z.string(), source_state: z.enum(["available", "prepared"]), + ok: z.literal(true), goal_id: z.string(), source_state: z.enum(["available", "prepared", "cutover_recorded"]), operation_id: z.string().nullable(), cancel_available: z.boolean(), continuation_available: z.literal(false), + local_cutover: z.object({source_final: z.literal(true), target_task_admitted: z.boolean()}).optional(), work: z.object({objective: z.string(), agents: z.array(z.string()), - todos: z.array(z.object({todo_id: z.string(), text: z.string(), status: z.string()}))}), + todos: z.array(z.object({todo_id: z.string(), text: z.string(), status: z.string(), + note: z.string().nullish(), evidence: z.string().nullish()}))}), holds: z.array(z.object({code: goalTransferHoldSchema, owner: z.string()})), }); export type GoalTransferView = z.infer; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx index 79f7fe993d..60b5d9e174 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx @@ -18,11 +18,12 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { turn_or_host_unsettled: zh ? "原执行或 Host 进程尚未确认结束。" : "Original work or Host processes are not confirmed settled.", quota_unsettled: zh ? "原执行扣额尚未结算。" : "Original accounting still needs settlement.", destination_admission_unavailable: zh ? "目标环境的执行准入及原任务续办尚未接通,当前不能完成迁移。" : "Destination admission and original-task continuation are not connected yet. Moving this Agent is unavailable.", + transfer_journey_incomplete: zh ? "此处可读取本地切换、原任务状态和证据引用;完整迁移流程与安装环境验证仍在开发中。" : "Local cutover, original-task status and evidence references can be read here. The complete migration flow and installed-environment validation are still in development.", }; async function perform(cancel = false) { if (inFlight.current) return; - inFlight.current = true; setBusy(true); setError(""); + inFlight.current = true; setBusy(true); setError(""); setView(null); try { if (cancel && view?.operation_id) { const result = await cancelGoalTransfer(goalId, view.operation_id); @@ -46,7 +47,10 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { ? (zh ? "准备已取消,源环境的原写入边界已恢复。" : "Preparation cancelled. The original source write boundary is restored.") : (zh ? "取消决定已记录;源环境已有后续状态,请读取当前状态。" : "Cancellation is recorded. The source has later state; read its current status.")}

: null} {view ? <> -

{view.source_state === "prepared" +

{view.local_cutover ? (view.local_cutover.target_task_admitted + ? (zh ? "源环境已停止写入,目标可重新领取原任务的本地租约。下方为目标任务状态;Host 续办仍待验证。" : "Source writes are closed. The destination can acquire a fresh local lease for the original task. Status below comes from the destination; Host continuation still needs qualification.") + : (zh ? "切换决定已记录,源环境保持停写;目标尚待同一次操作恢复任务准入。" : "Cutover is recorded and source writes remain closed. Recover the same operation to finish destination Task admission.")) + : view.source_state === "prepared" ? (zh ? "源环境已准备迁移,当前停止写入。" : "The source is prepared for transfer and rejects writes.") : (zh ? "源环境可供检查;尚未切换到目标环境。" : "The source is available for inspection. No destination cutover has occurred.")}

{view.work.objective}

@@ -54,7 +58,12 @@ export function GoalTransferSettings({goalId}: {goalId: string}) {
{zh ? "原工作" : "Original work"} · {view.work.todos.length} {view.work.agents.length ?

{view.work.agents.join(" · ")}

: null} -
    {view.work.todos.map(todo =>
  • {todo.text}
  • )}
+
    {view.work.todos.map(todo =>
  • {todo.text} · {zh + ? ({open: "待完成", done: "已完成", blocked: "受阻", deferred: "已推迟"} as Record)[todo.status] ?? todo.status + : todo.status} + {todo.note ?

    {todo.note}

    : null} + {todo.evidence ?

    {zh ? "证据引用:" : "Evidence reference: "}{todo.evidence}

    : null} +
  • )}
{view.cancel_available && view.operation_id ? <>

{zh ? "取消本次准备,恢复源环境的原写入边界,以便重新领取并继续原任务。" : "Cancel this preparation to restore source writes so the original tasks can be claimed and continued."}

diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md index 4476456074..e96ec8d66c 100644 --- a/docs/reference/configuration-backup.md +++ b/docs/reference/configuration-backup.md @@ -381,9 +381,49 @@ duplicate confirmation retain that decision; later source work is preserved. The CLI counterpart is `goal-transfer cancel --goal-id GOAL --operation-id OPERATION --execute`. A recorded cancellation whose follow-up read failed remains recorded; retry the read instead of treating it as an uncommitted move. -These entries expose current holds. Destination admission and original-task -continuation remain unavailable; this source recovery is not a completed Agent -migration or a qualification of installed/cross-machine operation. +These entries expose current holds. The complete Settings migration journey is +still unavailable. A separate development CLI profile now coordinates a local +cutover between a prepared source and its already staged destination. Both +original local registry/runtime routes must remain reachable until admission: + +```sh +loopx --registry .loopx/registry.json goal-transfer cutover-preview \ + --goal-id GOAL --operation-id OPERATION \ + --destination-registry ../destination/.loopx/registry.json + +# Confirm the returned exact preview_sha256 once. +loopx --registry .loopx/registry.json goal-transfer cutover \ + --goal-id GOAL --operation-id OPERATION \ + --destination-registry ../destination/.loopx/registry.json \ + --expected-preview-sha256 REVIEWED_SHA256 --execute +``` + +The native Goal owner verifies the original File/SQLite history, protected +staging, mapped material bytes and single-Agent local Task configuration. Extra +capability, parent-Goal, Host or credential bindings remain held for their +owners. It records the source-final decision under the same lock as cancellation, +publishes only the exact destination registration through its existing codec, +then issues a distinct native **Task and accounting** admission. Host/session/ +effect execution remains closed; this receipt is not an Agent runtime grant. +It never forges a new-Goal creation receipt or installs an old lease/session. The destination must acquire +a fresh lease for the **original Todo id**. Original quota history remains the +basis for new work and duplicate-effect lookup; old receipts are unchanged. + +Before source-final, cancellation can restore the unchanged source. After +source-final, cancellation refuses: retry the **same operation and preview** to +finish destination admission. A lost response or later destination work never +authorizes rewriting its registry or restoring the source. Source Settings and +`goal-transfer inspect` read current destination Todo status and evidence references after cutover; +an unavailable destination fails the read instead of displaying a cached success. +Source preparation binds its observed OS installation; destination Task admission +also binds its original physical runtime. A copied receipt or recreated runtime +does not re-admit writes. +Provider/policy replacement and a further transfer still require their owning +lineage qualification. This is a source-online local Task continuation profile, +not portable cross-machine authorization or complete installed Agent migration. +Portable custody/security, required configuration/session dependencies, automatic +preparation, the complete Settings interaction and installed qualification remain +open. Existing ordinary Goals and their provider defaults retain their behavior. 中文:开发中的 Goal 设置 / 能力中心新增“迁移 Agent”,与 `goal-transfer inspect --goal-id GOAL` 共用原生 owner,读取原任务全文、归档和关系及当前准备状态。 @@ -391,8 +431,19 @@ migration or a qualification of installed/cross-machine operation. 不要求本人找 runtime 路径、修改 registry 或拼接多套命令。已准备的源端可在原处 “取消迁移准备”,记录原取消决定并读回源写入边界;冷启动、重复确认和后写均保留。 取消已记录但后续读取失败时,重试读取,不把已提交决定说成未发生。 -目标准入和原任务在目标续办仍未接通,当前显示明确 hold;这只是源端读回和恢复, -不是完整迁移、正常安装态或跨机资格。 +开发中的 shared CLI 另有本地 `cutover-preview` / `cutover`:对已准备的源及已暂存的 +目标预览后,用准确摘要确认一次。原生 Goal owner 对账原 File/SQLite 历史、受保护的 +暂存内容、材料映射和单 Agent 本地任务配置;额外能力、父 Goal、Host 或凭证绑定仍由 +原 owner 处理。源最终决定和取消互斥;目标经原 registry codec 读回后取得本地 Task/记账 +准入,Host/session/effect 执行仍关闭,这不是 Agent runtime 的完整准入。 +不伪造新 Goal 创建收据、不沿用旧 lease/session。目标为原 Todo 重新取得 lease;原账、 +原 effect 去重和收据保持有效。源最终决定之前可取消,之后仅同操作向前恢复;重复确认 +不覆盖目标后写。原设置和 `inspect` 从目标读取原任务状态和证据引用,目标失联则明确报错; +引用本身不代表已在此处读取产物字节或完成原 Goal 验收。 +源准备绑定原 OS 安装身份,目标任务准入还绑定原物理 runtime;复制收据或重建目录不获新写权限。 +此路径在准入前要求两个原本地环境均可达;不是跨机授权令牌。再次迁移和 provider/ +策略替换仍待原谱系 owner 资格。自动准备、完整设置交互、必要配置/session 依赖、 +安全封装和正常安装态资格仍未闭,不能据此宣称完整 Agent 迁移已交付。 ## Full-state recovery after cold import diff --git a/loopx/cli_commands/support_control_transfer.py b/loopx/cli_commands/support_control_transfer.py index ae6b6226d8..8c6ba72848 100644 --- a/loopx/cli_commands/support_control_transfer.py +++ b/loopx/cli_commands/support_control_transfer.py @@ -9,24 +9,31 @@ from ..control_plane.goals.planned_transfer_io import ( cancel_registered_goal_transfer, inspect_registered_goal_transfer, + local_goal_transfer_cutover, ) def register_goal_transfer(subparsers: argparse._SubParsersAction[argparse.ArgumentParser], add_format: Callable[..., Any]) -> None: - parser = subparsers.add_parser("goal-transfer", help="Inspect original Agent work or cancel its prepared move; destination continuation is not available yet.") + parser = subparsers.add_parser("goal-transfer", help="Inspect original Agent work, recover preparation, or review a local cutover (development profile).") actions = parser.add_subparsers(dest="goal_transfer_action", required=True) - for action in ("inspect", "cancel"): + for action in ("inspect", "cancel", "cutover-preview", "cutover"): command = actions.add_parser(action) add_format(command) command.add_argument("--goal-id", required=True) - if action == "cancel": + if action != "inspect": command.add_argument("--operation-id", required=True) + if action in ("cutover-preview", "cutover"): + command.add_argument("--destination-registry", required=True, type=Path, + help="Existing staged destination registry on this POSIX host; not a portable authorization token.") + if action in ("cancel", "cutover"): command.add_argument("--execute", required=True, action="store_true") + if action == "cutover": + command.add_argument("--expected-preview-sha256", required=True) def render_goal_transfer(payload: dict[str, Any]) -> str: lines = ["# Agent transfer", ""] - for key in ("goal_id", "source_state", "cancel_available", "continuation_available", "error"): + for key in ("goal_id", "source_state", "cancel_available", "continuation_available", "preview_sha256", "source_final", "target_task_admitted", "error"): if key in payload: lines.append(f"- {key}: {payload[key]}") if "work" in payload: @@ -41,10 +48,15 @@ def render_goal_transfer(payload: dict[str, Any]) -> str: def handle_goal_transfer(args: argparse.Namespace, *, registry_path: Path, print_payload: Callable[..., Any], output_format: Callable[..., str]) -> int: try: - payload = (inspect_registered_goal_transfer(registry_path=registry_path, goal_id=args.goal_id) - if args.goal_transfer_action == "inspect" else - {"ok": True, **cancel_registered_goal_transfer(registry_path=registry_path, goal_id=args.goal_id, - operation_id=args.operation_id)}) + if args.goal_transfer_action == "inspect": + payload = inspect_registered_goal_transfer(registry_path=registry_path, goal_id=args.goal_id) + elif args.goal_transfer_action == "cancel": + payload = {"ok": True, **cancel_registered_goal_transfer(registry_path=registry_path, goal_id=args.goal_id, + operation_id=args.operation_id)} + else: + payload = local_goal_transfer_cutover(registry_path=registry_path, goal_id=args.goal_id, + operation_id=args.operation_id, destination_registry=args.destination_registry, + expected_preview_sha256=getattr(args, "expected_preview_sha256", None)) except Exception as error: payload = {"ok": False, "error": str(error)} print_payload(payload, output_format(args), render_goal_transfer) diff --git a/loopx/control_plane/coordination/legacy_writer_fence.ts b/loopx/control_plane/coordination/legacy_writer_fence.ts index c50d0755d3..2b2d07c6d7 100644 --- a/loopx/control_plane/coordination/legacy_writer_fence.ts +++ b/loopx/control_plane/coordination/legacy_writer_fence.ts @@ -41,6 +41,9 @@ export const COLD_SOURCE_IMPORT_WRITER_FENCE_SCHEMA = "loopx_cold_source_import_ * Older decoders reject this schema rather than treating retained history as a * fresh execution grant. The role is part of the exact persisted binding. */ export const PLANNED_TRANSFER_WRITER_FENCE_SCHEMA = "loopx_planned_transfer_writer_fence_v0"; +/** Native Task/accounting admission after a final source decision. Host, + * session and effect execution remain held. This is not new-Goal creation. */ +export const PLANNED_TRANSFER_TASK_FENCE_SCHEMA = "loopx_planned_transfer_task_fence_v0"; export interface PlannedTransferWriterFence extends JsonObject { schema_version: typeof PLANNED_TRANSFER_WRITER_FENCE_SCHEMA; state: "engaged"; @@ -123,6 +126,15 @@ export function legacyCoordinationWriterFencePath(root: string, goalId: string): export function decodeLegacyCoordinationWriterFence(value: unknown): JsonObject { const fence = canonicalAuthorityObject(value, "legacy coordination writer fence"); + if (fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { + if (fence.state !== "engaged" || !hasExactAuthorityKeys(fence, + ["schema_version", "state", "goal_id", "fence_id", "operation_id", "source_identity_sha256", "task_admission_sha256", "store_identity"]) || + !BARE_SHA256_PATTERN.test(String(fence.source_identity_sha256)) || !BARE_SHA256_PATTERN.test(String(fence.task_admission_sha256))) { + throw new Error("Invalid planned transfer admission fence"); + } + for (const key of ["goal_id", "fence_id", "operation_id", "store_identity"]) requireAuthorityStoreId(fence[key], key); + return fence; + } if (fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) { if (fence.state !== "engaged" || !hasExactAuthorityKeys(fence, ["schema_version", "state", "goal_id", "fence_id", "operation_id", "source_identity_sha256", "role"]) || @@ -254,6 +266,32 @@ export async function restorePlannedTransferWriterFenceUnderLock(root: string, g }); } +/** The Goal cutover owner holds maintenance and has read back its registry and + * durable admission. Only the exact stopped target may enter this lineage. */ +export async function admitPlannedTransferTaskWriterUnderLock(root: string, goalId: string, + prepared: JsonObject, admitted: JsonObject): Promise { + decodeLegacyCoordinationWriterFence(prepared); decodeLegacyCoordinationWriterFence(admitted); + if (prepared.schema_version !== PLANNED_TRANSFER_WRITER_FENCE_SCHEMA || prepared.role !== "target" || + admitted.schema_version !== PLANNED_TRANSFER_TASK_FENCE_SCHEMA || prepared.goal_id !== goalId || admitted.goal_id !== goalId || + prepared.operation_id !== admitted.operation_id || prepared.source_identity_sha256 !== admitted.source_identity_sha256) { + throw new Error("Invalid planned target admission transition"); + } + const path = legacyCoordinationWriterFencePath(root, goalId); + await withFileMutationLock(path, async () => { + const current = await loadLegacyCoordinationWriterFence(root, goalId); + if (current.status !== "loaded") throw new Error("Prepared target writer fence is unavailable"); + if (canonicalAuthorityBytes(current.fence).equals(canonicalAuthorityBytes(admitted))) return; + if (!canonicalAuthorityBytes(current.fence).equals(canonicalAuthorityBytes(prepared))) throw new Error("Prepared target writer fence changed"); + // Durable publication is the last step, after source-final, registry and + // admission readback. An interrupted earlier step remains write-closed. + await atomicWriteJson(path, admitted); + const after = await loadLegacyCoordinationWriterFence(root, goalId); + if (after.status !== "loaded" || !canonicalAuthorityBytes(after.fence).equals(canonicalAuthorityBytes(admitted))) { + throw new Error("Target admission fence readback failed"); + } + }); +} + export async function loadLegacyCoordinationWriterFence( root: string, goalId: string, diff --git a/loopx/control_plane/coordination/local_authority_write.ts b/loopx/control_plane/coordination/local_authority_write.ts index 8ed1605889..393e621463 100644 --- a/loopx/control_plane/coordination/local_authority_write.ts +++ b/loopx/control_plane/coordination/local_authority_write.ts @@ -1,7 +1,7 @@ import {withFileMutationLock} from "../effect_runtime_io.ts"; import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; -import {loadLegacyCoordinationWriterFence, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; +import {loadLegacyCoordinationWriterFence, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, PLANNED_TRANSFER_TASK_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; import {requireShadowPrimaryWriteAllowed, shadowMaintenanceLockPath, withShadowMaintenanceLock} from "./shadow_management.ts"; import {requireLocalAuthorityRuntimeRoot} from "./local_authority_provider.ts"; import {hasExactAuthorityKeys, requireAuthorityStoreId} from "./authority_store_codec.ts"; @@ -66,7 +66,7 @@ interface CanonicalWriterOptions { * Ordinary missing/promotion fences preserve their existing runtime behavior. */ export async function withGoalRuntimeWriter(root: string, goalId: string, - write: () => Promise, borrowedMaintenance?: unknown): Promise { + write: () => Promise, borrowedMaintenance?: unknown, purpose: "runtime" | "quota_accounting" = "runtime"): Promise { return withShadowMaintenanceLock(root, goalId, async () => { const fence = await loadLegacyCoordinationWriterFence(root, goalId); if (fence.status === "failed") throw new EffectRuntimeConflictError(fence.reason, "canonical_authority_fence_invalid"); @@ -74,6 +74,16 @@ export async function withGoalRuntimeWriter(root: string, goalId: string, throw new EffectRuntimeConflictError(`Goal runtime is prepared for transfer ${fence.fence.operation_id}; recover that original operation`, "canonical_authority_transfer_prepared"); } + if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { + const {requireLocalTaskTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); + await requireLocalTaskTransferAdmission(root, goalId, fence.fence); + // This partial profile admits native Tasks and their accounting only. + // Host/session/effect callers cannot select the quota-owned purpose in + // their RPC payload. Whole-Agent continuation needs its actual owners. + if (purpose !== "quota_accounting") { + throw new EffectRuntimeConflictError("Local Task transfer has no Host/session/effect admission; qualify the original Goal runtime before continuing a Host"); + } + } return write(); }, borrowedMaintenance); } @@ -115,6 +125,13 @@ export async function requireCanonicalBusinessWrite( throw new CanonicalWriteRouteError("canonical_authority_transfer_prepared", goalId, operation, `Authority is prepared for transfer ${fence.fence.operation_id}; recover that operation before retrying`); } + if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { + const {requireLocalTaskTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); + await requireLocalTaskTransferAdmission(root, goalId, fence.fence); + if (operation === "authority_maintenance" || operation === "handoff_mode_set" || operation === "goal_create") { + throw new EffectRuntimeConflictError("Local Task transfer admission does not cover provider/policy replacement; use its owning lineage recovery"); + } + } } /** Canonical command writers share the maintenance guard; provider CAS owns state. */ @@ -137,6 +154,11 @@ export async function withCanonicalWriter( throw new CanonicalWriteRouteError("canonical_authority_transfer_prepared", goalId, "authority_maintenance", `Authority is prepared for transfer ${fence.fence.operation_id}; recover that operation before retrying`); } + if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { + const {requireLocalTaskTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); + await requireLocalTaskTransferAdmission(root, goalId, fence.fence); + throw new EffectRuntimeConflictError("Local Task transfer admission does not cover provider/policy replacement; use its owning lineage recovery"); + } } return await write(); }, CANONICAL_WRITER_LOCK_TIMEOUT_MS); diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index 6a0bdc126e..591d110747 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -5,7 +5,7 @@ * admission. The product coordinator must obtain those owners' decisions before * exposing a cutover. No public activation entry is registered here. */ -import {mkdir, readFile} from "node:fs/promises"; +import {mkdir, readFile, stat} from "node:fs/promises"; import {isAbsolute, join, resolve} from "node:path"; import {createHash} from "node:crypto"; import type {JsonObject} from "../effect_program.ts"; @@ -22,6 +22,7 @@ import {canonicalTaskLease} from "./task_lease_state.ts"; import {openLocalAuthorityStoreHandle, requireLocalAuthorityRuntimeRoot} from "./local_authority_provider.ts"; import {loadLegacyCoordinationWriterFence, preparePlannedTransferWriterFenceUnderLock, restorePlannedTransferWriterFenceUnderLock, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, + PLANNED_TRANSFER_TASK_FENCE_SCHEMA, type PlannedTransferWriterFence} from "./legacy_writer_fence.ts"; import {readShadowManagementState, shadowManagementDirectory, withShadowMaintenanceLock} from "./shadow_management.ts"; import {PREPARATION_SCHEMA, readGoalTransferSnapshot, withVerifiedGoalTransferContext} from "../goals/planned_transfer.ts"; @@ -33,6 +34,7 @@ import {NEW_GOAL_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; import {readGoalTurnHistoryForTransfer, readGoalTurnJournalsForTransfer} from "../turn_driver/turn_journal_query.ts"; import {goalTransferHistory, readGoalTransferHistoryJson} from "../goals/planned_transfer_history.ts"; import {readGoalQuotaHistoryForTransfer} from "../quota/accounting_artifact_transaction.ts"; +import {observeLocalWorkspaceHostIdentity} from "../work_items/task_lease_workspace.ts"; export {PREPARATION_SCHEMA} from "../goals/planned_transfer.ts"; const REGISTERED_SOURCE_PREVIEW_SCHEMA = "loopx_planned_transfer_source_preview_v0"; @@ -69,9 +71,20 @@ export async function checkPlannedTransferRegistryWrite(value: JsonObject): Prom const root = requireLocalAuthorityRuntimeRoot(rawRoot); const fence = await loadLegacyCoordinationWriterFence(root, goalId); if (fence.status === "failed") reject("Goal writer fence is unreadable; preserve its original registration"); - if (fence.status !== "loaded" || fence.fence.schema_version !== PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) continue; + if (fence.status !== "loaded" || ![PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, PLANNED_TRANSFER_TASK_FENCE_SCHEMA].includes(String(fence.fence.schema_version))) continue; const matches = proposed.filter(row => row !== null && typeof row === "object" && !Array.isArray(row) && (row as JsonObject).id === goalId); const prior = original.filter(row => row !== null && typeof row === "object" && !Array.isArray(row) && (row as JsonObject).id === goalId); + if (fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { + const {requireLocalTaskTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); + await requireLocalTaskTransferAdmission(root, goalId, fence.fence); + const beforeGoal = prior[0] as JsonObject | undefined, afterGoal = matches[0] as JsonObject | undefined; + if (prior.length !== 1 || matches.length !== 1 || root !== before.common_runtime_root || root !== after.common_runtime_root || + ["repo", "state_file", "planned_transfer", "goal_instance_id", "creation_operation_id"].some(key => !same(beforeGoal?.[key] ?? null, afterGoal?.[key] ?? null)) || + ["registry_role", "role", "profile_id"].some(key => !same(before[key] ?? null, after[key] ?? null))) { + reject("Admitted target lineage cannot be removed or redirected by registry metadata; use its Goal lifecycle owner"); + } + continue; + } if (prior.length === 0 && fence.fence.role === "target" && matches.length === 1) { const directory = join(shadowManagementDirectory(root, goalId), "planned-transfer-targets", canonicalAuthoritySha256(fence.fence.operation_id)); const registration = await optionalJson(join(directory, "registration.json")), receipt = await optionalJson(join(directory, "receipt.json")); @@ -81,6 +94,12 @@ export async function checkPlannedTransferRegistryWrite(value: JsonObject): Prom after.profile_id === undefined && after.registry_role !== "global-local" && after.role !== "global-local" && ["registry_role", "role", "profile_id"].every(key => same(before[key] ?? null, after[key] ?? null))) continue; } + if (prior.length === 1 && fence.fence.role === "target" && matches.length === 1 && + root === before.common_runtime_root && after.common_runtime_root === root && + ["registry_role", "role", "profile_id"].every(key => same(before[key] ?? null, after[key] ?? null))) { + const {localTransferRegistryPublication} = await import("../goals/planned_transfer_cutover.ts"); + if (await localTransferRegistryPublication(root, goalId, String(fence.fence.operation_id), String(witness.path), matches[0] as JsonObject)) continue; + } if (root !== before.common_runtime_root || prior.length !== 1 || matches.length !== 1 || !same(matches[0], prior[0]) || ["common_runtime_root", "registry_role", "role", "profile_id"].some(key => !same(before[key] ?? null, after[key] ?? null))) { reject(`Goal registration is prepared for transfer ${fence.fence.operation_id}; recover that original operation`); @@ -141,6 +160,7 @@ async function registeredSourcePreview(context: JsonObject, originalFence: JsonO const turnHistory = await readGoalTurnHistoryForTransfer(String(context.runtime_root), goalId); const quotaHistory = await readGoalQuotaHistoryForTransfer(String(context.runtime_root), goalId); const facts = {schema_version: REGISTERED_SOURCE_PREVIEW_SCHEMA, goal_id: goalId, operation_id: operationId, + source_host_identity: await observeLocalWorkspaceHostIdentity(), source_context_sha256: canonicalAuthoritySha256(context), original_registration_verified: true, original_writer_fence_sha256: canonicalAuthoritySha256(originalFence), provider, provider_revision: head.provider_revision, cursor: head.cursor, source_store_identity: storeIdentity, @@ -169,10 +189,11 @@ interface RegisteredTransferSource { /** A product read is bound to the same source/maintenance observation as * preparation. It does not rediscover authority from a persisted UI carrier. */ -async function visitRegisteredTransferSource(value: JsonObject, requestedOperation: string | null, +export async function visitRegisteredTransferSource(value: JsonObject, requestedOperation: string | null, consume: (source: RegisteredTransferSource) => Promise): Promise { const root = requireLocalAuthorityRuntimeRoot(value.runtime_root); const goalId = requireAuthorityStoreId(value.goal_id, "source Goal id"); + if (!(await stat(root)).isDirectory()) reject("Original source runtime is unavailable; restore it without reinitializing it"); return withShadowMaintenanceLock(root, goalId, () => withVerifiedGoalTransferContext( value.source_context, value.registry_witness, goalId, root, async (context, snapshot) => { const managed = await readShadowManagementState(root, goalId); @@ -189,6 +210,9 @@ async function visitRegisteredTransferSource(value: JsonObject, requestedOper canonicalAuthoritySha256(operationId), "preparation.json")); if (!pending || original.role !== "source" || original.operation_id !== operationId || original.source_identity_sha256 !== canonicalAuthoritySha256(pending)) reject("Source belongs to another prepared transfer; recover its original operation"); + if (pending.source_host_identity !== undefined && pending.source_host_identity !== await observeLocalWorkspaceHostIdentity()) { + reject("Prepared source belongs to another OS installation; use its original Goal owner"); + } original = requireJsonObject(pending.original_writer_fence, "original source fence"); } const opened = await openLocalAuthorityStoreHandle(root, goalId, {}, {existingOnly: true}); @@ -219,6 +243,19 @@ export async function inspectRegisteredGoalTransfer(value: JsonObject): Promise< const turns = requireJsonObject(preview.turn_history, "original Turn history"); if (turns.closed !== true || turns.turn_hosts_closed !== true) holds.push({code: "turn_or_host_unsettled", owner: "turn_host"}); if (requireJsonObject(preview.quota_history, "original quota history").closed !== true) holds.push({code: "quota_unsettled", owner: "quota"}); + const cutover = preparedOperation === null ? null : await optionalJson(join(shadowManagementDirectory(String(context.runtime_root), String(goal.id)), + "planned-transfers", canonicalAuthoritySha256(preparedOperation), "cutover.json")); + if (cutover) { + const {readLocalGoalTransferContinuation} = await import("../goals/planned_transfer_cutover.ts"); + const current = await readLocalGoalTransferContinuation(cutover); + return {ok: true, schema_version: "loopx_planned_transfer_operation_view_v0", goal_id: goal.id, + source_state: "cutover_recorded", operation_id: preparedOperation, source_provider: preview.provider, source_revision: preview.provider_revision, + authority_preparation_ready: false, cancel_available: false, continuation_available: false, + local_cutover: {source_final: true, target_task_admitted: current.target_task_admitted}, + work: current.work ?? {objective: source.objective, requirements: context.state_text, + agents: coordination.registered_agents ?? [], todos: todos.todo_ids.map(id => todos.todos.get(id)!)}, + holds: [{code: "transfer_journey_incomplete" satisfies GoalTransferHoldCode, owner: "goal_transfer"}]}; + } // Staging still has no lawful destination grant. A green source preview, // a copied Task or a released lease must never erase that product gap. holds.push({code: "destination_admission_unavailable", owner: "goal_admission"}); @@ -254,12 +291,14 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro const root = requireLocalAuthorityRuntimeRoot(value.runtime_root); const goal = requireAuthorityStoreId(value.goal_id, "transfer Goal id"); const operation = requireAuthorityStoreId(value.operation_id, "transfer operation id"); + if (!(await stat(root)).isDirectory()) reject("Original source runtime is unavailable; do not recreate its preparation"); const expectedRevision = requireAuthorityStoreId(value.expected_provider_revision, "reviewed source revision"); const request = {goal_id: goal, operation_id: operation, expected_provider_revision: expectedRevision, expected_writer_fence_sha256: value.expected_writer_fence_sha256}; const directory = join(shadowManagementDirectory(root, goal), "planned-transfers", canonicalAuthoritySha256(operation)); return withShadowMaintenanceLock(root, goal, async () => { await requireUncancelledPreparation(directory); + if (await optionalJson(join(directory, "cutover.json"))) reject("Source cutover is final; continue its original destination operation"); const prepare = async (context?: JsonObject) => { const managed = await readShadowManagementState(root, goal); if (managed && managed.status !== "inactive") reject("Source capture requires disposition before planned transfer"); @@ -286,6 +325,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro const pendingPath = join(directory, "preparation.json"); const pending = await optionalJson(pendingPath); const facts = {schema_version: PREPARATION_SCHEMA, request, + ...(registered ? {source_host_identity: await observeLocalWorkspaceHostIdentity()} : {}), source_provider: opened.provider, source_store_identity: identity.store_identity, source_cursor: head.cursor, source_projection_sha256: canonicalAuthoritySha256(head.head), source_turn_history: turnHistory, @@ -327,6 +367,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro captured.source_provider_revision !== head.provider_revision || captured.commits !== head.cursor || captured.projection_sha256 !== facts.source_projection_sha256) reject("Transfer archive differs from its prepared source"); const result: JsonObject = {schema_version: PREPARATION_SCHEMA, operation_id: operation, goal_id: goal, + ...(registered ? {source_host_identity: facts.source_host_identity} : {}), authority_write_fenced: true, authority_archive: {...captured}, archive, runtime_history: runtimeHistory, ...(context ? {source_context_sha256: canonicalAuthoritySha256(context)} : {}), @@ -365,17 +406,23 @@ export async function cancelSourcePreparation(value: JsonObject): Promise { + if (await optionalJson(join(directory, "cutover.json"))) reject("Source cutover is final; cancellation cannot reopen the old source"); const pending = await optionalJson(join(directory, "preparation.json")); if (!pending || pending.schema_version !== PREPARATION_SCHEMA || !hasExactAuthorityKeys(pending, ["schema_version", "request", "source_provider", "source_store_identity", "source_cursor", "source_projection_sha256", - "source_turn_history", "source_quota_history", "source_context", "source_context_sha256", "original_writer_fence"])) { + "source_turn_history", "source_quota_history", "source_context", "source_context_sha256", "original_writer_fence", + ...(pending.source_host_identity === undefined ? [] : ["source_host_identity"])])) { reject("Cancellation requires the retained original registered source preparation"); } const request = requireJsonObject(pending.request, "original preparation request"); const context = requireJsonObject(pending.source_context, "original registered source context"); const prior = requireJsonObject(pending.original_writer_fence, "original source fence"); + if (pending.source_host_identity !== undefined && pending.source_host_identity !== await observeLocalWorkspaceHostIdentity()) { + reject("Prepared source belongs to another OS installation; cancellation cannot adopt that source"); + } if (!hasExactAuthorityKeys(request, ["goal_id", "operation_id", "expected_provider_revision", "expected_writer_fence_sha256"]) || request.goal_id !== goal || request.operation_id !== operation || context.runtime_root !== root || pending.source_context_sha256 !== canonicalAuthoritySha256(context) || request.expected_writer_fence_sha256 !== canonicalAuthoritySha256(prior)) { @@ -384,11 +431,13 @@ export async function cancelSourcePreparation(value: JsonObject): Promise import("./coordination/planned_transfer_target.ts"), ({stageProtectedGoalTransfer}) => stageProtectedGoalTransfer)], ["coordination.planned_transfer.inspect_target_history", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({inspectStagedGoalTransferHistory}) => inspectStagedGoalTransferHistory)], ["coordination.planned_transfer.read_target_runs", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({readStagedGoalTransferRuns}) => readStagedGoalTransferRuns)], + ["coordination.planned_transfer.plan_local_cutover", lazyHandler(() => import("./goals/planned_transfer_cutover.ts"), ({planLocalGoalTransferCutover}) => planLocalGoalTransferCutover)], + ["coordination.planned_transfer.finish_local_cutover", lazyHandler(() => import("./goals/planned_transfer_cutover.ts"), ({finishLocalGoalTransferCutover}) => finishLocalGoalTransferCutover)], ["configuration.backup", lazyHandler(() => import("./configuration_backup.ts"), ({configurationBackupOperation}) => configurationBackupOperation)], ["runtime.checkpoint_artifacts", lazyHandler(() => import("./runtime/checkpoint_artifacts.ts"), ({checkpointArtifacts}) => checkpointArtifacts)], ["state.recovery.archive.verify", lazyHandler(() => import("./state_recovery.ts"), ({verifyStateRecoveryArchive}) => verifyStateRecoveryArchive)], diff --git a/loopx/control_plane/goals/planned_transfer_contract.ts b/loopx/control_plane/goals/planned_transfer_contract.ts index f3d53ce7d5..ed735a2ffb 100644 --- a/loopx/control_plane/goals/planned_transfer_contract.ts +++ b/loopx/control_plane/goals/planned_transfer_contract.ts @@ -5,4 +5,5 @@ export type GoalTransferHoldCode = | "retained_leases" | "turn_or_host_unsettled" | "quota_unsettled" - | "destination_admission_unavailable"; + | "destination_admission_unavailable" + | "transfer_journey_incomplete"; diff --git a/loopx/control_plane/goals/planned_transfer_cutover.ts b/loopx/control_plane/goals/planned_transfer_cutover.ts new file mode 100644 index 0000000000..fe93545393 --- /dev/null +++ b/loopx/control_plane/goals/planned_transfer_cutover.ts @@ -0,0 +1,324 @@ +/** Source-online cutover between two local registered environments. + * + * The original source is read through its native owner, not a copied receipt. + * Its final decision and cancellation share maintenance. The destination Goal + * owner admits native Tasks/accounting after registry readback; Host/session/ + * effect execution remains held. This is not cross-machine authorization. + */ +import {join, resolve} from "node:path"; +import {stat} from "node:fs/promises"; +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; +import {durableWriteJson} from "../effect_runtime_io.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys, requireAuthorityStoreId} from "../coordination/authority_store_codec.ts"; +import {visitRegisteredTransferSource} from "../coordination/planned_transfer_authority.ts"; +import {readStagedGoalTransferRuns} from "../coordination/planned_transfer_target.ts"; +import {auditAuthorityArchive} from "../coordination/authority_archive_audit.ts"; +import {openLocalAuthorityStoreHandle, requireLocalAuthorityRuntimeRoot} from "../coordination/local_authority_provider.ts"; +import {indexCoordinationProjection} from "../coordination/coordination_projection.ts"; +import {loadLegacyCoordinationWriterFence, admitPlannedTransferTaskWriterUnderLock, + PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, PLANNED_TRANSFER_TASK_FENCE_SCHEMA} from "../coordination/legacy_writer_fence.ts"; +import {shadowManagementDirectory, withShadowMaintenanceLock} from "../coordination/shadow_management.ts"; +import {readGoalTransferHistoryJson} from "./planned_transfer_history.ts"; +import {requirePhysicalTransferPath, inspectTransferFile} from "./planned_transfer_container.ts"; +import {readGoalTransferSnapshot, withVerifiedGoalTransferContext} from "./planned_transfer.ts"; +import {observeLocalWorkspaceHostIdentity} from "../work_items/task_lease_workspace.ts"; + +const CUTOVER_SCHEMA = "loopx_local_goal_transfer_cutover_v0"; +const ADMISSION_SCHEMA = "loopx_local_goal_transfer_task_admission_v0"; +function reject(message: string): never {throw new EffectRuntimeConflictError(message);} +function same(a: unknown, b: unknown): boolean {return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b));} +function operationDirectory(root: string, goal: string, operation: string, source: boolean): string { + return join(shadowManagementDirectory(root, goal), source ? "planned-transfers" : "planned-transfer-targets", canonicalAuthoritySha256(operation)); +} +async function optional(path: string): Promise { + try {return await readGoalTransferHistoryJson(path);} + catch (error) {if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error;} +} +async function retain(path: string, record: JsonObject): Promise { + const prior = await optional(path); + if (prior && !same(prior, record)) reject("Original cutover identity changed; preserve the original operation"); + if (!prior) await durableWriteJson(path, record); + if (!same(await optional(path), record)) reject("Cutover decision readback failed; retry the original operation"); +} +async function locationIdentity(root: string): Promise { + const entry = await stat(await requirePhysicalTransferPath(root)); + if (!entry.isDirectory()) reject("Local target runtime must remain its original physical directory"); + return canonicalAuthoritySha256({device: entry.dev, inode: entry.ino}); +} + +/** A positive, explicitly narrow owner profile, not absence inferred from an + * empty backup. Unknown Goal/adapter/config fields require their actual owner. + * Source sessions and credentials remain historical; none is auto-installed. + */ +function localTaskGoal(original: JsonObject, staged: JsonObject): JsonObject { + const keys = ["id", "display_name", "domain", "status", "role", "parent_goal_id", "repo", "state_file", + "authority_sources", "adapter", "spawn_policy", "coordination", "execution_profile", "next_probe", "guards", + "creation_operation_id", "activation"]; + if (Object.keys(original).some(key => !keys.includes(key)) || original.parent_goal_id != null) { + reject("Source Goal configuration requires its owning continuation profile"); + } + if (original.activation !== undefined && requireJsonObject(original.activation, "original activation").state !== "active") { + reject("A stopped original Goal requires its lifecycle decision before target activation"); + } + const adapter = requireJsonObject(original.adapter, "source adapter"); + const spawn = requireJsonObject(original.spawn_policy, "source spawn policy"); + const coordination = requireJsonObject(original.coordination, "source coordination"); + if (!hasExactAuthorityKeys(adapter, ["kind", "status"]) || adapter.kind !== "generic_project_goal_v0" || + spawn.allowed !== false || !Array.isArray(original.authority_sources) || original.authority_sources.length !== 0 || + Object.keys(coordination).some(key => !["write_scope", "requires_parent_approval", "registered_agents", "storage_target", "agent_model"].includes(key)) || + coordination.agent_model !== "peer_v1" || + !Array.isArray(coordination.registered_agents) || coordination.registered_agents.length !== 1 || + coordination.registered_agents.some(agent => typeof agent !== "string") || !Array.isArray(coordination.write_scope)) { + reject("Local Task transfer needs its qualified single-Agent configuration; required materials, capabilities or Host bindings need their original owner"); + } + const {storage_target: _originalCreationTarget, ...currentCoordination} = coordination; + return {...staged, domain: original.domain, role: original.role, + parent_goal_id: null, spawn_policy: spawn, coordination: currentCoordination, + ...(original.execution_profile === undefined ? {} : {execution_profile: original.execution_profile}), + ...(original.guards === undefined ? {} : {guards: original.guards}), + activation: {schema_version: "loopx_goal_activation_v1", state: "active", reason: "Native local Task admission; Host continuation remains unqualified"}, + planned_transfer: {...requireJsonObject(staged.planned_transfer, "staged transfer"), + state: "task_admitted", identity_continuity: "same_goal_task_continuation", fresh_admission_required: true, task_write_admitted: true}}; +} + +/** The registry codec calls this under its original transaction. This is only + * the exact native proposal, while the runtime fence is still closed. */ +export async function localTransferRegistryPublication(root: string, goal: string, + operation: string, registry: string, proposed: JsonObject): Promise { + const intent = await optional(join(operationDirectory(root, goal, operation, false), "admission.json")); + if (!intent || intent.schema_version !== ADMISSION_SCHEMA || intent.target_registry_path !== registry || + !same(intent.goal, proposed)) return false; + const decision = requireJsonObject(intent.decision, "native source cutover"); + return decision.schema_version === CUTOVER_SCHEMA && decision.goal_id === goal && decision.operation_id === operation && + same(await optional(join(operationDirectory(String(decision.source_runtime_root), goal, operation, true), "cutover.json")), decision); +} + +/** Read-only native Task admission used by canonical writers and accounting. + * It proves this runtime's retained lineage, not an imported lease/session. */ +export async function requireLocalTaskTransferAdmission(root: string, goal: string, fence: JsonObject): Promise { + if (fence.schema_version !== PLANNED_TRANSFER_TASK_FENCE_SCHEMA) reject("Target admission fence is required"); + const directory = operationDirectory(root, goal, String(fence.operation_id), false); + const receipt = await readGoalTransferHistoryJson(join(directory, "admission-receipt.json")); + const decision = requireJsonObject(receipt.decision, "admitted original cutover"); + const target = requireJsonObject(decision.target, "admitted destination"); + const intent = await readGoalTransferHistoryJson(join(directory, "admission.json")); + if (receipt.schema_version !== ADMISSION_SCHEMA || canonicalAuthoritySha256(receipt) !== fence.task_admission_sha256 || + !same(intent, receipt) || decision.goal_id !== goal || decision.operation_id !== fence.operation_id || + target.runtime_root !== root || target.identity_sha256 !== fence.source_identity_sha256 || + target.store_identity !== fence.store_identity) reject("Original target admission changed; recover its original operation"); + if (target.host_identity !== await observeLocalWorkspaceHostIdentity() || target.runtime_identity !== await locationIdentity(root)) { + reject("Target admission belongs to another host or recreated runtime; use its Goal lifecycle owner"); + } + const opened = await openLocalAuthorityStoreHandle(root, goal, {}, {existingOnly: true}); + const identity = await opened.store.storeIdentity(); + if (opened.provider !== target.provider || identity.status !== "available" || identity.store_identity !== fence.store_identity) { + reject("Admitted target provider lineage changed; preserve later state"); + } + return receipt; +} + +/** Path-free initiating-entry readback from the current native destination. + * Missing/disconnected state is an error, never the old source's cached result. + */ +export async function readLocalGoalTransferContinuation(decision: JsonObject): Promise { + const goal = requireAuthorityStoreId(decision.goal_id, "original Goal"); + const target = requireJsonObject(decision.target, "recorded cutover destination"); + const root = await requirePhysicalTransferPath(target.runtime_root); + // A read must not recreate a disconnected runtime just to acquire its lock. + // Provider/codec snapshots and exact before/after witnesses own this view; + // a changing observation refuses instead of holding a mutation lock. + const physical = await locationIdentity(root); + const fence = await loadLegacyCoordinationWriterFence(root, goal); + if (fence.status !== "loaded" || fence.fence.operation_id !== decision.operation_id || + fence.fence.source_identity_sha256 !== target.identity_sha256) reject("Original destination continuation binding is unavailable"); + if (fence.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA && fence.fence.role === "target") { + if (!same(fence, await loadLegacyCoordinationWriterFence(root, goal)) || physical !== await locationIdentity(root)) { + reject("Original destination changed during readback"); + } + return {target_task_admitted: false, work: null}; + } + const admission = await requireLocalTaskTransferAdmission(root, goal, fence.fence); + if (!same(admission.decision, decision)) reject("Original destination admission differs from source-final"); + const snapshot = await readGoalTransferSnapshot(String(target.registry_path), goal); + const opened = await openLocalAuthorityStoreHandle(root, goal, {}, {existingOnly: true}); + const head = await opened.store.loadAuthority(); + if (head.status !== "loaded") reject("Original destination work is unavailable"); + const index = indexCoordinationProjection(head.head, goal); + const context = requireJsonObject(snapshot.source_context, "current target Goal"); + const currentGoal = requireJsonObject(context.goal, "current Goal"); + const admittedGoal = requireJsonObject(admission.goal, "admitted Task Goal"); + if (snapshot.registry_profile !== null || snapshot.registry_role === "global-local" || context.runtime_root !== root || + currentGoal.goal_instance_id !== undefined || currentGoal.id !== goal || + ["repo", "state_file", "planned_transfer"].some(key => !same(currentGoal[key], admittedGoal[key]))) { + reject("Current target Goal requires its owning lifecycle; do not mix its original Task lineage"); + } + const coordination = requireJsonObject(currentGoal.coordination, "current Agent registration"); + const after = await opened.store.loadAuthority(); + if (after.status !== "loaded" || after.provider_revision !== head.provider_revision || + !same(fence, await loadLegacyCoordinationWriterFence(root, goal)) || physical !== await locationIdentity(root) || + !same(snapshot, await readGoalTransferSnapshot(String(target.registry_path), goal))) { + reject("Original destination changed during readback; retry its current state"); + } + return {target_task_admitted: true, work: {objective: snapshot.state_objective, requirements: context.state_text, + agents: coordination.registered_agents, todos: index.todo_ids.map(id => index.todos.get(id)!)}}; +} + +/** Plan and retain one final local source decision. There is no confirmation + * bypass: execution compares the full current proposal with the reviewed hash. + * Source->target lock order is safe for this profile: a stopped target cannot + * itself qualify as an original registered transfer source. + */ +export async function planLocalGoalTransferCutover(value: JsonObject): Promise { + const execute = value.execute === true; + if (!hasExactAuthorityKeys(value, ["source", "target_snapshot", "target_registry", + ...(execute ? ["execute", "expected_preview_sha256"] : [])])) reject("Local cutover requires complete current source and target observations"); + const source = requireJsonObject(value.source, "original source request"); + if (!hasExactAuthorityKeys(source, ["runtime_root", "goal_id", "operation_id", "source_context", "registry_witness"])) reject("Local cutover requires its original registered source"); + const root = requireLocalAuthorityRuntimeRoot(source.runtime_root), goal = requireAuthorityStoreId(source.goal_id, "original Goal"); + const operation = requireAuthorityStoreId(source.operation_id, "original transfer operation"); + const sourceDirectory = operationDirectory(root, goal, operation, true); + const snapshot = requireJsonObject(value.target_snapshot, "current target codec observation"); + const context = requireJsonObject(snapshot.source_context, "current destination context"); + const targetRoot = await requirePhysicalTransferPath(context.runtime_root); + await locationIdentity(targetRoot); + const targetGoal = requireJsonObject(context.goal, "current destination Goal"); + const registryPath = String(context.registry_path), registry = requireJsonObject(value.target_registry, "current destination registry"); + if (root === targetRoot || targetGoal.id !== goal || registry.common_runtime_root !== targetRoot || + !Array.isArray(registry.goals) || registry.goals.filter(raw => requireJsonObject(raw, "Goal").id === goal).length !== 1 || + !same(registry.goals.find(raw => requireJsonObject(raw, "Goal").id === goal), targetGoal)) reject("Destination registration differs from its codec observation"); + return visitRegisteredTransferSource(source, operation, async current => { + if (current.preparedOperation !== operation || current.preview.ready_for_authority_preparation !== true) reject("Original source is not prepared and settled for this cutover"); + return withShadowMaintenanceLock(targetRoot, goal, () => withVerifiedGoalTransferContext( + context, snapshot.registry_witness, goal, targetRoot, async () => { + const directory = operationDirectory(targetRoot, goal, operation, false); + const priorDecision = await optional(join(sourceDirectory, "cutover.json")); + const intent = await optional(join(directory, "admission.json")); + // A recorded final decision is immutable. Retry finishes only its exact + // remaining stage; after admission, never replace later Goal/state/work. + if (priorDecision) { + const target = requireJsonObject(priorDecision.target, "recorded destination"); + if (priorDecision.schema_version !== CUTOVER_SCHEMA || target.runtime_root !== targetRoot || target.registry_path !== registryPath || + priorDecision.goal_id !== goal || priorDecision.operation_id !== operation || !intent || !same(intent.decision, priorDecision)) { + reject("Source already chose another destination; recover its original cutover"); + } + if (execute && value.expected_preview_sha256 !== canonicalAuthoritySha256(priorDecision)) reject("Reviewed cutover identity changed"); + const fence = await loadLegacyCoordinationWriterFence(targetRoot, goal); + const admitted = fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA; + if (admitted) await requireLocalTaskTransferAdmission(targetRoot, goal, fence.fence); + if (!admitted && !same(targetGoal, intent.staged_goal) && !same(targetGoal, intent.goal)) reject("Destination changed after final cutover; preserve it for recovery"); + return {decision: priorDecision, preview_sha256: canonicalAuthoritySha256(priorDecision), + source_final: true, target_task_admitted: admitted, publish_registry: !admitted && same(targetGoal, intent.staged_goal), + destination_registry: admitted ? registry : {...registry, goals: (registry.goals as JsonObject[]).map(row => row.id === goal ? intent.goal : row)}}; + } + const pending = await readGoalTransferHistoryJson(join(sourceDirectory, "preparation.json")); + const sourceReceipt = await readGoalTransferHistoryJson(join(sourceDirectory, "receipt.json")); + const request = requireJsonObject(pending.request, "original source request"); + if (pending.source_projection_sha256 !== canonicalAuthoritySha256(current.projection) || + request.expected_provider_revision !== current.preview.provider_revision || pending.source_context_sha256 !== canonicalAuthoritySha256(current.context) || + pending.source_host_identity !== await observeLocalWorkspaceHostIdentity() || sourceReceipt.source_host_identity !== pending.source_host_identity) { + reject("Prepared source changed before cutover"); + } + const staged = await readGoalTransferHistoryJson(join(directory, "preparation.json")); + const receipt = await readGoalTransferHistoryJson(join(directory, "receipt.json")); + const registration = await readGoalTransferHistoryJson(join(directory, "registration.json")); + const original = await readGoalTransferHistoryJson(join(directory, "source-preparation.json")); + const target = requireJsonObject(staged.target, "staged target"); + if (!same(original, sourceReceipt) || !same(targetGoal, registration.goal) || + registration.registry_path !== registryPath || target.runtime_root !== targetRoot || target.registry_path !== registryPath || + receipt.runtime_history_readback_verified !== true || receipt.workspace_materials_readback_verified !== true || + receipt.material_readback_verified !== true || staged.source_context_sha256 !== pending.source_context_sha256 || + !same(sourceReceipt.authority_archive, receipt.original_authority)) reject("Destination is not the complete original protected stage"); + await readStagedGoalTransferRuns({runtime_root: targetRoot, goal_id: goal, operation_id: operation}); + const materials = requireJsonObject(staged.protected_transport, "reviewed destination materials"); + if (!Array.isArray(materials.workspace_destinations)) reject("Original workspace mapping is unavailable"); + const destinations = new Map((materials.workspace_destinations as JsonObject[]).map(row => [String(row.id), String(row.relative_path)])); + for (const raw of materials.materials as JsonObject[]) { + if (!destinations.has(String(raw.id))) reject("A required material has no reviewed destination"); + const path = resolve(String(target.project), destinations.get(String(raw.id))!); + const observed = await inspectTransferFile(path); + if (!same(observed, {sha256: raw.sha256, bytes: raw.bytes})) reject("Required destination material changed; review its original owner"); + } + const opened = await openLocalAuthorityStoreHandle(targetRoot, goal, {}, {existingOnly: true}); + const head = await opened.store.loadAuthority(), storeIdentity = await opened.store.storeIdentity(); + if (head.status !== "loaded" || storeIdentity.status !== "available" || storeIdentity.store_identity !== receipt.target_store_identity || + (await auditAuthorityArchive(String(sourceReceipt.archive), opened.store, + String(requireJsonObject(sourceReceipt.authority_archive, "original archive").archive_sha256))).status !== "matched") reject("Original target authority is missing or changed"); + const admittedGoal = localTaskGoal(requireJsonObject(current.context.goal, "original Goal"), targetGoal); + const originalCoordination = requireJsonObject(requireJsonObject(current.context.goal, "original Goal").coordination, "original coordination"); + const supportedCapabilities = new Set(["filesystem_read", "filesystem_write", "shell"]); + for (const todo of indexCoordinationProjection(current.projection, goal).todos.values()) { + if (todo.done === true || todo.archive_state === "archive") continue; + if ((todo.required_capabilities !== undefined && !Array.isArray(todo.required_capabilities)) || + (Array.isArray(todo.required_capabilities) && todo.required_capabilities.some(capability => !supportedCapabilities.has(String(capability)))) || + (todo.claimed_by && !(originalCoordination.registered_agents as unknown[]).includes(todo.claimed_by))) { + reject("Original work needs an unsupported capability or Agent binding; retain its owning continuation hold"); + } + } + const decision = {schema_version: CUTOVER_SCHEMA, goal_id: goal, operation_id: operation, source_runtime_root: root, + source_preparation_sha256: canonicalAuthoritySha256(pending), source_receipt_sha256: canonicalAuthoritySha256(sourceReceipt), + target: {...target, identity_sha256: receipt.identity_sha256, store_identity: storeIdentity.store_identity, + host_identity: await observeLocalWorkspaceHostIdentity(), runtime_identity: await locationIdentity(targetRoot), + provider_revision: head.provider_revision, stage_receipt_sha256: canonicalAuthoritySha256(receipt)}, + admitted_goal: admittedGoal, history_sha256: receipt.runtime_history_sha256, + profile: "source_online_local_task_v0", native_source_writes_closed: true, + source_sessions_adopted: false, credentials_adopted: false}; + const digest = canonicalAuthoritySha256(decision); + if (execute) { + if (value.expected_preview_sha256 !== digest) reject("Local cutover preview changed; review the current destination"); + // Intent first while target remains stopped. A crash before the source + // decision still permits source cancellation; no target grant exists. + await retain(join(directory, "admission.json"), {schema_version: ADMISSION_SCHEMA, decision, + target_registry_path: registryPath, staged_goal: targetGoal, goal: admittedGoal}); + await retain(join(sourceDirectory, "cutover.json"), decision); + } + return {decision, preview_sha256: digest, source_final: execute, target_task_admitted: false, publish_registry: execute, + destination_registry: {...registry, goals: (registry.goals as JsonObject[]).map(row => row.id === goal ? admittedGoal : row)}}; + })); + }); +} + +/** Codec publication precedes this final Task/accounting transition. A lost response + * after it is a readback retry, not another grant, debit or registry rewrite. */ +export async function finishLocalGoalTransferCutover(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id"])) reject("Original target cutover identity is required"); + const root = await requirePhysicalTransferPath(value.runtime_root), goal = requireAuthorityStoreId(value.goal_id, "original Goal"); + const operation = requireAuthorityStoreId(value.operation_id, "original transfer operation"); + await locationIdentity(root); + return withShadowMaintenanceLock(root, goal, async () => { + const directory = operationDirectory(root, goal, operation, false); + const intent = await readGoalTransferHistoryJson(join(directory, "admission.json")); + const decision = requireJsonObject(intent.decision, "final original source decision"); + const fence = await loadLegacyCoordinationWriterFence(root, goal); + if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { + await requireLocalTaskTransferAdmission(root, goal, fence.fence); + return {source_final: true, target_task_admitted: true, replayed: true, goal_id: goal, operation_id: operation}; + } + if (!await localTransferRegistryPublication(root, goal, operation, String(intent.target_registry_path), requireJsonObject(intent.goal, "admitted Goal"))) { + reject("Original source has no final cutover decision; target remains stopped"); + } + const target = requireJsonObject(decision.target, "original target"); + if (target.host_identity !== await observeLocalWorkspaceHostIdentity() || target.runtime_identity !== await locationIdentity(root)) { + reject("Target host or physical runtime changed before admission"); + } + if (fence.status !== "loaded" || fence.fence.schema_version !== PLANNED_TRANSFER_WRITER_FENCE_SCHEMA || fence.fence.role !== "target" || + fence.fence.operation_id !== operation || fence.fence.source_identity_sha256 !== target.identity_sha256) reject("Original stopped target fence changed"); + const observed = await readGoalTransferSnapshot(String(intent.target_registry_path), goal); + return withVerifiedGoalTransferContext(observed.source_context, observed.registry_witness, goal, root, async () => { + if (!same(requireJsonObject(observed.source_context, "target context").goal, intent.goal)) reject("Admitted target registry readback changed"); + const opened = await openLocalAuthorityStoreHandle(root, goal, {}, {existingOnly: true}); + const identity = await opened.store.storeIdentity(), head = await opened.store.loadAuthority(); + if (identity.status !== "available" || identity.store_identity !== target.store_identity || + head.status !== "loaded" || head.provider_revision !== target.provider_revision) reject("Target authority changed before admission; preserve it for recovery"); + await readStagedGoalTransferRuns({runtime_root: root, goal_id: goal, operation_id: operation}); + await retain(join(directory, "admission-receipt.json"), intent); + await admitPlannedTransferTaskWriterUnderLock(root, goal, fence.fence, { + schema_version: PLANNED_TRANSFER_TASK_FENCE_SCHEMA, state: "engaged", goal_id: goal, + fence_id: `planned-transfer-task:${operation}`, operation_id: operation, + source_identity_sha256: target.identity_sha256, task_admission_sha256: canonicalAuthoritySha256(intent), store_identity: target.store_identity, + }); + return {source_final: true, target_task_admitted: true, replayed: false, goal_id: goal, operation_id: operation}; + }); + }); +} diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index 63f60818da..f87d11e7a5 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -104,6 +104,42 @@ def cancel_registered_goal_transfer(*, registry_path: Path, goal_id: str, operat goal_id=goal_id, operation_id=operation_id) +def local_goal_transfer_cutover( + *, registry_path: Path, goal_id: str, operation_id: str, destination_registry: Path, + expected_preview_sha256: str | None = None, +) -> dict[str, Any]: + """One source-online local cutover; native owners decide every transition. + + No copied receipt authorizes this call. Both local registrations must be + readable through their actual codec. A final source decision has no reverse + compensation; same-operation recovery preserves the stopped destination. + """ + context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) + destination_registry = destination_registry.expanduser().absolute() + snapshot = _read_goal_transfer_snapshot(registry_path=destination_registry, goal_id=goal_id) + before = load_registry(destination_registry) + request: dict[str, Any] = {"source": {**context, "runtime_root": context["source_context"]["runtime_root"], + "goal_id": goal_id, "operation_id": operation_id}, "target_snapshot": snapshot, "target_registry": before} + if expected_preview_sha256 is not None: + request.update(execute=True, expected_preview_sha256=expected_preview_sha256) + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.plan_local_cutover", request, + timeout=300.0, retry_safe=False) + proposed = result.pop("destination_registry") + if expected_preview_sha256 is None: + return {"ok": True, **result} + if result.pop("publish_registry"): + with project_registry_transaction(destination_registry, operation="planned_transfer_admission_publish") as transaction: + if transaction.payload_copy() != before: + raise ValueError("Destination registry changed after source-final; retry the original cutover without reverting later work") + transaction.commit(proposed) + if load_registry(destination_registry) != proposed: + raise ValueError("Target admission registration readback failed; recover the same cutover") + admitted: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.finish_local_cutover", { + "runtime_root": snapshot["source_context"]["runtime_root"], "goal_id": goal_id, "operation_id": operation_id, + }, timeout=300.0, retry_safe=False) + return {"ok": True, **result, **admitted, "registry_readback_verified": True} + + def prepare_registered_goal_transfer( *, registry_path: Path, goal_id: str, operation_id: str, expected_preview_sha256: str, ) -> dict[str, Any]: diff --git a/loopx/control_plane/quota/accounting_artifact_transaction.ts b/loopx/control_plane/quota/accounting_artifact_transaction.ts index c2fb00d375..6810ee82b4 100644 --- a/loopx/control_plane/quota/accounting_artifact_transaction.ts +++ b/loopx/control_plane/quota/accounting_artifact_transaction.ts @@ -14,7 +14,7 @@ import { import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; import {withGoalRuntimeWriter} from "../coordination/local_authority_write.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; -import {loadLegacyCoordinationWriterFence, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA} from "../coordination/legacy_writer_fence.ts"; +import {loadLegacyCoordinationWriterFence, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, PLANNED_TRANSFER_TASK_FENCE_SCHEMA} from "../coordination/legacy_writer_fence.ts"; import {shadowManagementDirectory} from "../coordination/shadow_management.ts"; import { jsonObject, @@ -534,7 +534,8 @@ export async function lookupQuotaAccountingReplay( let retainedTarget = false; try {await lstat(join(shadowManagementDirectory(root, goalId), "planned-transfer-targets")); retainedTarget = true;} catch (error) {if (!isNodeErrorCode(error, "ENOENT")) throw error;} - const targetFence = fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA && fence.fence.role === "target"; + const targetFence = fence.status === "loaded" && ((fence.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA && fence.fence.role === "target") || + fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA); if (retainedTarget || targetFence) { // Retained lineage without its fence is damaged state, not an absent // old debit. Unrelated ordinary/legacy replay retains its prior rule. @@ -1197,7 +1198,7 @@ export async function commitQuotaAccountingArtifactTransaction( const lockedCommit = () => request.indexLockHeld ? commit() : withFileMutationLock(indexPath, commit); const goal = dirname(request.runsDir), goals = dirname(goal); if (basename(request.runsDir) === "runs" && basename(goals) === "goals") { - return withGoalRuntimeWriter(dirname(goals), basename(goal), lockedCommit, request.maintenanceLock); + return withGoalRuntimeWriter(dirname(goals), basename(goal), lockedCommit, request.maintenanceLock, "quota_accounting"); } if (request.maintenanceLock !== undefined) throw new EffectRuntimeRequestError("Quota maintenance handoff requires its canonical runtime route"); return lockedCommit(); diff --git a/loopx/control_plane/work_items/task_lease_workspace.ts b/loopx/control_plane/work_items/task_lease_workspace.ts index 17331884d3..07fb90f650 100644 --- a/loopx/control_plane/work_items/task_lease_workspace.ts +++ b/loopx/control_plane/work_items/task_lease_workspace.ts @@ -99,6 +99,14 @@ async function machineIdentity(): Promise { return undefined; } +/** Reuse the same OS-installation identity for local Goal admission. The + * digest is an observation, not a transferable credential or execution grant. */ +export async function observeLocalWorkspaceHostIdentity(): Promise { + const machine = await machineIdentity(); + if (!machine) throw new LeaseWorktreeObservationError("host identity unavailable for worktree isolation"); + return digest(machine); +} + export async function observeLeaseWorktree(path: string, overlaps: (path: string) => boolean): Promise { if (!isAbsolute(path)) throw new LeaseWorktreeObservationError("worktree observation requires an absolute caller path"); const cwd = await realpath(path); @@ -147,7 +155,5 @@ export async function observeLeaseWorktree(path: string, overlaps: (path: string // Private local paths are not persisted. Filesystem inode identity collapses // symlink/case aliases; a recreated directory gets a new identity. const key = async (p: string) => {const s = await stat(p); return digest(`${s.dev}:${s.ino}`);}; - const machine = await machineIdentity(); - if (!machine) throw new LeaseWorktreeObservationError("host identity unavailable for worktree isolation"); - return {host: digest(machine), common_directory: await key(common), worktree: await key(root), repository}; + return {host: await observeLocalWorkspaceHostIdentity(), common_directory: await key(common), worktree: await key(root), repository}; } diff --git a/loopx/presentation/goal_transfer_api.py b/loopx/presentation/goal_transfer_api.py index 6092954e32..480057bd91 100644 --- a/loopx/presentation/goal_transfer_api.py +++ b/loopx/presentation/goal_transfer_api.py @@ -37,5 +37,5 @@ def _goal_transfer(self) -> None: goal_id=body["goal_id"], operation_id=body["operation_id"])} self._send_json(result) except Exception: - self._send_error("The original Agent transfer is unavailable. Retry its current source; no destination admission was issued.", + self._send_error("The original transfer state is unavailable. Retry the same operation; recorded decisions and destination work are preserved.", status=400, error_code="goal_transfer_unavailable") diff --git a/tests/control_plane/test_planned_transfer_cutover.py b/tests/control_plane/test_planned_transfer_cutover.py new file mode 100644 index 0000000000..d31ffc7ee3 --- /dev/null +++ b/tests/control_plane/test_planned_transfer_cutover.py @@ -0,0 +1,308 @@ +"""Source-online local cutover continues the original Task through real owners. + +These synthetic two-environment CLI drills do not qualify a normal installed +App, cross-machine authorization, a model session or the complete user profile. +""" +from __future__ import annotations + +import hashlib +import json +import shutil +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + +from loopx.control_plane.coordination.legacy_writer_fence import legacy_coordination_writer_fence_path +from loopx.control_plane.effect_runtime import EffectRuntimeRemoteError, effect_runtime_result, restart_effect_runtime +from loopx.control_plane.goals.first_party_host_admission import FirstPartyHostGoalAdmission +from loopx.control_plane.goals.planned_transfer_io import ( + cancel_prepared_goal_transfer, create_planned_transfer_recipient, export_registered_goal_transfer, + local_goal_transfer_cutover, preview_protected_goal_transfer_target, preview_registered_goal_transfer, + stage_protected_goal_transfer, +) +from loopx.control_plane.goals import planned_transfer_io +from loopx.control_plane.projects.registry_codec import project_registry_transaction +from loopx.control_plane.turn_driver.codex_sessions import _session_path, _store_codex_cli_session +from loopx.control_plane.quota.spend_commit import record_quota_slot_spend_from_preview, replay_quota_spend_by_effect_ref +from loopx.history import collect_history +from tests.control_plane.test_planned_transfer_cancellation import source as source_fixture, operation_directory +from tests.control_plane.test_quota_spend_commit_runtime import _preview as quota_preview +from tests.control_plane.test_planned_transfer_operation import _http + +source = source_fixture +OPERATION = "move-original-task" + + +def _staged(source: dict, root: Path, provider: str): + recipient = create_planned_transfer_recipient(destination=root / "recipient") + preview = preview_registered_goal_transfer(registry_path=source["registry"], goal_id="report", operation_id=OPERATION) + material = source["project"] / "input.txt" + exported = export_registered_goal_transfer(registry_path=source["registry"], goal_id="report", operation_id=OPERATION, + expected_preview_sha256=preview["preview_sha256"], recipient=recipient, destination=root / "portable", + materials=[(material, hashlib.sha256(material.read_bytes()).hexdigest())]) + project, runtime = root / "destination", root / "destination-runtime" + project.mkdir() + artifact = exported["protected_artifact"]["artifact_sha256"] + arguments = dict(directory=root / "portable", private_key=root / "recipient/private-key.pem", + expected_artifact_sha256=artifact, project=project, runtime_root=runtime, provider=provider) + workspace = preview_protected_goal_transfer_target(**arguments) + staged = stage_protected_goal_transfer(**arguments, expected_workspace_preview_sha256=workspace["preview_sha256"]) + registry = project / ".loopx/registry.json" + + def cli(*arguments: str, expected_code: int | tuple[int, ...] = 0): + result = subprocess.run([sys.executable, "-I", "-m", "loopx.cli", "--format", "json", "--registry", str(registry), + "--runtime-root", str(runtime), *arguments], cwd=project, text=True, capture_output=True, timeout=90) + allowed = expected_code if isinstance(expected_code, tuple) else (expected_code,) + assert result.returncode in allowed, result.stdout + result.stderr + return json.loads(result.stdout) + + return {"project": project, "runtime": runtime, "registry": registry, "cli": cli, "staged": staged} + + +def _preview(source: dict, target: dict): + return source["cli"]("goal-transfer", "cutover-preview", "--goal-id", "report", "--operation-id", OPERATION, + "--destination-registry", str(target["registry"])) + + +def _cutover(source: dict, target: dict, preview: dict, *, expected_code: int = 0): + return source["cli"]("goal-transfer", "cutover", "--goal-id", "report", "--operation-id", OPERATION, + "--destination-registry", str(target["registry"]), "--expected-preview-sha256", preview["preview_sha256"], + "--execute", expected_code=expected_code) + + +def _target_worktree(target: dict): + project = target["project"] + def git(*arguments: str): + subprocess.run(["git", "-C", str(project), *arguments], check=True, capture_output=True) + git("init", "-q") + git("remote", "add", "origin", "https://example.org/fixtures/agent-transfer.git") + git("add", "input.txt") + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.org", "commit", "-qm", "Original transferred input") + worktree = project.parent / "destination-agent" + git("worktree", "add", "-qb", "continue-original", str(worktree)) + return worktree + + +@pytest.mark.parametrize("target_provider", ["file", "sqlite"]) +def test_exact_cutover_then_original_task_result_and_accounting(source: dict, tmp_path: Path, target_provider: str): + target = _staged(source, tmp_path, target_provider) + original = source["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] + source_bytes = source["registry"].read_bytes(), source["fence"].read_bytes() + preview = _preview(source, target) + assert preview["source_final"] is False and preview["target_task_admitted"] is False + assert not (operation_directory(source, OPERATION) / "cutover.json").exists() + assert source_bytes == (source["registry"].read_bytes(), source["fence"].read_bytes()) + assert _cutover(source, target, preview)["target_task_admitted"] is True + # The selected profile has qualified only native Task/accounting owners. + # It must not silently widen that result into Host, session or effect grants. + with pytest.raises(EffectRuntimeRemoteError, match="no Host/session/effect admission"): + _store_codex_cli_session(target["runtime"], lineage=source["lineage"], session_id="copied-session-is-not-admission") + assert not _session_path(target["runtime"], lineage=source["lineage"]).exists() + decision = (operation_directory(source, OPERATION) / "cutover.json").read_bytes() + # The source retains its prepared fence permanently; no old session, lease + # or runtime writer can become current again after destination admission. + assert source_bytes == (source["registry"].read_bytes(), source["fence"].read_bytes()) + with pytest.raises(EffectRuntimeRemoteError, match="cutover is final"): + cancel_prepared_goal_transfer(runtime_root=source["runtime"], goal_id="report", operation_id=OPERATION) + assert "prepared for transfer" in source["acquire"]("stale-source", expected_code=1)["error"] + with pytest.raises((ValueError, EffectRuntimeRemoteError), match="prepared for transfer"): + source["admission"].require_current() + assert target["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] == original + worktree = _target_worktree(target) + old = target["cli"]("task-lease", "acquire", "--goal-id", "report", "--todo-id", source["todo"], "--owner", "agent-a", + "--idempotency-key", "original-work", "--write-scope", "report.txt", "--write-worktree", str(worktree), expected_code=1) + assert old["ok"] is False + lease = target["cli"]("task-lease", "acquire", "--goal-id", "report", "--todo-id", source["todo"], "--owner", "agent-a", + "--idempotency-key", "continue-original", "--write-scope", "report.txt", "--write-worktree", str(worktree))["lease"] + assert lease["lease_epoch"] > 1 and lease["status"] == "active" + old_release = target["cli"]("task-lease", "release", "--goal-id", "report", "--todo-id", source["todo"], "--owner", "agent-a", + "--idempotency-key", "original-work", "--expected-version", "1", expected_code=(0, 1)) + if old_release.get("ok"): + assert old_release["lease"]["idempotency_key"] == "original-work" + assert old_release["lease"]["status"] == "released" + # A historical readback or a stale-proof refusal are both legal. Neither + # may release the new epoch; completion below must accept its unchanged proof. + # A deterministic Agent stand-in performs the actual original request under + # the new target lease. The independent expected result predates transfer. + report = worktree / "report.txt" + report.write_text(f"{sum(map(int, (worktree / 'input.txt').read_text().split()))}\n") + assert report.read_text() == "21\n" + done = target["cli"]("todo", "complete", "--goal-id", "report", "--todo-id", source["todo"], "--agent-id", "agent-a", + "--task-lease-idempotency-key", lease["idempotency_key"], "--task-lease-expected-version", str(lease["version"]), + "--evidence", "report.txt", "--note", "Original sum independently checked as 21") + assert done["ok"] is True + current = target["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] + assert current["done"] is True and current["text"] == original["text"] + assert current["todo_id"] == source["todo"] and current["required_write_scopes"] == original["required_write_scopes"] + # The initiating source entry follows the native destination. It does not + # mistake its own still-open historical Task for the destination result. + observed = source["cli"]("goal-transfer", "inspect", "--goal-id", "report") + assert observed["source_state"] == "cutover_recorded" and observed["cancel_available"] is False + assert observed["local_cutover"]["target_task_admitted"] is True and observed["continuation_available"] is False + assert next(row for row in observed["work"]["todos"] if row["todo_id"] == source["todo"])["done"] is True + server, thread, call = _http(source) + try: + assert call({"action": "inspect", "goal_id": "report"}) == (200, observed) + assert call({"action": "cancel", "goal_id": "report", "operation_id": OPERATION})[0] == 400 + finally: + server.shutdown() + thread.join(5) + server.server_close() + replay = replay_quota_spend_by_effect_ref(target["runtime"], goal_id="report", effect_ref=source["debit"]["effect_id"], + agent_id="agent-a", read_only=False, registry_path=target["registry"]) + assert replay["replay_found"] is True and replay["appended"] is False + spend = record_quota_slot_spend_from_preview(quota_preview(goal_id="report", agent_id="agent-a", effect_ref="continued-work#quota_spend"), + {"runtime_root": str(target["runtime"])}, goal_id="report", execute=True, registry_path=target["registry"]) + assert spend["appended"] is True + history = collect_history(registry_path=target["registry"], runtime_root=target["runtime"], goal_id="report", limit=50)["goals"][0] + assert history["quota"]["spent_slots"] == 2 and history["quota"]["spend_event_count"] == 2 + assert history["planned_transfer_history"]["runtime_accounting_adopted"] is True + with project_registry_transaction(target["registry"], operation="later-display-change") as transaction: + payload = transaction.payload_copy() + payload["goals"][0]["display_name"] = "Later destination name" + transaction.commit(payload) + restart_effect_runtime(wait_for_exit=True) + # Duplicate confirmation is pure readback even after real target writes. + before_retry = target["registry"].read_bytes(), report.read_bytes() + assert _cutover(source, target, preview)["replayed"] is True + assert before_retry == (target["registry"].read_bytes(), report.read_bytes()) + assert (operation_directory(source, OPERATION) / "cutover.json").read_bytes() == decision + hidden_target = tmp_path / "temporarily-unavailable-target" + target["runtime"].rename(hidden_target) + failed_read = source["cli"]("goal-transfer", "inspect", "--goal-id", "report", expected_code=1) + assert failed_read["ok"] is False + assert not target["runtime"].exists(), "inspection must not create lock directories in a missing target" + hidden_target.rename(target["runtime"]) + assert source["cli"]("goal-transfer", "inspect", "--goal-id", "report")["local_cutover"]["target_task_admitted"] is True + # Destination execution and historical accounting no longer depend on the + # source's absolute paths after its original native admission is complete. + source["runtime"].rename(tmp_path / "source-offline") + assert source["cli"]("goal-transfer", "inspect", "--goal-id", "report", expected_code=1)["ok"] is False + assert not source["runtime"].exists(), "inspection must not initialize a missing original source" + assert collect_history(registry_path=target["registry"], runtime_root=target["runtime"], goal_id="report", limit=1)["goals"][0]["quota"]["spent_slots"] == 2 + with pytest.raises(EffectRuntimeRemoteError, match="no Host/session/effect admission"): + FirstPartyHostGoalAdmission.for_plan(registry_path=target["registry"], goal_id="report", planned_goal_ref=None).require_current() + assert effect_runtime_result("coordination.planned_transfer.finish_local_cutover", {"runtime_root": str(target["runtime"]), + "goal_id": "report", "operation_id": OPERATION})["replayed"] is True + + +def test_target_material_change_refuses_before_source_final(source: dict, tmp_path: Path): + target = _staged(source, tmp_path, source["provider"]) + preview = _preview(source, target) + failure = _cutover(source, target, {"preview_sha256": "0" * 64}, expected_code=1) + assert "preview changed" in failure["error"] + (target["project"] / "input.txt").write_text("Changed required input\n") + before = target["registry"].read_bytes(), source["fence"].read_bytes() + failure = _cutover(source, target, preview, expected_code=1) + assert "material changed" in failure["error"] + assert before == (target["registry"].read_bytes(), source["fence"].read_bytes()) + assert not (operation_directory(source, OPERATION) / "cutover.json").exists() + assert cancel_prepared_goal_transfer(runtime_root=source["runtime"], goal_id="report", operation_id=OPERATION)["readback"]["original_writer_fence_restored"] is True + + +def test_final_source_and_lost_target_finish_recover_forward(source: dict, tmp_path: Path, monkeypatch): + target = _staged(source, tmp_path, source["provider"]) + preview = _preview(source, target) + real = planned_transfer_io.effect_runtime_result + def interrupted(method, params, **kwargs): + if method == "coordination.planned_transfer.finish_local_cutover": + raise OSError("lost before target admission") + return real(method, params, **kwargs) + monkeypatch.setattr(planned_transfer_io, "effect_runtime_result", interrupted) + with pytest.raises(OSError, match="lost before"): + local_goal_transfer_cutover(registry_path=source["registry"], goal_id="report", operation_id=OPERATION, + destination_registry=target["registry"], expected_preview_sha256=preview["preview_sha256"]) + assert (operation_directory(source, OPERATION) / "cutover.json").exists() + with pytest.raises(EffectRuntimeRemoteError, match="cutover is final"): + cancel_prepared_goal_transfer(runtime_root=source["runtime"], goal_id="report", operation_id=OPERATION) + with pytest.raises(EffectRuntimeRemoteError, match="prepared for transfer"): + FirstPartyHostGoalAdmission.for_plan(registry_path=target["registry"], goal_id="report", planned_goal_ref=None).require_current() + monkeypatch.setattr(planned_transfer_io, "effect_runtime_result", real) + restart_effect_runtime(wait_for_exit=True) + assert _cutover(source, target, preview)["target_task_admitted"] is True + assert _cutover(source, target, preview)["replayed"] is True + + +def test_cancel_and_final_cutover_are_mutually_exclusive(source: dict, tmp_path: Path): + target = _staged(source, tmp_path, source["provider"]) + preview = _preview(source, target) + def finalize(): + return local_goal_transfer_cutover(registry_path=source["registry"], goal_id="report", operation_id=OPERATION, + destination_registry=target["registry"], expected_preview_sha256=preview["preview_sha256"]) + def cancel(): + return cancel_prepared_goal_transfer(runtime_root=source["runtime"], goal_id="report", operation_id=OPERATION) + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(action) for action in (finalize, cancel)] + outcomes = [] + for future in futures: + try: + outcomes.append(future.result()) + except EffectRuntimeRemoteError as error: + outcomes.append(str(error)) + directory = operation_directory(source, OPERATION) + assert (directory / "cutover.json").exists() != (directory / "cancelled.json").exists(), outcomes + assert sum(isinstance(outcome, dict) for outcome in outcomes) == 1, outcomes + if (directory / "cutover.json").exists(): + assert _cutover(source, target, preview)["target_task_admitted"] is True + else: + with pytest.raises(EffectRuntimeRemoteError, match="prepared for transfer"): + FirstPartyHostGoalAdmission.for_plan(registry_path=target["registry"], goal_id="report", planned_goal_ref=None).require_current() + + +def test_admission_corruption_and_metadata_redirect_do_not_create_a_new_grant(source: dict, tmp_path: Path): + target = _staged(source, tmp_path, source["provider"]) + preview = _preview(source, target) + assert _cutover(source, target, preview)["target_task_admitted"] is True + before_registry = target["registry"].read_bytes() + with pytest.raises(EffectRuntimeRemoteError, match="cannot be removed or redirected"): + with project_registry_transaction(target["registry"], operation="forged-redirect") as transaction: + payload = transaction.payload_copy() + payload["goals"][0]["repo"] = str(tmp_path / "elsewhere") + transaction.commit(payload) + assert target["registry"].read_bytes() == before_registry + fence = legacy_coordination_writer_fence_path(runtime_root=target["runtime"], goal_id="report") + original_fence = json.loads(fence.read_text()) + directory = Path(target["staged"]["original_runtime_history_root"]).parent + admission = directory / "admission-receipt.json" + original = admission.read_bytes() + changed = json.loads(original) + changed["goal"]["display_name"] = "Forged admission" + admission.write_text(json.dumps(changed)) + with pytest.raises(EffectRuntimeRemoteError, match="admission changed"): + FirstPartyHostGoalAdmission.for_plan(registry_path=target["registry"], goal_id="report", planned_goal_ref=None).require_current() + with pytest.raises((ValueError, EffectRuntimeRemoteError), match="admission changed"): + record_quota_slot_spend_from_preview(quota_preview(goal_id="report", agent_id="agent-a", effect_ref="not-authorized#quota_spend"), + {"runtime_root": str(target["runtime"])}, goal_id="report", execute=True, registry_path=target["registry"]) + assert not (target["runtime"] / "goals/report/runs/index.jsonl").exists() + # Restore only this disposable fixture's damaged evidence, then verify the + # supported writer is live and provider replacement refuses before IO. + admission.write_bytes(original) + with pytest.raises(EffectRuntimeRemoteError, match="no Host/session/effect admission"): + FirstPartyHostGoalAdmission.for_plan(registry_path=target["registry"], goal_id="report", planned_goal_ref=None).require_current() + migration = target["cli"]("authority-archive", "plan-migration", "--goal-id", "report", "--provider", + "sqlite" if source["provider"] == "file" else "file", "--plan", str(tmp_path / "migration.json"), expected_code=1) + assert migration["ok"] is False and "does not cover provider/policy" in migration["reason"] + assert not (tmp_path / "migration.json").exists() + assert json.loads(fence.read_text()) == original_fence + assert target["registry"].read_bytes() == before_registry + restart_effect_runtime(wait_for_exit=True) + retained_runtime = tmp_path / "retained-admitted-runtime" + target["runtime"].rename(retained_runtime) + shutil.copytree(retained_runtime, target["runtime"]) + with pytest.raises(EffectRuntimeRemoteError, match="another host or recreated runtime"): + FirstPartyHostGoalAdmission.for_plan(registry_path=target["registry"], goal_id="report", planned_goal_ref=None).require_current() + + +def test_required_capability_stays_with_its_owner_before_final_cutover(source: dict, tmp_path: Path): + source["cli"]("todo", "add", "--goal-id", "report", "--role", "agent", "--claimed-by", "agent-a", + "--text", "Keep the required external evidence dependency", "--required-capability", "external_evidence_poll", + "--operation-id", "actual-required-dependency") + target = _staged(source, tmp_path, source["provider"]) + rejected = source["cli"]("goal-transfer", "cutover-preview", "--goal-id", "report", "--operation-id", OPERATION, + "--destination-registry", str(target["registry"]), expected_code=1) + assert "unsupported capability or Agent binding" in rejected["error"] + assert not (operation_directory(source, OPERATION) / "cutover.json").exists() + assert json.loads(target["registry"].read_text())["goals"][0]["activation"]["state"] == "stopped" From 68532671d1cee7f9dcc4e89424f26f5594211351 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:58:19 +0800 Subject: [PATCH 14/55] Preserve checkpoint sources during Host observation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../coordination/local_authority_write.ts | 49 +++++++++++++------ .../control_plane/effect_runtime_handlers.ts | 1 + .../goals/first_party_host_admission.py | 5 +- .../turn_driver/turn_host_process.py | 20 ++++++++ .../test_planned_transfer_cancellation.py | 13 ++++- 5 files changed, 68 insertions(+), 20 deletions(-) diff --git a/loopx/control_plane/coordination/local_authority_write.ts b/loopx/control_plane/coordination/local_authority_write.ts index 393e621463..3942bd5a70 100644 --- a/loopx/control_plane/coordination/local_authority_write.ts +++ b/loopx/control_plane/coordination/local_authority_write.ts @@ -65,29 +65,46 @@ interface CanonicalWriterOptions { * that could otherwise repair a receipt or debit outside the Todo provider. * Ordinary missing/promotion fences preserve their existing runtime behavior. */ +async function requireGoalRuntimeFence(root: string, goalId: string, + purpose: "runtime" | "quota_accounting"): Promise { + const fence = await loadLegacyCoordinationWriterFence(root, goalId); + if (fence.status === "failed") throw new EffectRuntimeConflictError(fence.reason, "canonical_authority_fence_invalid"); + if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) { + throw new EffectRuntimeConflictError(`Goal runtime is prepared for transfer ${fence.fence.operation_id}; recover that original operation`, + "canonical_authority_transfer_prepared"); + } + if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { + const {requireLocalTaskTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); + await requireLocalTaskTransferAdmission(root, goalId, fence.fence); + // This partial profile admits native Tasks and their accounting only. + // Host/session/effect callers cannot select the quota-owned purpose in + // their RPC payload. Whole-Agent continuation needs its actual owners. + if (purpose !== "quota_accounting") { + throw new EffectRuntimeConflictError("Local Task transfer has no Host/session/effect admission; qualify the original Goal runtime before continuing a Host"); + } + } +} + export async function withGoalRuntimeWriter(root: string, goalId: string, write: () => Promise, borrowedMaintenance?: unknown, purpose: "runtime" | "quota_accounting" = "runtime"): Promise { return withShadowMaintenanceLock(root, goalId, async () => { - const fence = await loadLegacyCoordinationWriterFence(root, goalId); - if (fence.status === "failed") throw new EffectRuntimeConflictError(fence.reason, "canonical_authority_fence_invalid"); - if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA) { - throw new EffectRuntimeConflictError(`Goal runtime is prepared for transfer ${fence.fence.operation_id}; recover that original operation`, - "canonical_authority_transfer_prepared"); - } - if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { - const {requireLocalTaskTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); - await requireLocalTaskTransferAdmission(root, goalId, fence.fence); - // This partial profile admits native Tasks and their accounting only. - // Host/session/effect callers cannot select the quota-owned purpose in - // their RPC payload. Whole-Agent continuation needs its actual owners. - if (purpose !== "quota_accounting") { - throw new EffectRuntimeConflictError("Local Task transfer has no Host/session/effect admission; qualify the original Goal runtime before continuing a Host"); - } - } + await requireGoalRuntimeFence(root, goalId, purpose); return write(); }, borrowedMaintenance); } +/** A point-in-time read, not an execution grant. Observers must not rewrite + * mutex metadata inside the checkpoint they are verifying. Every real write + * and supervised launch still rechecks this same fence under maintenance. */ +export async function checkGoalRuntimeCurrent(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id"])) { + throw new EffectRuntimeConflictError("Runtime observation requires its exact Goal route"); + } + await requireGoalRuntimeFence(requireLocalAuthorityRuntimeRoot(value.runtime_root), + requireAuthorityStoreId(value.goal_id, "runtime Goal"), "runtime"); + return {current: true}; +} + /** The caller owns this exact maintenance lock while publishing Host/session * IO. Transport never supplies a policy boolean or chooses a different fence. */ export async function checkGoalRuntimeWrite(value: JsonObject): Promise { diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 49f72f4091..d1ca2855ed 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -422,6 +422,7 @@ export function createEffectRuntimeHandlers( ["coordination.planned_transfer.cancel_source_preparation", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({cancelSourcePreparation}) => cancelSourcePreparation)], ["coordination.planned_transfer.registry_write_check", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({checkPlannedTransferRegistryWrite}) => checkPlannedTransferRegistryWrite)], ["coordination.planned_transfer.runtime_write_check", lazyHandler(() => import("./coordination/local_authority_write.ts"), ({checkGoalRuntimeWrite}) => checkGoalRuntimeWrite)], + ["coordination.planned_transfer.runtime_current_check", lazyHandler(() => import("./coordination/local_authority_write.ts"), ({checkGoalRuntimeCurrent}) => checkGoalRuntimeCurrent)], ["coordination.planned_transfer.stage_target", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({stageAuthorityForPlannedTransfer}) => stageAuthorityForPlannedTransfer)], ["coordination.planned_transfer.create_recipient", lazyHandler(() => import("./goals/planned_transfer_container.ts"), ({createPlannedTransferRecipient}) => createPlannedTransferRecipient)], ["coordination.planned_transfer.preview_protected", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({previewProtectedGoalTransfer}) => previewProtectedGoalTransfer)], diff --git a/loopx/control_plane/goals/first_party_host_admission.py b/loopx/control_plane/goals/first_party_host_admission.py index 14a4789a84..4646247046 100644 --- a/loopx/control_plane/goals/first_party_host_admission.py +++ b/loopx/control_plane/goals/first_party_host_admission.py @@ -333,10 +333,9 @@ def select_state( def require_current(self) -> None: if not self.source_profile: if self.runtime_root is not None: - from ..turn_driver.turn_host_process import goal_runtime_write + from ..turn_driver.turn_host_process import require_goal_runtime_current - with goal_runtime_write(self.runtime_root, self.goal_id): - pass + require_goal_runtime_current(self.runtime_root, self.goal_id) return with exclusive_cross_runtime_file_lock( guard_path(self.registry_path, self.goal_id), diff --git a/loopx/control_plane/turn_driver/turn_host_process.py b/loopx/control_plane/turn_driver/turn_host_process.py index 4543fdd036..ffdae2bcf6 100644 --- a/loopx/control_plane/turn_driver/turn_host_process.py +++ b/loopx/control_plane/turn_driver/turn_host_process.py @@ -48,6 +48,26 @@ def goal_runtime_write(runtime_root: Path, goal_id: str) -> Iterator[None]: yield +def require_goal_runtime_current(runtime_root: Path, goal_id: str) -> None: + """Observe the native fence without mutating a captured source's mutex. + + This check grants no future IO. Writes and launches retain their locked + admission even if a source is prepared immediately after this observation. + """ + from ..coordination.legacy_writer_fence import legacy_coordination_writer_fence_path + + goal_id = validate_goal_id_path_segment(goal_id) + root = runtime_root.expanduser().resolve() + fence = legacy_coordination_writer_fence_path(runtime_root=root, goal_id=goal_id) + if not fence.exists() and not fence.is_symlink(): + return + result = effect_runtime_result("coordination.planned_transfer.runtime_current_check", { + "runtime_root": str(root), "goal_id": goal_id, + }) + if result != {"current": True}: + raise RuntimeError("Goal runtime observation is unavailable") + + @dataclass(frozen=True, slots=True) class TurnHostProcess: runtime_root: Path diff --git a/tests/control_plane/test_planned_transfer_cancellation.py b/tests/control_plane/test_planned_transfer_cancellation.py index e194577567..7b0b826ae2 100644 --- a/tests/control_plane/test_planned_transfer_cancellation.py +++ b/tests/control_plane/test_planned_transfer_cancellation.py @@ -29,7 +29,7 @@ from loopx.control_plane.projects.registry_codec import project_registry_transaction from loopx.control_plane.projects import registry_codec from loopx.control_plane.quota.spend_commit import record_quota_slot_spend_from_preview, replay_quota_spend_by_effect_ref -from loopx.control_plane.turn_driver.codex_sessions import _session_path, _store_codex_cli_session +from loopx.control_plane.turn_driver.codex_sessions import _session_path, _store_codex_cli_session, select_codex_cli_session from loopx.file_lock import exclusive_cross_runtime_file_lock from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime from tests.control_plane.test_quota_spend_commit_runtime import _preview as quota_preview @@ -103,6 +103,17 @@ def release(lease: dict) -> dict: restart_effect_runtime(wait_for_exit=True) +def test_current_host_observation_preserves_checkpoint_source_bytes(source): + """Checking a current binding must not mutate the source it observes.""" + mutex = shadow_management_directory(source["runtime"], "report") / "maintenance.lock" + original = mutex.read_bytes() + source["admission"].require_current() + selected = select_codex_cli_session(source["runtime"], lineage=source["lineage"], + goal_admission=source["admission"]) + assert selected["session_id"] == "original-context" + assert mutex.read_bytes() == original + + def operation_directory(source: dict, operation: str) -> Path: return shadow_management_directory(source["runtime"], "report") / "planned-transfers" / hashlib.sha256(canonical_bytes(operation)).hexdigest() From 1a2e6ce1027b00c21a185fbc930998f876433c35 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:13:25 +0800 Subject: [PATCH 15/55] Validate original transfer context before touching its runtime Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../planned_transfer_authority.ts | 10 ++++-- loopx/control_plane/goals/planned_transfer.ts | 27 ++++++++++---- .../test_planned_transfer_cancellation.py | 36 +++++++++++++++++++ 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index 591d110747..99f03e32c0 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -25,7 +25,8 @@ import {loadLegacyCoordinationWriterFence, preparePlannedTransferWriterFenceUnde PLANNED_TRANSFER_TASK_FENCE_SCHEMA, type PlannedTransferWriterFence} from "./legacy_writer_fence.ts"; import {readShadowManagementState, shadowManagementDirectory, withShadowMaintenanceLock} from "./shadow_management.ts"; -import {PREPARATION_SCHEMA, readGoalTransferSnapshot, withVerifiedGoalTransferContext} from "../goals/planned_transfer.ts"; +import {PREPARATION_SCHEMA, checkGoalTransferSourceContext, readGoalTransferSnapshot, + withVerifiedGoalTransferContext} from "../goals/planned_transfer.ts"; import {reviewProtectedGoalTransferInputs, writeProtectedGoalTransfer} from "../goals/planned_transfer_payload.ts"; import type {AuthorityStore} from "./authority_store.ts"; import {newGoalCreationBinding} from "./new_goal_initialization.ts"; @@ -193,7 +194,12 @@ export async function visitRegisteredTransferSource(value: JsonObject, reques consume: (source: RegisteredTransferSource) => Promise): Promise { const root = requireLocalAuthorityRuntimeRoot(value.runtime_root); const goalId = requireAuthorityStoreId(value.goal_id, "source Goal id"); - if (!(await stat(root)).isDirectory()) reject("Original source runtime is unavailable; restore it without reinitializing it"); + await checkGoalTransferSourceContext(value.source_context, value.registry_witness, goalId, root); + const runtime = await stat(root).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" || error.code === "ENOTDIR") return null; + throw error; + }); + if (!runtime?.isDirectory()) reject("Original source runtime is unavailable; restore it without reinitializing it"); return withShadowMaintenanceLock(root, goalId, () => withVerifiedGoalTransferContext( value.source_context, value.registry_witness, goalId, root, async (context, snapshot) => { const managed = await readShadowManagementState(root, goalId); diff --git a/loopx/control_plane/goals/planned_transfer.ts b/loopx/control_plane/goals/planned_transfer.ts index 7ae11ad462..759ab03963 100644 --- a/loopx/control_plane/goals/planned_transfer.ts +++ b/loopx/control_plane/goals/planned_transfer.ts @@ -81,13 +81,9 @@ export async function readGoalTransferSnapshot(registry: string, goalId: string) } catch { reject("Registered Goal context codec readback is unavailable; preserve the original operation and retry through its installed source"); } } -/** Capture Goal intent through its actual registered source while the caller - * holds maintenance. Read the registered Goal through the existing Python - * registry codec using the source-selected interpreter, never another registry - * parser. Python owns codec/byte IO; Goal identity and profile decisions remain - * here. The lifecycle-only profile cannot become a generic runtime grant. */ -export async function withVerifiedGoalTransferContext(raw: unknown, rawWitness: unknown, - goalId: string, root: string, operation: (context: JsonObject, snapshot: JsonObject) => Promise): Promise { +/** Python owns codec/byte IO; Goal identity and profile decisions stay in this + * single verifier for both nonmutating preflight and the locked capture. */ +function goalTransferContextVerification(raw: unknown, rawWitness: unknown, goalId: string, root: string) { const context = requireJsonObject(raw, "original Goal context"); if (!hasExactAuthorityKeys(context, ["goal", "state_text", "runtime_root", "registry_path"]) || context.runtime_root !== root || typeof context.state_text !== "string" || !context.state_text.trim()) reject("Complete registered Goal context is required"); @@ -106,6 +102,23 @@ export async function withVerifiedGoalTransferContext(raw: unknown, rawWitnes if (!canonicalAuthorityBytes(observed.source_context).equals(canonicalAuthorityBytes(context))) reject("Registered Goal intent differs from its reviewed transfer context"); return observed; }; + return {context, state, registry, verify}; +} + +/** Reject an invalid registry route before a caller touches its alleged + * runtime. This observation grants no write authority; capture rechecks under + * the original locks, including a final check after the operation. */ +export async function checkGoalTransferSourceContext(raw: unknown, rawWitness: unknown, + goalId: string, root: string): Promise { + await goalTransferContextVerification(raw, rawWitness, goalId, root).verify(); +} + +/** Capture Goal intent through its actual registered source while the caller + * holds maintenance. The lifecycle-only profile cannot become a generic runtime + * grant. Keep maintenance -> Todo -> state -> registry lock ordering intact. */ +export async function withVerifiedGoalTransferContext(raw: unknown, rawWitness: unknown, + goalId: string, root: string, operation: (context: JsonObject, snapshot: JsonObject) => Promise): Promise { + const {context, state, registry, verify} = goalTransferContextVerification(raw, rawWitness, goalId, root); return withFileMutationLock(legacyCoordinationTodoLockPath(root, goalId), () => withFileMutationLock(state, () => withFileMutationLock(registry, async () => { const snapshot = await verify(); diff --git a/tests/control_plane/test_planned_transfer_cancellation.py b/tests/control_plane/test_planned_transfer_cancellation.py index 7b0b826ae2..c3302d7ece 100644 --- a/tests/control_plane/test_planned_transfer_cancellation.py +++ b/tests/control_plane/test_planned_transfer_cancellation.py @@ -114,6 +114,42 @@ def test_current_host_observation_preserves_checkpoint_source_bytes(source): assert mutex.read_bytes() == original +@pytest.mark.parametrize("invalid_source", ["global_projection", "missing_runtime"]) +def test_source_preview_refuses_before_initializing_an_invalid_runtime(source, tmp_path, invalid_source): + """A broken source needs an owner action, not an empty replacement store.""" + from loopx.control_plane.projects.registry_codec import load_project_registry + + registry, runtime = source["registry"], source["runtime"] + original_fence = source["fence"].read_bytes() + hidden = tmp_path / "retained-source-runtime" + if invalid_source == "global_projection": + projection = load_project_registry(registry) + runtime = tmp_path / "absent-projection-runtime" + projection.update(registry_role="global-local", common_runtime_root=str(runtime)) + for goal in projection["goals"]: + goal.pop("source_registry", None) + registry = tmp_path / "registry.global.json" + registry.write_text(json.dumps(projection)) + message = "global registry is a read model" + else: + restart_effect_runtime(wait_for_exit=True) + runtime.rename(hidden) + message = "Original source runtime is unavailable" + original_registry = registry.read_bytes() + try: + with pytest.raises(EffectRuntimeRemoteError, match=message): + preview_registered_goal_transfer(registry_path=registry, goal_id="report", operation_id="move-invalid") + assert not runtime.exists() + assert registry.read_bytes() == original_registry + finally: + restart_effect_runtime(wait_for_exit=True) + if invalid_source == "missing_runtime": + if runtime.exists(): + runtime.rename(tmp_path / "unexpected-runtime-initialization") + hidden.rename(runtime) + assert source["fence"].read_bytes() == original_fence + + def operation_directory(source: dict, operation: str) -> Path: return shadow_management_directory(source["runtime"], "report") / "planned-transfers" / hashlib.sha256(canonical_bytes(operation)).hexdigest() From 8aeaf0d15bd5beefd412cd7bfb764676c804fda2 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:46:02 +0800 Subject: [PATCH 16/55] Qualify native checkpoint path and recovery boundaries Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/research-checkpoint.md | 19 ++++++++ .../test_research_checkpoint_cli.py | 43 ++++++++++++++++--- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/docs/reference/research-checkpoint.md b/docs/reference/research-checkpoint.md index a3e655bb51..0bfc4ea716 100644 --- a/docs/reference/research-checkpoint.md +++ b/docs/reference/research-checkpoint.md @@ -108,6 +108,21 @@ loopx --registry DEST_REGISTRY --runtime-root DEST_RUNTIME --format json \ --lease-key CURRENT_LEASE_KEY --lease-version CURRENT_LEASE_VERSION --execute ``` +The native fork above qualifies the preserved guest paths, not relocation to an +arbitrary physical `CODEX_HOME` or workspace. In the pinned paginated native +store, retained session indexes still name absolute rollout paths. Supplying a +different rollout path to the native fork API does not qualify that relocation; +its stale-path check can reject the copied index. Forking by thread id can also +require the original rollout path in that index. Do not rewrite the provider's +SQLite/history or alias an active source home to bypass that boundary. + +For ordinary migration to different paths, retain this native point as history +and use a separately qualified, reviewed continuation context through the +current Goal/session/Todo owners. That composition must preserve the original +work and obtain fresh destination admission; it is not implemented merely by +creating a fresh conversation. The fixed-path native profile remains available +without making it the only allowed design for the planned Agent-transfer journey. + The existing continuation owner prepares/adopts a complete context reference. Explore knowledge seeds the fresh Goal with preserved counterevidence, topology, authorship, timestamps and checkpoint provenance. Ordered-prefix recovery rejects @@ -140,3 +155,7 @@ counterexample, accept a current typed review, execute a complete tool experimen and settle it. The joined qualification recipe in `benchmark/README.md` also runs this Core case. It validates mechanics and isolation; real-model decisions, long-run reliability and net score/cost improvements require separate evidence. +The relocated-path case instead verifies an actionable refusal before creating +the destination native home or session binding, with the original point and +unfinished destination Task preserved. It does not certify portable native +session reconstruction or complete Agent migration. diff --git a/tests/control_plane/test_research_checkpoint_cli.py b/tests/control_plane/test_research_checkpoint_cli.py index f76942d9b4..d0c2c0aabf 100644 --- a/tests/control_plane/test_research_checkpoint_cli.py +++ b/tests/control_plane/test_research_checkpoint_cli.py @@ -1,6 +1,7 @@ """Ordinary Goal capture entry: real SQLite/artifact owners, synthetic Host IO.""" import json import os +import shutil import sys import subprocess import time @@ -18,8 +19,10 @@ ) -@pytest.mark.parametrize('real_host', [False, True]) -def test_explicit_ordinary_goal_capture_preserves_context_without_activation(tmp_path, monkeypatch, capsys, request, real_host): +@pytest.mark.parametrize('real_host, relocated_paths', [(False, False), (True, False), (True, True)]) +def test_explicit_ordinary_goal_capture_preserves_context_without_activation( + tmp_path, monkeypatch, capsys, request, real_host, relocated_paths, +): if real_host and not os.environ.get('LOOPX_NATIVE_CHECKPOINT_BIN'): pytest.skip('Requires an explicit real Codex binary; inference is a local deterministic provider.') isolate_sqlite_runtime(tmp_path, monkeypatch) @@ -144,12 +147,24 @@ def native(env, session_id, **kwargs): # Isolated fixture only: retain the closed source home, reconstruct a # fresh home at the qualified provider's same fixed guest path. home.rename(home.with_name('preserved-source-home')) + if relocated_paths: + target_workspace = tmp_path / 'target-workspace' + target_workspace.mkdir() + shutil.copy2(code, target_workspace / code.name) + retained_workspace = workspace.with_name('preserved-source-workspace') + workspace.rename(retained_workspace) + code = retained_workspace / code.name + workspace = target_workspace + monkeypatch.chdir(workspace) + monkeypatch.setenv('CODEX_HOME', str(tmp_path / 'target-codex-home')) + monkeypatch.setenv('LOOPX_PROJECT', str(workspace)) branch_registry, branch_root = tmp_path / 'branch-registry.json', tmp_path / 'branch-runtime' branch_prefix = ['--format', 'json', '--registry', str(branch_registry), '--runtime-root', str(branch_root)] - def branch(*args): + def branch(*args, ok=True): code = main([*branch_prefix, *args]) result = json.loads(capsys.readouterr().out) - assert code == 0, result + assert code == (0 if ok else 1), result + assert result['ok'] is ok return result branch('bootstrap', '--project', str(workspace), '--goal-id', 'branch', '--objective', 'Continue retained experiments.', '--adapter-kind', 'read_only_project_map_v0', '--adapter-status', 'connected-read-only', '--write-scope', '**', '--no-global-sync') @@ -159,8 +174,25 @@ def branch(*args): '--task-class', 'advancement_task', '--text', 'Complete a retained two-candidate experiment.')['todo_id'] common = ['--goal-id', 'branch', '--agent-id', 'branch-actor', '--project', str(workspace), '--sandbox', sandbox, '--root', str(destination), '--expected-sha256', sealed['sha256']] - admitted = branch('research-checkpoint', 'admit-session', *common, '--config-path', str(config), '--execute') + admission = ['research-checkpoint', 'admit-session', *common, '--config-path', str(config), '--execute'] + if relocated_paths: + held = branch(*admission, ok=False) + assert 'fixed guest paths' in held['error'] and held['execution_authority_granted'] is False + assert not Path(os.environ['CODEX_HOME']).exists() and len(model_calls) == 1 + from loopx.control_plane.turn_driver.codex_sessions import select_codex_cli_session + assert select_codex_cli_session(branch_root, lineage={'goal_id': 'branch', + 'agent_id': 'branch-actor'}, session_scope='agent') is None + retained_task = branch('todo', 'list', '--goal-id', 'branch', '--todo-id', current_todo)['todo'] + assert retained_task['done'] is False and retained_task['claimed_by'] == 'branch-actor' + assert (destination / 'checkpoint.json').read_bytes() == original + assert code.read_text() == '7' and log.read_bytes() == source_bytes + assert not Path(metadata['native_session']['guest_workspace']).exists() + assert not (retained_workspace / 'complete-experiment.json').exists() + return # Expected profile refusal; no destination continuation was admitted. + admitted = branch(*admission) assert admitted['session_id'] != source_session and admitted['session_binding_admitted'] + recovered = branch(*admission) + assert recovered['session_id'] == admitted['session_id'] and len(model_calls) == 1 lease = branch('task-lease', 'acquire', '--goal-id', 'branch', '--todo-id', current_todo, '--owner', 'branch-actor', '--idempotency-key', 'branch-lease') revision = branch('todo', 'list', '--goal-id', 'branch', '--todo-id', current_todo)['authority_read']['provider_revision'] @@ -204,6 +236,7 @@ def work(guard): _ordinary_review_and_work(branch, workspace, tmp_path, current_todo, sealed['sha256'], monkeypatch, work=work) assert len(model_calls) == 3 assert code.read_text() == '7' and log.read_bytes() == source_bytes + assert (workspace / 'complete-experiment.json').is_file() def test_mutation_preview_requires_no_native_host_and_dispatches_no_operation(tmp_path, capsys): From d36e875bca0651add00356e5565d235ed8394e19 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:39:39 +0800 Subject: [PATCH 17/55] feat(migration): continue original tasks in an admitted local runtime Bind reviewed original Goal context to a new destination runtime and consume it through the ordinary Codex Turn driver. Preserve Task-only admission and expose actual destination results through existing settings and CLI readback. Reuse the canonical Turn journal before rebuilding a retry plan, and reject concurrent journals for the same structured settlement identity. Keep the complete installed migration journey explicitly unqualified. Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 2 +- .../goal-transfer-settings.tsx | 4 +- docs/reference/research-checkpoint.md | 18 ++ .../cli_commands/support_control_transfer.py | 18 +- loopx/cli_commands/turn.py | 18 +- .../coordination/legacy_writer_fence.ts | 16 +- .../coordination/local_authority_write.ts | 31 +-- .../planned_transfer_authority.ts | 16 +- .../coordination/planned_transfer_target.ts | 8 +- .../control_plane/effect_runtime_handlers.ts | 1 + .../goals/planned_transfer_continuation.ts | 127 +++++++++++ .../goals/planned_transfer_cutover.ts | 78 ++++--- .../goals/planned_transfer_io.py | 18 +- .../quota/accounting_artifact_transaction.ts | 4 +- loopx/control_plane/turn_driver/codex_cli.py | 17 ++ .../turn_driver/journal_store.py | 2 + .../turn_driver/turn_journal_effects.ts | 19 +- .../turn_driver/turn_journal_query.ts | 8 +- .../test_planned_transfer_continuation.py | 201 ++++++++++++++++++ .../turn_journal_effects.test.ts | 24 +++ 20 files changed, 558 insertions(+), 72 deletions(-) create mode 100644 loopx/control_plane/goals/planned_transfer_continuation.ts create mode 100644 tests/control_plane/test_planned_transfer_continuation.py diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 266c98af35..17c34ca9d8 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -45,7 +45,7 @@ const goalTransferHoldSchema: z.ZodType = z.enum([ const goalTransferViewSchema = z.object({ ok: z.literal(true), goal_id: z.string(), source_state: z.enum(["available", "prepared", "cutover_recorded"]), operation_id: z.string().nullable(), cancel_available: z.boolean(), continuation_available: z.literal(false), - local_cutover: z.object({source_final: z.literal(true), target_task_admitted: z.boolean()}).optional(), + local_cutover: z.object({source_final: z.literal(true), target_task_admitted: z.boolean(), target_runtime_admitted: z.boolean().optional()}).optional(), work: z.object({objective: z.string(), agents: z.array(z.string()), todos: z.array(z.object({todo_id: z.string(), text: z.string(), status: z.string(), note: z.string().nullish(), evidence: z.string().nullish()}))}), diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx index 60b5d9e174..0fe2c9463b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx @@ -47,7 +47,9 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { ? (zh ? "准备已取消,源环境的原写入边界已恢复。" : "Preparation cancelled. The original source write boundary is restored.") : (zh ? "取消决定已记录;源环境已有后续状态,请读取当前状态。" : "Cancellation is recorded. The source has later state; read its current status.")}

: null} {view ? <> -

{view.local_cutover ? (view.local_cutover.target_task_admitted +

{view.local_cutover ? (view.local_cutover.target_runtime_admitted + ? (zh ? "目标环境已取得新的本地执行准入,可用审阅过的上下文继续原任务。下方为当前目标结果;完整迁移流程仍待验证。" : "The destination has new local execution admission and can continue the original work with reviewed context. Current destination results appear below; the complete migration flow still needs qualification.") + : view.local_cutover.target_task_admitted ? (zh ? "源环境已停止写入,目标可重新领取原任务的本地租约。下方为目标任务状态;Host 续办仍待验证。" : "Source writes are closed. The destination can acquire a fresh local lease for the original task. Status below comes from the destination; Host continuation still needs qualification.") : (zh ? "切换决定已记录,源环境保持停写;目标尚待同一次操作恢复任务准入。" : "Cutover is recorded and source writes remain closed. Recover the same operation to finish destination Task admission.")) : view.source_state === "prepared" diff --git a/docs/reference/research-checkpoint.md b/docs/reference/research-checkpoint.md index cfe60642f1..3e2996eeec 100644 --- a/docs/reference/research-checkpoint.md +++ b/docs/reference/research-checkpoint.md @@ -130,6 +130,24 @@ work and obtain fresh destination admission; it is not implemented merely by creating a fresh conversation. The fixed-path native profile remains available without making it the only allowed design for the planned Agent-transfer journey. +The development `goal-transfer cutover` composition can separately review +`--continuation reviewed_context` for an already protected, staged local Goal. +It admits a new physical destination runtime while retaining the original +Goal/Task ids, archived relations, requirements, material mappings and original +accounting. The ordinary Codex Turn driver receives that reviewed context and +still needs current Task/lease authority and independent result validation. +It neither copies a native session nor installs the source's credentials or +creation receipt. The default Task-only cutover remains unable to launch a Host. +This is a source-online, same-host development profile; it does not establish +cross-machine authorization, portable-artifact privacy certification or the +complete installed Settings journey. Those product qualifications remain held. + +For a governed CLI Turn with an explicit Todo and `--turn-instance-id`, retrying +that same identity now reads the original journal before rebuilding a live plan. +A changed plan hash cannot allocate a second canonical journal for the same +structured settlement identity. Unknown, inconsistent or conflicting history +refuses; use the original recovery path instead of starting another conversation. + The existing continuation owner prepares/adopts a complete context reference. Explore knowledge seeds the fresh Goal with preserved counterevidence, topology, authorship, timestamps and checkpoint provenance. Ordered-prefix recovery rejects diff --git a/loopx/cli_commands/support_control_transfer.py b/loopx/cli_commands/support_control_transfer.py index 8c6ba72848..6a49af6e95 100644 --- a/loopx/cli_commands/support_control_transfer.py +++ b/loopx/cli_commands/support_control_transfer.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +import json from collections.abc import Callable from pathlib import Path from typing import Any @@ -10,19 +11,25 @@ cancel_registered_goal_transfer, inspect_registered_goal_transfer, local_goal_transfer_cutover, + read_goal_transfer_host_context, ) def register_goal_transfer(subparsers: argparse._SubParsersAction[argparse.ArgumentParser], add_format: Callable[..., Any]) -> None: parser = subparsers.add_parser("goal-transfer", help="Inspect original Agent work, recover preparation, or review a local cutover (development profile).") actions = parser.add_subparsers(dest="goal_transfer_action", required=True) - for action in ("inspect", "cancel", "cutover-preview", "cutover"): + for action in ("inspect", "cancel", "cutover-preview", "cutover", "context"): command = actions.add_parser(action) add_format(command) command.add_argument("--goal-id", required=True) - if action != "inspect": + if action not in ("inspect", "context"): command.add_argument("--operation-id", required=True) + if action == "context": + command.add_argument("--agent-id", required=True) + command.add_argument("--todo-id", required=True) if action in ("cutover-preview", "cutover"): + command.add_argument("--continuation", choices=("task_only", "reviewed_context"), default=None, + help="Review original decision context and new local runtime admission; never copy native sessions or credentials.") command.add_argument("--destination-registry", required=True, type=Path, help="Existing staged destination registry on this POSIX host; not a portable authorization token.") if action in ("cancel", "cutover"): @@ -42,6 +49,8 @@ def render_goal_transfer(payload: dict[str, Any]) -> str: lines.extend(f"- Hold: {row['code']} ({row['owner']})" for row in payload.get("holds", [])) if "readback" in payload: lines.append(f"- Original source restored: {payload['readback']['original_writer_fence_restored']}") + if "context" in payload: + lines.extend(["", "```json", json.dumps(payload["context"], ensure_ascii=False, indent=2), "```"]) return "\n".join(lines) + "\n" @@ -50,13 +59,16 @@ def handle_goal_transfer(args: argparse.Namespace, *, registry_path: Path, try: if args.goal_transfer_action == "inspect": payload = inspect_registered_goal_transfer(registry_path=registry_path, goal_id=args.goal_id) + elif args.goal_transfer_action == "context": + payload = {"ok": True, **read_goal_transfer_host_context(registry_path=registry_path, + goal_id=args.goal_id, agent_id=args.agent_id, todo_id=args.todo_id)} elif args.goal_transfer_action == "cancel": payload = {"ok": True, **cancel_registered_goal_transfer(registry_path=registry_path, goal_id=args.goal_id, operation_id=args.operation_id)} else: payload = local_goal_transfer_cutover(registry_path=registry_path, goal_id=args.goal_id, operation_id=args.operation_id, destination_registry=args.destination_registry, - expected_preview_sha256=getattr(args, "expected_preview_sha256", None)) + expected_preview_sha256=getattr(args, "expected_preview_sha256", None), continuation=args.continuation) except Exception as error: payload = {"ok": False, "error": str(error)} print_payload(payload, output_format(args), render_goal_transfer) diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index a58721a49e..6109c0edb6 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -119,11 +119,23 @@ def handle_turn_command( "--operation-source-route-json requires --codex-operation-tools for codex-cli or --dsh-operation-module for dsh" ) decision: dict[str, Any] | None = None + retry_key = args.resume_turn_key if args.turn_command == "run-once" else None + implicit_retry = False + if (args.turn_command == "run-once" and not retry_key and args.turn_instance_id + and getattr(args, "todo_id", None)): + from ..control_plane.turn_driver.journal_store import find_loopx_turn_key_by_settlement_identity + + # Reuse the journal owner before live hooks or changed provider + # state can produce a different plan hash for this same request. + retry_key = find_loopx_turn_key_by_settlement_identity(runtime_root, + goal_id=args.goal_id, agent_id=args.agent_id, todo_id=args.todo_id, + turn_instance_id=args.turn_instance_id, goal_ref=goal_ref) + implicit_retry = retry_key is not None # A resumed Turn already chose its transaction. New work and context # belong to the next Turn, not to recovery of this journal. - if args.turn_command == "run-once" and args.resume_turn_key: + if args.turn_command == "run-once" and retry_key: resume_requested, _session_binding = resolve_turn_resume_session_binding(args) - if args.turn_instance_id: + if args.turn_instance_id and not implicit_retry: raise ValueError( "--resume-turn-key cannot be combined with --turn-instance-id" ) @@ -134,7 +146,7 @@ def handle_turn_command( payload = load_loopx_turn_plan_from_journal( runtime_root, goal_id=args.goal_id, - turn_key=args.resume_turn_key, + turn_key=retry_key, ) raw_envelope = payload.get("turn_envelope") envelope = raw_envelope if isinstance(raw_envelope, dict) else {} diff --git a/loopx/control_plane/coordination/legacy_writer_fence.ts b/loopx/control_plane/coordination/legacy_writer_fence.ts index 2b2d07c6d7..e202113be2 100644 --- a/loopx/control_plane/coordination/legacy_writer_fence.ts +++ b/loopx/control_plane/coordination/legacy_writer_fence.ts @@ -44,6 +44,13 @@ export const PLANNED_TRANSFER_WRITER_FENCE_SCHEMA = "loopx_planned_transfer_writ /** Native Task/accounting admission after a final source decision. Host, * session and effect execution remain held. This is not new-Goal creation. */ export const PLANNED_TRANSFER_TASK_FENCE_SCHEMA = "loopx_planned_transfer_task_fence_v0"; +/** A distinct, Goal-owned runtime admission. Historical Task-only fences stay + * Task-only; older installations reject this new contract instead of widening it. */ +export const PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA = "loopx_planned_transfer_runtime_fence_v0"; +export function isPlannedTransferAdmissionFence(fence: JsonObject): boolean { + return fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA || + fence.schema_version === PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA; +} export interface PlannedTransferWriterFence extends JsonObject { schema_version: typeof PLANNED_TRANSFER_WRITER_FENCE_SCHEMA; state: "engaged"; @@ -126,10 +133,11 @@ export function legacyCoordinationWriterFencePath(root: string, goalId: string): export function decodeLegacyCoordinationWriterFence(value: unknown): JsonObject { const fence = canonicalAuthorityObject(value, "legacy coordination writer fence"); - if (fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { + if (isPlannedTransferAdmissionFence(fence)) { + const digestKey = fence.schema_version === PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA ? "runtime_admission_sha256" : "task_admission_sha256"; if (fence.state !== "engaged" || !hasExactAuthorityKeys(fence, - ["schema_version", "state", "goal_id", "fence_id", "operation_id", "source_identity_sha256", "task_admission_sha256", "store_identity"]) || - !BARE_SHA256_PATTERN.test(String(fence.source_identity_sha256)) || !BARE_SHA256_PATTERN.test(String(fence.task_admission_sha256))) { + ["schema_version", "state", "goal_id", "fence_id", "operation_id", "source_identity_sha256", digestKey, "store_identity"]) || + !BARE_SHA256_PATTERN.test(String(fence.source_identity_sha256)) || !BARE_SHA256_PATTERN.test(String(fence[digestKey]))) { throw new Error("Invalid planned transfer admission fence"); } for (const key of ["goal_id", "fence_id", "operation_id", "store_identity"]) requireAuthorityStoreId(fence[key], key); @@ -272,7 +280,7 @@ export async function admitPlannedTransferTaskWriterUnderLock(root: string, goal prepared: JsonObject, admitted: JsonObject): Promise { decodeLegacyCoordinationWriterFence(prepared); decodeLegacyCoordinationWriterFence(admitted); if (prepared.schema_version !== PLANNED_TRANSFER_WRITER_FENCE_SCHEMA || prepared.role !== "target" || - admitted.schema_version !== PLANNED_TRANSFER_TASK_FENCE_SCHEMA || prepared.goal_id !== goalId || admitted.goal_id !== goalId || + !isPlannedTransferAdmissionFence(admitted) || prepared.goal_id !== goalId || admitted.goal_id !== goalId || prepared.operation_id !== admitted.operation_id || prepared.source_identity_sha256 !== admitted.source_identity_sha256) { throw new Error("Invalid planned target admission transition"); } diff --git a/loopx/control_plane/coordination/local_authority_write.ts b/loopx/control_plane/coordination/local_authority_write.ts index 3942bd5a70..f81975b542 100644 --- a/loopx/control_plane/coordination/local_authority_write.ts +++ b/loopx/control_plane/coordination/local_authority_write.ts @@ -1,7 +1,7 @@ import {withFileMutationLock} from "../effect_runtime_io.ts"; import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; -import {loadLegacyCoordinationWriterFence, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, PLANNED_TRANSFER_TASK_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; +import {loadLegacyCoordinationWriterFence, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, isPlannedTransferAdmissionFence, PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; import {requireShadowPrimaryWriteAllowed, shadowMaintenanceLockPath, withShadowMaintenanceLock} from "./shadow_management.ts"; import {requireLocalAuthorityRuntimeRoot} from "./local_authority_provider.ts"; import {hasExactAuthorityKeys, requireAuthorityStoreId} from "./authority_store_codec.ts"; @@ -73,13 +73,16 @@ async function requireGoalRuntimeFence(root: string, goalId: string, throw new EffectRuntimeConflictError(`Goal runtime is prepared for transfer ${fence.fence.operation_id}; recover that original operation`, "canonical_authority_transfer_prepared"); } - if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { - const {requireLocalTaskTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); - await requireLocalTaskTransferAdmission(root, goalId, fence.fence); - // This partial profile admits native Tasks and their accounting only. - // Host/session/effect callers cannot select the quota-owned purpose in - // their RPC payload. Whole-Agent continuation needs its actual owners. - if (purpose !== "quota_accounting") { + if (fence.status === "loaded" && isPlannedTransferAdmissionFence(fence.fence)) { + const {requireLocalGoalTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); + const admission = await requireLocalGoalTransferAdmission(root, goalId, fence.fence); + if (purpose === "runtime" && fence.fence.schema_version === PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA) { + const {readReviewedTransferContext} = await import("../goals/planned_transfer_continuation.ts"); + await readReviewedTransferContext(admission); + } + // Historical Task-only admission remains narrow. Runtime continuation is + // a separately reviewed Goal admission, never a caller-selected purpose. + if (purpose !== "quota_accounting" && fence.fence.schema_version !== PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA) { throw new EffectRuntimeConflictError("Local Task transfer has no Host/session/effect admission; qualify the original Goal runtime before continuing a Host"); } } @@ -142,9 +145,9 @@ export async function requireCanonicalBusinessWrite( throw new CanonicalWriteRouteError("canonical_authority_transfer_prepared", goalId, operation, `Authority is prepared for transfer ${fence.fence.operation_id}; recover that operation before retrying`); } - if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { - const {requireLocalTaskTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); - await requireLocalTaskTransferAdmission(root, goalId, fence.fence); + if (fence.status === "loaded" && isPlannedTransferAdmissionFence(fence.fence)) { + const {requireLocalGoalTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); + await requireLocalGoalTransferAdmission(root, goalId, fence.fence); if (operation === "authority_maintenance" || operation === "handoff_mode_set" || operation === "goal_create") { throw new EffectRuntimeConflictError("Local Task transfer admission does not cover provider/policy replacement; use its owning lineage recovery"); } @@ -171,9 +174,9 @@ export async function withCanonicalWriter( throw new CanonicalWriteRouteError("canonical_authority_transfer_prepared", goalId, "authority_maintenance", `Authority is prepared for transfer ${fence.fence.operation_id}; recover that operation before retrying`); } - if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { - const {requireLocalTaskTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); - await requireLocalTaskTransferAdmission(root, goalId, fence.fence); + if (fence.status === "loaded" && isPlannedTransferAdmissionFence(fence.fence)) { + const {requireLocalGoalTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); + await requireLocalGoalTransferAdmission(root, goalId, fence.fence); throw new EffectRuntimeConflictError("Local Task transfer admission does not cover provider/policy replacement; use its owning lineage recovery"); } } diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index 99f03e32c0..4512f0871c 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -22,7 +22,7 @@ import {canonicalTaskLease} from "./task_lease_state.ts"; import {openLocalAuthorityStoreHandle, requireLocalAuthorityRuntimeRoot} from "./local_authority_provider.ts"; import {loadLegacyCoordinationWriterFence, preparePlannedTransferWriterFenceUnderLock, restorePlannedTransferWriterFenceUnderLock, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, - PLANNED_TRANSFER_TASK_FENCE_SCHEMA, + isPlannedTransferAdmissionFence, type PlannedTransferWriterFence} from "./legacy_writer_fence.ts"; import {readShadowManagementState, shadowManagementDirectory, withShadowMaintenanceLock} from "./shadow_management.ts"; import {PREPARATION_SCHEMA, checkGoalTransferSourceContext, readGoalTransferSnapshot, @@ -72,12 +72,12 @@ export async function checkPlannedTransferRegistryWrite(value: JsonObject): Prom const root = requireLocalAuthorityRuntimeRoot(rawRoot); const fence = await loadLegacyCoordinationWriterFence(root, goalId); if (fence.status === "failed") reject("Goal writer fence is unreadable; preserve its original registration"); - if (fence.status !== "loaded" || ![PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, PLANNED_TRANSFER_TASK_FENCE_SCHEMA].includes(String(fence.fence.schema_version))) continue; + if (fence.status !== "loaded" || !(fence.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA || isPlannedTransferAdmissionFence(fence.fence))) continue; const matches = proposed.filter(row => row !== null && typeof row === "object" && !Array.isArray(row) && (row as JsonObject).id === goalId); const prior = original.filter(row => row !== null && typeof row === "object" && !Array.isArray(row) && (row as JsonObject).id === goalId); - if (fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { - const {requireLocalTaskTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); - await requireLocalTaskTransferAdmission(root, goalId, fence.fence); + if (isPlannedTransferAdmissionFence(fence.fence)) { + const {requireLocalGoalTransferAdmission} = await import("../goals/planned_transfer_cutover.ts"); + await requireLocalGoalTransferAdmission(root, goalId, fence.fence); const beforeGoal = prior[0] as JsonObject | undefined, afterGoal = matches[0] as JsonObject | undefined; if (prior.length !== 1 || matches.length !== 1 || root !== before.common_runtime_root || root !== after.common_runtime_root || ["repo", "state_file", "planned_transfer", "goal_instance_id", "creation_operation_id"].some(key => !same(beforeGoal?.[key] ?? null, afterGoal?.[key] ?? null)) || @@ -239,6 +239,9 @@ export async function inspectRegisteredGoalTransfer(value: JsonObject): Promise< if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "source_context", "registry_witness"])) { reject("Transfer inspection requires its registered source observation"); } + const {inspectAdmittedGoalTransferDestination} = await import("../goals/planned_transfer_continuation.ts"); + const destination = await inspectAdmittedGoalTransferDestination(value); + if (destination !== null) return destination; return visitRegisteredTransferSource(value, null, async source => { const {context, preview, projection, preparedOperation} = source; const goal = requireJsonObject(context.goal, "original Goal"); @@ -257,7 +260,8 @@ export async function inspectRegisteredGoalTransfer(value: JsonObject): Promise< return {ok: true, schema_version: "loopx_planned_transfer_operation_view_v0", goal_id: goal.id, source_state: "cutover_recorded", operation_id: preparedOperation, source_provider: preview.provider, source_revision: preview.provider_revision, authority_preparation_ready: false, cancel_available: false, continuation_available: false, - local_cutover: {source_final: true, target_task_admitted: current.target_task_admitted}, + local_cutover: {source_final: true, target_task_admitted: current.target_task_admitted, + target_runtime_admitted: current.target_runtime_admitted}, work: current.work ?? {objective: source.objective, requirements: context.state_text, agents: coordination.registered_agents ?? [], todos: todos.todo_ids.map(id => todos.todos.get(id)!)}, holds: [{code: "transfer_journey_incomplete" satisfies GoalTransferHoldCode, owner: "goal_transfer"}]}; diff --git a/loopx/control_plane/coordination/planned_transfer_target.ts b/loopx/control_plane/coordination/planned_transfer_target.ts index 08980a39ce..75bacae709 100644 --- a/loopx/control_plane/coordination/planned_transfer_target.ts +++ b/loopx/control_plane/coordination/planned_transfer_target.ts @@ -20,7 +20,7 @@ import {FileAuthorityStore} from "./file_authority_store.ts"; import {SqliteAuthorityStore} from "./sqlite_authority_store.ts"; import {localAuthorityProviderPaths, publishLocalAuthoritySelection} from "./local_authority_provider.ts"; import {engageLegacyCoordinationWriterFenceUnderLocks, loadLegacyCoordinationWriterFence, - PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, PLANNED_TRANSFER_TASK_FENCE_SCHEMA, type PlannedTransferWriterFence} from "./legacy_writer_fence.ts"; + PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, isPlannedTransferAdmissionFence, type PlannedTransferWriterFence} from "./legacy_writer_fence.ts"; import {readShadowManagementState, shadowManagementDirectory, withShadowMaintenanceLock} from "./shadow_management.ts"; import {decodeGoalTransferHistory, inspectGoalTransferHistory, readGoalTransferHistoryJson, retainGoalTransferHistory, type ProtectedGoalTransferHistory} from "../goals/planned_transfer_history.ts"; import {PREPARATION_SCHEMA} from "./planned_transfer_authority.ts"; @@ -332,14 +332,14 @@ export async function readStagedGoalTransferRuns(value: JsonObject): Promise import("./coordination/planned_transfer_target.ts"), ({readStagedGoalTransferRuns}) => readStagedGoalTransferRuns)], ["coordination.planned_transfer.plan_local_cutover", lazyHandler(() => import("./goals/planned_transfer_cutover.ts"), ({planLocalGoalTransferCutover}) => planLocalGoalTransferCutover)], ["coordination.planned_transfer.finish_local_cutover", lazyHandler(() => import("./goals/planned_transfer_cutover.ts"), ({finishLocalGoalTransferCutover}) => finishLocalGoalTransferCutover)], + ["coordination.planned_transfer.host_context", lazyHandler(() => import("./goals/planned_transfer_continuation.ts"), ({readGoalTransferHostContext}) => readGoalTransferHostContext)], ["configuration.backup", lazyHandler(() => import("./configuration_backup.ts"), ({configurationBackupOperation}) => configurationBackupOperation)], ["runtime.checkpoint_artifacts", lazyHandler(() => import("./runtime/checkpoint_artifacts.ts"), ({checkpointArtifacts}) => checkpointArtifacts)], ["state.recovery.archive.verify", lazyHandler(() => import("./state_recovery.ts"), ({verifyStateRecoveryArchive}) => verifyStateRecoveryArchive)], diff --git a/loopx/control_plane/goals/planned_transfer_continuation.ts b/loopx/control_plane/goals/planned_transfer_continuation.ts new file mode 100644 index 0000000000..26c4f21076 --- /dev/null +++ b/loopx/control_plane/goals/planned_transfer_continuation.ts @@ -0,0 +1,127 @@ +/** Reviewed continuation of original work in a newly admitted local runtime. + * + * The cutover receipt owns admission. This module reads its immutable context + * and current Goal/Task owners; it creates no session, lease or second ledger. + * Historical source bindings and receipts are context, never executable proof. + */ +import {dirname, join, resolve} from "node:path"; +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys, + requireAuthorityStoreId} from "../coordination/authority_store_codec.ts"; +import {loadLegacyCoordinationWriterFence, isPlannedTransferAdmissionFence, PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA} from "../coordination/legacy_writer_fence.ts"; +import {openLocalAuthorityStoreHandle, requireLocalAuthorityRuntimeRoot} from "../coordination/local_authority_provider.ts"; +import {indexCoordinationProjection} from "../coordination/coordination_projection.ts"; +import {readGoalTransferSnapshot, checkGoalTransferSourceContext} from "./planned_transfer.ts"; +import {readGoalTransferHistoryJson} from "./planned_transfer_history.ts"; + +function reject(message: string): never {throw new EffectRuntimeConflictError(message);} +function same(a: unknown, b: unknown): boolean {return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b));} + +/** Local extension of the existing cutover profile, explicitly reviewed in its + * preview. Current registration and execution permissions remain independent. */ +export function planReviewedTransferContinuation(mode: unknown, original: JsonObject, + context: JsonObject, preview: JsonObject, materials: JsonObject): JsonObject | null { + if (mode === undefined || mode === "task_only") return null; + if (mode !== "reviewed_context") reject("Unsupported Goal transfer continuation profile"); + const coordination = requireJsonObject(original.coordination, "original Agent registration"); + if (!Array.isArray(coordination.registered_agents) || coordination.registered_agents.length !== 1 || + preview.ready_for_authority_preparation !== true || + requireJsonObject(preview.turn_history, "source Turn and Host closure").turn_hosts_closed !== true) { + reject("Reviewed continuation requires the original single Agent and closed native source owners"); + } + return {kind: "reviewed_context", agent_id: requireAuthorityStoreId(coordination.registered_agents[0], "original Agent"), + source_context_sha256: canonicalAuthoritySha256(context), materials_manifest_sha256: canonicalAuthoritySha256(materials), + source_sessions_adopted: false, credentials_adopted: false}; +} + +/** Read current registration without inventing a new-Goal creation receipt. + * The physical runtime/store binding is checked by the existing cutover owner. + * Stopping, revoking or redirecting this Goal immediately stops Host admission; + * a harmless display-name change and unrelated Goals do not invalidate it. */ +export async function readReviewedTransferContext(receipt: JsonObject): Promise<{snapshot: JsonObject; context: JsonObject; materials: JsonObject}> { + const decision = requireJsonObject(receipt.decision, "original cutover decision"); + const continuation = requireJsonObject(decision.continuation, "reviewed continuation admission"); + const admitted = requireJsonObject(receipt.goal, "admitted Goal"); + if (decision.profile !== "source_online_local_runtime_v0" || continuation.kind !== "reviewed_context" || + continuation.source_sessions_adopted !== false || continuation.credentials_adopted !== false) { + reject("Original cutover has no reviewed Host continuation admission"); + } + const snapshot = await readGoalTransferSnapshot(String(receipt.target_registry_path), String(decision.goal_id)); + const current = requireJsonObject(requireJsonObject(snapshot.source_context, "current Goal context").goal, "current Goal"); + if (snapshot.registry_profile !== null || snapshot.registry_role === "global-local" || snapshot.activation_state !== "active" || + ["id", "repo", "state_file", "planned_transfer", "goal_instance_id", "creation_operation_id", "status", + "domain", "role", "spawn_policy", "coordination", "guards", "execution_profile", "authority_sources"] + .some(key => !same(current[key] ?? null, admitted[key] ?? null))) { + reject("Current Goal registration no longer matches its reviewed continuation; use its Goal owner"); + } + const context = await readGoalTransferHistoryJson(join(dirname(resolve(String(current.repo), String(current.state_file))), "TRANSFER_CONTEXT.json")); + if (canonicalAuthoritySha256(context) !== continuation.source_context_sha256) { + reject("Original reviewed continuation context changed; preserve its operation"); + } + const materials = await readGoalTransferHistoryJson(join(dirname(resolve(String(current.repo), String(current.state_file))), "TRANSFER_MATERIALS.json")); + if (canonicalAuthoritySha256(materials) !== continuation.materials_manifest_sha256) { + reject("Original reviewed material mapping changed; preserve its operation"); + } + return {snapshot, context, materials}; +} + +/** Complete private context for a current Host request. It is guidance only: + * the ordinary Turn and Task/lease owners still decide actual work and effects. + * No authority or native database is reconstructed from these historical bytes. */ +export async function readGoalTransferHostContext(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "agent_id", "todo_id"])) reject("Host context requires its complete current work identity"); + const root = requireLocalAuthorityRuntimeRoot(value.runtime_root), goal = requireAuthorityStoreId(value.goal_id, "current Goal"); + const agent = requireAuthorityStoreId(value.agent_id, "current Agent"), todoId = requireAuthorityStoreId(value.todo_id, "current Task"); + const fence = await loadLegacyCoordinationWriterFence(root, goal); + if (fence.status === "failed") reject("Current Goal fence is unreadable"); + if (fence.status !== "loaded" || fence.fence.schema_version !== PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA) return {context: null}; + const {requireLocalGoalTransferAdmission} = await import("./planned_transfer_cutover.ts"); + const admission = await requireLocalGoalTransferAdmission(root, goal, fence.fence); + const {snapshot, context, materials: manifest} = await readReviewedTransferContext(admission); + const continuation = requireJsonObject(requireJsonObject(admission.decision, "cutover").continuation, "continuation"); + if (continuation.agent_id !== agent) reject("Reviewed continuation belongs to another Agent"); + const opened = await openLocalAuthorityStoreHandle(root, goal, {}, {existingOnly: true}); + const head = await opened.store.loadAuthority(); + if (head.status !== "loaded") reject("Current original work is unavailable"); + const index = indexCoordinationProjection(head.head, goal), selected = index.todos.get(todoId); + if (!selected || selected.claimed_by !== agent || selected.done === true || selected.archive_state === "archive") { + reject("Reviewed continuation requires this Agent's current unfinished original Task"); + } + const destinations = manifest.workspace_destinations as JsonObject[]; + if (!Array.isArray(destinations) || !Array.isArray(manifest.materials)) reject("Original material mapping is unavailable"); + const materials = (manifest.materials as JsonObject[]).map(row => ({...row, + relative_path: destinations.find(destination => destination.id === row.id)?.relative_path})); + const {readStagedGoalTransferRuns} = await import("../coordination/planned_transfer_target.ts"); + const history = await readStagedGoalTransferRuns({runtime_root: root, goal_id: goal, operation_id: fence.fence.operation_id}); + const after = await opened.store.loadAuthority(); + if (after.status !== "loaded" || after.provider_revision !== head.provider_revision || + !same(snapshot, await readGoalTransferSnapshot(String(admission.target_registry_path), goal)) || + !same(fence, await loadLegacyCoordinationWriterFence(root, goal))) reject("Original work changed during continuation context read; retry its current owner"); + return {context: {kind: "reviewed_context", goal_id: goal, agent_id: agent, + requirements: context.state_text, original_goal: context.goal, selected_todo: selected, + todos: index.todo_ids.map(id => index.todos.get(id)!), materials, original_accounting: history.provenance, + source_sessions_adopted: false, credentials_adopted: false, + guidance: "Continue the original work using current Task/lease and Host authority. Historical decisions and receipts grant no execution permission."}}; +} + +/** The existing Settings/CLI entry also works after moving to the destination. + * Use its admitted original decision and retained provenance; a disconnected + * old source is neither opened nor recreated to read current destination work. */ +export async function inspectAdmittedGoalTransferDestination(value: JsonObject): Promise { + const root = requireLocalAuthorityRuntimeRoot(value.runtime_root), goal = requireAuthorityStoreId(value.goal_id, "current Goal"); + const fence = await loadLegacyCoordinationWriterFence(root, goal); + if (fence.status !== "loaded" || !isPlannedTransferAdmissionFence(fence.fence)) return null; + await checkGoalTransferSourceContext(value.source_context, value.registry_witness, goal, root); + const {requireLocalGoalTransferAdmission, readLocalGoalTransferContinuation} = await import("./planned_transfer_cutover.ts"); + const admission = await requireLocalGoalTransferAdmission(root, goal, fence.fence); + const decision = requireJsonObject(admission.decision, "original admitted cutover"); + const current = await readLocalGoalTransferContinuation(decision); + return {ok: true, schema_version: "loopx_planned_transfer_operation_view_v0", goal_id: goal, + source_state: "cutover_recorded", operation_id: fence.fence.operation_id, + authority_preparation_ready: false, cancel_available: false, continuation_available: false, + local_cutover: {source_final: true, target_task_admitted: current.target_task_admitted, + target_runtime_admitted: current.target_runtime_admitted}, work: current.work, + holds: [{code: "transfer_journey_incomplete", owner: "goal_transfer"}]}; +} diff --git a/loopx/control_plane/goals/planned_transfer_cutover.ts b/loopx/control_plane/goals/planned_transfer_cutover.ts index fe93545393..6a07b8841c 100644 --- a/loopx/control_plane/goals/planned_transfer_cutover.ts +++ b/loopx/control_plane/goals/planned_transfer_cutover.ts @@ -2,8 +2,9 @@ * * The original source is read through its native owner, not a copied receipt. * Its final decision and cancellation share maintenance. The destination Goal - * owner admits native Tasks/accounting after registry readback; Host/session/ - * effect execution remains held. This is not cross-machine authorization. + * owner admits Tasks/accounting, or a separately reviewed new local runtime. + * Neither profile copies a session or credential. This is not cross-machine + * authorization or qualification of the complete installed user journey. */ import {join, resolve} from "node:path"; import {stat} from "node:fs/promises"; @@ -18,15 +19,18 @@ import {auditAuthorityArchive} from "../coordination/authority_archive_audit.ts" import {openLocalAuthorityStoreHandle, requireLocalAuthorityRuntimeRoot} from "../coordination/local_authority_provider.ts"; import {indexCoordinationProjection} from "../coordination/coordination_projection.ts"; import {loadLegacyCoordinationWriterFence, admitPlannedTransferTaskWriterUnderLock, - PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, PLANNED_TRANSFER_TASK_FENCE_SCHEMA} from "../coordination/legacy_writer_fence.ts"; + PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, PLANNED_TRANSFER_TASK_FENCE_SCHEMA, + PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA, isPlannedTransferAdmissionFence} from "../coordination/legacy_writer_fence.ts"; import {shadowManagementDirectory, withShadowMaintenanceLock} from "../coordination/shadow_management.ts"; import {readGoalTransferHistoryJson} from "./planned_transfer_history.ts"; import {requirePhysicalTransferPath, inspectTransferFile} from "./planned_transfer_container.ts"; import {readGoalTransferSnapshot, withVerifiedGoalTransferContext} from "./planned_transfer.ts"; +import {planReviewedTransferContinuation, readReviewedTransferContext} from "./planned_transfer_continuation.ts"; import {observeLocalWorkspaceHostIdentity} from "../work_items/task_lease_workspace.ts"; const CUTOVER_SCHEMA = "loopx_local_goal_transfer_cutover_v0"; const ADMISSION_SCHEMA = "loopx_local_goal_transfer_task_admission_v0"; +const RUNTIME_ADMISSION_SCHEMA = "loopx_local_goal_transfer_runtime_admission_v0"; function reject(message: string): never {throw new EffectRuntimeConflictError(message);} function same(a: unknown, b: unknown): boolean {return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b));} function operationDirectory(root: string, goal: string, operation: string, source: boolean): string { @@ -52,7 +56,7 @@ async function locationIdentity(root: string): Promise { * empty backup. Unknown Goal/adapter/config fields require their actual owner. * Source sessions and credentials remain historical; none is auto-installed. */ -function localTaskGoal(original: JsonObject, staged: JsonObject): JsonObject { +function localAdmittedGoal(original: JsonObject, staged: JsonObject, continuation: JsonObject | null): JsonObject { const keys = ["id", "display_name", "domain", "status", "role", "parent_goal_id", "repo", "state_file", "authority_sources", "adapter", "spawn_policy", "coordination", "execution_profile", "next_probe", "guards", "creation_operation_id", "activation"]; @@ -78,9 +82,11 @@ function localTaskGoal(original: JsonObject, staged: JsonObject): JsonObject { parent_goal_id: null, spawn_policy: spawn, coordination: currentCoordination, ...(original.execution_profile === undefined ? {} : {execution_profile: original.execution_profile}), ...(original.guards === undefined ? {} : {guards: original.guards}), - activation: {schema_version: "loopx_goal_activation_v1", state: "active", reason: "Native local Task admission; Host continuation remains unqualified"}, + activation: {schema_version: "loopx_goal_activation_v1", state: "active", reason: continuation ? "Reviewed original work continues in this new local runtime" : "Native local Task admission; Host continuation remains unqualified"}, planned_transfer: {...requireJsonObject(staged.planned_transfer, "staged transfer"), - state: "task_admitted", identity_continuity: "same_goal_task_continuation", fresh_admission_required: true, task_write_admitted: true}}; + state: continuation ? "runtime_admitted" : "task_admitted", + identity_continuity: continuation ? "same_goal_new_runtime" : "same_goal_task_continuation", + fresh_admission_required: true, task_write_admitted: true, ...(continuation ? {runtime_admitted: true} : {})}}; } /** The registry codec calls this under its original transaction. This is only @@ -88,23 +94,25 @@ function localTaskGoal(original: JsonObject, staged: JsonObject): JsonObject { export async function localTransferRegistryPublication(root: string, goal: string, operation: string, registry: string, proposed: JsonObject): Promise { const intent = await optional(join(operationDirectory(root, goal, operation, false), "admission.json")); - if (!intent || intent.schema_version !== ADMISSION_SCHEMA || intent.target_registry_path !== registry || + if (!intent || ![ADMISSION_SCHEMA, RUNTIME_ADMISSION_SCHEMA].includes(String(intent.schema_version)) || intent.target_registry_path !== registry || !same(intent.goal, proposed)) return false; const decision = requireJsonObject(intent.decision, "native source cutover"); return decision.schema_version === CUTOVER_SCHEMA && decision.goal_id === goal && decision.operation_id === operation && same(await optional(join(operationDirectory(String(decision.source_runtime_root), goal, operation, true), "cutover.json")), decision); } -/** Read-only native Task admission used by canonical writers and accounting. - * It proves this runtime's retained lineage, not an imported lease/session. */ -export async function requireLocalTaskTransferAdmission(root: string, goal: string, fence: JsonObject): Promise { - if (fence.schema_version !== PLANNED_TRANSFER_TASK_FENCE_SCHEMA) reject("Target admission fence is required"); +/** Read-only admission from the existing Goal-owned cutover receipt. + * It proves this physical runtime's retained lineage, not a copied grant. */ +export async function requireLocalGoalTransferAdmission(root: string, goal: string, fence: JsonObject): Promise { + if (!isPlannedTransferAdmissionFence(fence)) reject("Target admission fence is required"); const directory = operationDirectory(root, goal, String(fence.operation_id), false); const receipt = await readGoalTransferHistoryJson(join(directory, "admission-receipt.json")); const decision = requireJsonObject(receipt.decision, "admitted original cutover"); const target = requireJsonObject(decision.target, "admitted destination"); const intent = await readGoalTransferHistoryJson(join(directory, "admission.json")); - if (receipt.schema_version !== ADMISSION_SCHEMA || canonicalAuthoritySha256(receipt) !== fence.task_admission_sha256 || + const runtime = fence.schema_version === PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA; + if (receipt.schema_version !== (runtime ? RUNTIME_ADMISSION_SCHEMA : ADMISSION_SCHEMA) || + canonicalAuthoritySha256(receipt) !== fence[runtime ? "runtime_admission_sha256" : "task_admission_sha256"] || !same(intent, receipt) || decision.goal_id !== goal || decision.operation_id !== fence.operation_id || target.runtime_root !== root || target.identity_sha256 !== fence.source_identity_sha256 || target.store_identity !== fence.store_identity) reject("Original target admission changed; recover its original operation"); @@ -137,9 +145,10 @@ export async function readLocalGoalTransferContinuation(decision: JsonObject): P if (!same(fence, await loadLegacyCoordinationWriterFence(root, goal)) || physical !== await locationIdentity(root)) { reject("Original destination changed during readback"); } - return {target_task_admitted: false, work: null}; + return {target_task_admitted: false, target_runtime_admitted: false, work: null}; } - const admission = await requireLocalTaskTransferAdmission(root, goal, fence.fence); + const admission = await requireLocalGoalTransferAdmission(root, goal, fence.fence); + if (fence.fence.schema_version === PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA) await readReviewedTransferContext(admission); if (!same(admission.decision, decision)) reject("Original destination admission differs from source-final"); const snapshot = await readGoalTransferSnapshot(String(target.registry_path), goal); const opened = await openLocalAuthorityStoreHandle(root, goal, {}, {existingOnly: true}); @@ -161,7 +170,7 @@ export async function readLocalGoalTransferContinuation(decision: JsonObject): P !same(snapshot, await readGoalTransferSnapshot(String(target.registry_path), goal))) { reject("Original destination changed during readback; retry its current state"); } - return {target_task_admitted: true, work: {objective: snapshot.state_objective, requirements: context.state_text, + return {target_task_admitted: true, target_runtime_admitted: fence.fence.schema_version === PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA, work: {objective: snapshot.state_objective, requirements: context.state_text, agents: coordination.registered_agents, todos: index.todo_ids.map(id => index.todos.get(id)!)}}; } @@ -173,7 +182,7 @@ export async function readLocalGoalTransferContinuation(decision: JsonObject): P export async function planLocalGoalTransferCutover(value: JsonObject): Promise { const execute = value.execute === true; if (!hasExactAuthorityKeys(value, ["source", "target_snapshot", "target_registry", - ...(execute ? ["execute", "expected_preview_sha256"] : [])])) reject("Local cutover requires complete current source and target observations"); + ...(Object.hasOwn(value, "continuation") ? ["continuation"] : []), ...(execute ? ["execute", "expected_preview_sha256"] : [])])) reject("Local cutover requires complete current source and target observations"); const source = requireJsonObject(value.source, "original source request"); if (!hasExactAuthorityKeys(source, ["runtime_root", "goal_id", "operation_id", "source_context", "registry_witness"])) reject("Local cutover requires its original registered source"); const root = requireLocalAuthorityRuntimeRoot(source.runtime_root), goal = requireAuthorityStoreId(source.goal_id, "original Goal"); @@ -203,13 +212,15 @@ export async function planLocalGoalTransferCutover(value: JsonObject): Promise row.id === goal ? intent.goal : row)}}; } const pending = await readGoalTransferHistoryJson(join(sourceDirectory, "preparation.json")); @@ -245,7 +256,12 @@ export async function planLocalGoalTransferCutover(value: JsonObject): Promise row.id === goal ? admittedGoal : row)}}; })); }); @@ -291,9 +308,10 @@ export async function finishLocalGoalTransferCutover(value: JsonObject): Promise const intent = await readGoalTransferHistoryJson(join(directory, "admission.json")); const decision = requireJsonObject(intent.decision, "final original source decision"); const fence = await loadLegacyCoordinationWriterFence(root, goal); - if (fence.status === "loaded" && fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA) { - await requireLocalTaskTransferAdmission(root, goal, fence.fence); - return {source_final: true, target_task_admitted: true, replayed: true, goal_id: goal, operation_id: operation}; + if (fence.status === "loaded" && isPlannedTransferAdmissionFence(fence.fence)) { + await requireLocalGoalTransferAdmission(root, goal, fence.fence); + return {source_final: true, target_task_admitted: true, + target_runtime_admitted: fence.fence.schema_version === PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA, replayed: true, goal_id: goal, operation_id: operation}; } if (!await localTransferRegistryPublication(root, goal, operation, String(intent.target_registry_path), requireJsonObject(intent.goal, "admitted Goal"))) { reject("Original source has no final cutover decision; target remains stopped"); @@ -312,13 +330,15 @@ export async function finishLocalGoalTransferCutover(value: JsonObject): Promise if (identity.status !== "available" || identity.store_identity !== target.store_identity || head.status !== "loaded" || head.provider_revision !== target.provider_revision) reject("Target authority changed before admission; preserve it for recovery"); await readStagedGoalTransferRuns({runtime_root: root, goal_id: goal, operation_id: operation}); + if (decision.continuation) await readReviewedTransferContext(intent); await retain(join(directory, "admission-receipt.json"), intent); await admitPlannedTransferTaskWriterUnderLock(root, goal, fence.fence, { - schema_version: PLANNED_TRANSFER_TASK_FENCE_SCHEMA, state: "engaged", goal_id: goal, + schema_version: decision.continuation ? PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA : PLANNED_TRANSFER_TASK_FENCE_SCHEMA, state: "engaged", goal_id: goal, fence_id: `planned-transfer-task:${operation}`, operation_id: operation, - source_identity_sha256: target.identity_sha256, task_admission_sha256: canonicalAuthoritySha256(intent), store_identity: target.store_identity, + source_identity_sha256: target.identity_sha256, + [decision.continuation ? "runtime_admission_sha256" : "task_admission_sha256"]: canonicalAuthoritySha256(intent), store_identity: target.store_identity, }); - return {source_final: true, target_task_admitted: true, replayed: false, goal_id: goal, operation_id: operation}; + return {source_final: true, target_task_admitted: true, target_runtime_admitted: decision.continuation !== undefined, replayed: false, goal_id: goal, operation_id: operation}; }); }); } diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index f87d11e7a5..8305af285a 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -46,6 +46,7 @@ def _read_goal_transfer_snapshot(*, registry_path: Path, goal_id: str) -> dict[s from ...registry import find_registry_goal, resolve_state_file from ..coordination.authority_source_capture import authority_registry_source from .active_state_metadata import active_state_section_text + from .activation import goal_activation_state registry_path = registry_path.expanduser().resolve() with authority_registry_source(registry_path) as witness: @@ -61,6 +62,7 @@ def _read_goal_transfer_snapshot(*, registry_path: Path, goal_id: str) -> dict[s "runtime_root": registry["common_runtime_root"], "registry_path": str(registry_path)}, "registry_witness": witness, "registry_profile": registry.get("profile_id"), "registry_role": registry.get("registry_role"), + "activation_state": goal_activation_state(goal).value, "state_objective": active_state_section_text(state_text, "Objective")} @@ -106,7 +108,7 @@ def cancel_registered_goal_transfer(*, registry_path: Path, goal_id: str, operat def local_goal_transfer_cutover( *, registry_path: Path, goal_id: str, operation_id: str, destination_registry: Path, - expected_preview_sha256: str | None = None, + expected_preview_sha256: str | None = None, continuation: str | None = None, ) -> dict[str, Any]: """One source-online local cutover; native owners decide every transition. @@ -120,6 +122,8 @@ def local_goal_transfer_cutover( before = load_registry(destination_registry) request: dict[str, Any] = {"source": {**context, "runtime_root": context["source_context"]["runtime_root"], "goal_id": goal_id, "operation_id": operation_id}, "target_snapshot": snapshot, "target_registry": before} + if continuation is not None: + request["continuation"] = continuation if expected_preview_sha256 is not None: request.update(execute=True, expected_preview_sha256=expected_preview_sha256) result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.plan_local_cutover", request, @@ -140,6 +144,18 @@ def local_goal_transfer_cutover( return {"ok": True, **result, **admitted, "registry_readback_verified": True} +def read_goal_transfer_host_context(*, registry_path: Path, goal_id: str, + agent_id: str, todo_id: str) -> dict[str, Any]: + """Read the current Goal owner's context; this IO adapter grants nothing.""" + from ..runtime.runtime_projection_route import resolve_goal_source_runtime_route + + route = resolve_goal_source_runtime_route(registry_path=registry_path, goal_id=goal_id) + result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.host_context", { + "runtime_root": route["source_runtime_root"], "goal_id": goal_id, "agent_id": agent_id, "todo_id": todo_id, + }, timeout=60.0, retry_safe=True) + return result + + def prepare_registered_goal_transfer( *, registry_path: Path, goal_id: str, operation_id: str, expected_preview_sha256: str, ) -> dict[str, Any]: diff --git a/loopx/control_plane/quota/accounting_artifact_transaction.ts b/loopx/control_plane/quota/accounting_artifact_transaction.ts index 6810ee82b4..adb82cf492 100644 --- a/loopx/control_plane/quota/accounting_artifact_transaction.ts +++ b/loopx/control_plane/quota/accounting_artifact_transaction.ts @@ -14,7 +14,7 @@ import { import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; import {withGoalRuntimeWriter} from "../coordination/local_authority_write.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; -import {loadLegacyCoordinationWriterFence, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, PLANNED_TRANSFER_TASK_FENCE_SCHEMA} from "../coordination/legacy_writer_fence.ts"; +import {loadLegacyCoordinationWriterFence, PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, isPlannedTransferAdmissionFence} from "../coordination/legacy_writer_fence.ts"; import {shadowManagementDirectory} from "../coordination/shadow_management.ts"; import { jsonObject, @@ -535,7 +535,7 @@ export async function lookupQuotaAccountingReplay( try {await lstat(join(shadowManagementDirectory(root, goalId), "planned-transfer-targets")); retainedTarget = true;} catch (error) {if (!isNodeErrorCode(error, "ENOENT")) throw error;} const targetFence = fence.status === "loaded" && ((fence.fence.schema_version === PLANNED_TRANSFER_WRITER_FENCE_SCHEMA && fence.fence.role === "target") || - fence.fence.schema_version === PLANNED_TRANSFER_TASK_FENCE_SCHEMA); + isPlannedTransferAdmissionFence(fence.fence)); if (retainedTarget || targetFence) { // Retained lineage without its fence is damaged state, not an absent // old debit. Unrelated ordinary/legacy replay retains its prior rule. diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index 6553f223b8..fb0c066f34 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -288,6 +288,10 @@ def _prompt(request: Mapping[str, Any]) -> str: "For user_action_required, wait, or iteration_failed, leave material-only fields empty and explain the stop in summary. iteration_failed ends only this iteration and never requests a retry or successor.", 'completed_phases must be exactly ["host_execute","typed_result"], and turn_key must match the request.', ] + if request.get("transfer_context"): + instructions.append( + "Read transfer_context before continuing this original unfinished work. It preserves reviewed historical requirements, decisions and material mappings; it grants no permissions, and historical leases, receipts and sessions are not current execution proof. Use the current TurnEnvelope and Task/lease authority." + ) recall = _mapping(request.get("reward_memory_recall")) if (reward_memory_automation_enabled(request, operation="automatic_recall") and _mapping(recall.get("context")).get("guidance")): @@ -670,6 +674,19 @@ def run_codex_cli_host( if not resolved or not Path(resolved).exists(): raise ValueError("Codex CLI executable is unavailable") lineage = _lineage(request) + if registry_path is not None: + from ..coordination.legacy_writer_fence import legacy_coordination_writer_fence_path + + # Existence is only a transport fast path; TypeScript owns the profile + # decision. Ordinary Goals without transfer state keep their old prompt. + fence = legacy_coordination_writer_fence_path(runtime_root=runtime_root, goal_id=lineage["goal_id"]) + if fence.exists() or fence.is_symlink(): + from ..goals.planned_transfer_io import read_goal_transfer_host_context + + context = read_goal_transfer_host_context(registry_path=registry_path, goal_id=lineage["goal_id"], + agent_id=lineage["agent_id"], todo_id=lineage["todo_id"])["context"] + if context is not None: + request = {**request, "transfer_context": context} planned_session = _mapping(request.get("session")) planned_action = str(planned_session.get("action") or "") context_policy = _mapping(planned_session.get("context_policy")) diff --git a/loopx/control_plane/turn_driver/journal_store.py b/loopx/control_plane/turn_driver/journal_store.py index d0c45f3e87..448ae84b76 100644 --- a/loopx/control_plane/turn_driver/journal_store.py +++ b/loopx/control_plane/turn_driver/journal_store.py @@ -143,6 +143,7 @@ def find_loopx_turn_key_by_settlement_identity( agent_id: str, todo_id: str, turn_instance_id: str, + goal_ref: Mapping[str, Any] | None = None, ) -> str | None: """Find one validated history; unreadable or conflicting history blocks retry.""" @@ -153,6 +154,7 @@ def find_loopx_turn_key_by_settlement_identity( "agent_id": agent_id, "todo_id": todo_id, "turn_instance_id": turn_instance_id, + **({"goal_ref": dict(goal_ref)} if goal_ref is not None else {}), }) except EffectRuntimeConflict as exc: # Preserve the public ambiguity exception; the native owner also diff --git a/loopx/control_plane/turn_driver/turn_journal_effects.ts b/loopx/control_plane/turn_driver/turn_journal_effects.ts index 3d14c62d28..c3eb2e844b 100644 --- a/loopx/control_plane/turn_driver/turn_journal_effects.ts +++ b/loopx/control_plane/turn_driver/turn_journal_effects.ts @@ -21,6 +21,7 @@ import { decideFirstPartyHostRuntime } from "../goals/first_party_host_runtime.t import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; import { requireNonEmptyString as requiredString } from "../runtime_decode.ts"; import { preparedAttemptViolation } from "./turn_journal_attempt_contract.ts"; +import {querySettlementJournal} from "./turn_journal_query.ts"; import { interpretTurnJournalEffect, journalPhaseViolation, @@ -338,6 +339,8 @@ export async function commitTurnJournal( ); } const incomingOperationId = operationId(journal); + const turns = dirname(path), goal = dirname(turns), goals = dirname(goal); + const canonicalRoute = basename(turns) === "turns" && basename(goals) === "goals"; const commitBytes = async (): Promise => await withFileMutationLock(path, async () => { let existing: JsonObject | null = null; @@ -374,6 +377,19 @@ export async function commitTurnJournal( )) { conflict("A new Turn journal must begin in progress with no completed phases"); } + if (existing === null && canonicalRoute) { + // The ordinary runtime maintenance lock serializes this check and + // publication. A changed plan hash cannot allocate a second Host + // attempt for the same structured caller/Task/Turn identity. + const parsed = settlementIdentityFromPlan(asObject(asObject(journal.plan).transaction)); + if (parsed.failure !== null) conflict(parsed.failure.reason); + const exact = goalBinding.kind === "exact" ? parseExactGoalRef(goalBinding.goal_ref) : null; + if (exact?.kind === "invalid") conflict("Turn journal GoalRef is invalid"); + const original = await querySettlementJournal(dirname(goals), parsed.value, exact?.value ?? null); + if (original !== null && original.turn_key !== journal.turn_key) { + conflict(`Original settlement already belongs to Turn ${original.turn_key}; resume its original journal`); + } + } await atomicWriteJson(path, journal); return { ok: true, @@ -384,8 +400,7 @@ export async function commitTurnJournal( }; }); const commit = async (): Promise => { - const turns = dirname(path), goal = dirname(turns), goals = dirname(goal); - if (basename(turns) !== "turns" || basename(goals) !== "goals") { + if (!canonicalRoute) { if (params.maintenance_lock !== undefined) conflict("Turn journal maintenance handoff requires its canonical runtime route"); return commitBytes(); } diff --git a/loopx/control_plane/turn_driver/turn_journal_query.ts b/loopx/control_plane/turn_driver/turn_journal_query.ts index 63efbc346a..1521ba7820 100644 --- a/loopx/control_plane/turn_driver/turn_journal_query.ts +++ b/loopx/control_plane/turn_driver/turn_journal_query.ts @@ -229,7 +229,7 @@ function evidenceForJournal( }; } -async function querySettlementJournal( +export async function querySettlementJournal( runtimeRootValue: unknown, identity: BoundSettlementIdentity, goalRef: ExactGoalRef | null, @@ -277,7 +277,11 @@ export async function findTurnJournalBySettlement(params: JsonObject): Promise<{ todo_id: requireNonEmptyString(params.todo_id, "todo_id"), turn_instance_id: requireNonEmptyString(params.turn_instance_id, "turn_instance_id"), })); - const evidence = await querySettlementJournal(params.runtime_root, identity, null); + const goalRef = Object.hasOwn(params, "goal_ref") ? decodeGoalRef(params.goal_ref) : null; + if (goalRef !== null && goalRef.goalId.value !== identity.goal_id) { + throw new EffectRuntimeRequestError("Turn journal lookup GoalRef conflicts with its settlement identity"); + } + const evidence = await querySettlementJournal(params.runtime_root, identity, goalRef); return { turn_key: evidence?.turn_key ?? null }; } diff --git a/tests/control_plane/test_planned_transfer_continuation.py b/tests/control_plane/test_planned_transfer_continuation.py new file mode 100644 index 0000000000..c7c3217d04 --- /dev/null +++ b/tests/control_plane/test_planned_transfer_continuation.py @@ -0,0 +1,201 @@ +"""A reviewed context continues original work under new destination authority. + +The Task-only profile remains covered by test_planned_transfer_cutover. These +fixtures never activate a real Goal, borrow a native session or contact a paid model. +""" +from __future__ import annotations + +import json +import os +import shlex +import sys +from pathlib import Path + +import pytest + +from loopx.control_plane.goals.first_party_host_admission import FirstPartyHostGoalAdmission +from loopx.control_plane.effect_runtime import EffectRuntimeRemoteError, restart_effect_runtime +from loopx.control_plane.projects.registry_codec import project_registry_transaction +from loopx.control_plane.turn_driver.codex_sessions import select_codex_cli_session +from loopx.history import collect_history +from tests.control_plane.test_planned_transfer_cancellation import source as source_fixture +from tests.control_plane.test_planned_transfer_cutover import OPERATION, _staged, _target_worktree +from tests.control_plane.test_planned_transfer_operation import _http + +source = source_fixture + + +def test_reviewed_context_admits_a_new_runtime_without_copying_a_session(source: dict, tmp_path: Path): + target = _staged(source, tmp_path, source["provider"]) + arguments = ("--goal-id", "report", "--operation-id", OPERATION, + "--destination-registry", str(target["registry"]), "--continuation", "reviewed_context") + preview = source["cli"]("goal-transfer", "cutover-preview", *arguments) + assert preview["decision"]["continuation"]["agent_id"] == "agent-a" + assert preview["decision"]["admitted_goal"]["planned_transfer"]["identity_continuity"] == "same_goal_new_runtime" + assert source["cli"]("goal-transfer", "cutover", *arguments, "--expected-preview-sha256", + preview["preview_sha256"], "--execute")["target_runtime_admitted"] is True + admission = FirstPartyHostGoalAdmission.for_plan(registry_path=target["registry"], goal_id="report", planned_goal_ref=None) + admission.require_current() + assert select_codex_cli_session(target["runtime"], lineage=source["lineage"], goal_admission=admission) is None + context = target["cli"]("goal-transfer", "context", "--goal-id", "report", "--agent-id", "agent-a", "--todo-id", source["todo"]) + assert context["context"]["selected_todo"]["todo_id"] == source["todo"] + assert context["context"]["selected_todo"]["done"] is False + assert context["context"]["source_sessions_adopted"] is False + assert context["context"]["credentials_adopted"] is False + assert context["context"]["materials"][0]["relative_path"] == "input.txt" + assert "Produce report.txt from input.txt" in context["context"]["requirements"] + + +@pytest.mark.skipif(not os.environ.get("LOOPX_NATIVE_CHECKPOINT_BIN"), reason="requires the isolated real Codex fixture") +def test_real_fresh_native_turn_finishes_original_work_and_recovers_without_reexecution(source: dict, tmp_path: Path, monkeypatch): + from tests.control_plane.codex_checkpoint_fixture import real_native_source + + # Original accepted context and its relation survive; no replacement Todo + # is invented after transfer. Expected 21 is fixed before any model output. + accepted = source["cli"]("todo", "add", "--goal-id", "report", "--role", "user", "--task-class", "user_action", + "--text", "Accept exactly 21 from the unchanged three input values", "--operation-id", "original-criteria")["todo_id"] + source["cli"]("todo", "complete", "--goal-id", "report", "--role", "user", "--todo-id", accepted, + "--agent-id", "agent-a", "--evidence", "input.txt", "--successor-todo-id", source["todo"]) + source["cli"]("todo", "archive-completed", "--goal-id", "report", "--role", "user", "--max-active-done", "0", "--execute") + original = source["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] + target = _staged(source, tmp_path, source["provider"]) + args = ("--goal-id", "report", "--operation-id", OPERATION, "--destination-registry", str(target["registry"]), + "--continuation", "reviewed_context") + preview = source["cli"]("goal-transfer", "cutover-preview", *args) + confirmed = source["cli"]("goal-transfer", "cutover", *args, "--expected-preview-sha256", preview["preview_sha256"], "--execute") + assert confirmed["target_runtime_admitted"] is True + worktree = _target_worktree(target) + lease = target["cli"]("task-lease", "acquire", "--goal-id", "report", "--todo-id", source["todo"], + "--owner", "agent-a", "--idempotency-key", "new-target-native-work", "--write-scope", "report.txt", + "--write-worktree", str(worktree))["lease"] + assert lease["lease_epoch"] > 1 and lease["idempotency_key"] != "original-work" + script = "from pathlib import Path; p=Path('report.txt'); assert not p.exists(); p.write_text(str(sum(map(int,Path('input.txt').read_text().split())))+'\\n')" + requests = [] + + def response(body, number, message): + if number == 1: # The reusable fixture's independent native source. + return message + texts = [] + for item in body.get("input", []): + for part in item.get("content", []) if isinstance(item, dict) else []: + if isinstance(part, dict) and isinstance(part.get("text"), str): + texts.append(part["text"]) + packet = next((text.split("Turn request:\n", 1)[1] for text in texts if "Turn request:\n" in text), None) + if packet is not None: + request = json.loads(packet) + if not requests: + requests.append(request) + assert requests, "The ordinary native driver must supply the complete current request" + context = requests[0]["transfer_context"] + assert context["selected_todo"]["todo_id"] == source["todo"] + assert context["requirements"].find("Produce report.txt from input.txt") >= 0 + archived = next(row for row in context["todos"] if row["todo_id"] == accepted) + assert archived["archive_state"] == "archive" and archived["successor_todo_ids"] == [source["todo"]] + if number == 2: + return {"type": "function_call", "id": "fc_original_report", "call_id": "call_original_report", + "name": "exec_command", "arguments": json.dumps({"cmd": shlex.quote(sys.executable) + " -c " + shlex.quote(script), "max_output_tokens": 1000})} + result = {"schema_version": "loopx_turn_result_v0", "turn_key": requests[0]["turn_key"], + "result_kind": "validated_progress", "completed_phases": ["host_execute", "typed_result"], + "classification": "original_work_continued", "recommended_action": "Read the independently verified original report.", + "next_action": "Accept the original work through its existing Task.", "delivery_batch_scale": "implementation", + "delivery_outcome": "outcome_progress", "vision_unchanged_reason": "Continue the original report objective.", + "path_delta_mode": "unchanged", "agent_vision_json": "", "summary": "Created the original report from transferred input."} + return {**message, "content": [{"type": "output_text", "text": json.dumps(result)}]} + + with real_native_source(tmp_path / "fresh-native", os.environ["LOOPX_NATIVE_CHECKPOINT_BIN"], worktree, + close_source=True, response_builder=response, sandbox="danger-full-access") as (env, unused_session, _, calls): + for key, value in env.items(): + monkeypatch.setenv(key, value) + validator = [sys.executable, "-c", "from pathlib import Path; assert Path('report.txt').read_bytes()==b'21\\n'"] + arguments = ("turn", "run-once", "--goal-id", "report", "--agent-id", "agent-a", "--todo-id", source["todo"], + "--turn-instance-id", "continue-original-work", "--project", str(worktree), "--host", "codex-cli", + "--codex-bin", env["CODEX_BIN"], "--codex-sandbox", "danger-full-access", "--codex-model", "fixture", + "--codex-reasoning-effort", "high", "--session-scope", "agent", "--validation-command-json", json.dumps(validator), + "--available-capability", "filesystem_read", "--available-capability", "filesystem_write", "--available-capability", "shell", + "--timeout-seconds", "60", "--no-global-sync", "--execute") + result = target["cli"](*arguments) + assert result["ok"] is True, result + assert (worktree / "report.txt").read_bytes() == b"21\n" + binding = select_codex_cli_session(target["runtime"], lineage={"goal_id": "report", "agent_id": "agent-a"}, session_scope="agent") + assert binding["session_id"] not in ("original-context", unused_session) + # Read the same committed Turn; a second tool execution would fail the + # independent not-exists assertion and must never happen. + count = len(calls) + restart_effect_runtime(wait_for_exit=True) + replay = target["cli"](*arguments) + assert replay["ok"] is True and len(calls) == count + assert (worktree / "report.txt").read_bytes() == b"21\n" + current = target["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] + assert current["text"] == original["text"] and current["required_write_scopes"] == original["required_write_scopes"] + target["cli"]("todo", "complete", "--goal-id", "report", "--todo-id", source["todo"], "--agent-id", "agent-a", + "--task-lease-idempotency-key", lease["idempotency_key"], "--task-lease-expected-version", str(lease["version"]), + "--evidence", "report.txt", "--note", "Original result independently accepted as 21") + observed = source["cli"]("goal-transfer", "inspect", "--goal-id", "report") + assert next(row for row in observed["work"]["todos"] if row["todo_id"] == source["todo"])["done"] is True + assert observed["continuation_available"] is False # Full installed journey remains unqualified. + assert observed["local_cutover"]["target_runtime_admitted"] is True + assert target["cli"](*arguments)["ok"] is True and len(calls) == count + history = collect_history(registry_path=target["registry"], runtime_root=target["runtime"], goal_id="report", limit=50)["goals"][0] + assert history["quota"]["spent_slots"] == 2 + with pytest.raises((ValueError, EffectRuntimeRemoteError), match="prepared for transfer"): + source["admission"].require_current() + source["runtime"].rename(tmp_path / "closed-original-runtime") + source["project"].rename(tmp_path / "closed-original-project") + destination = target["cli"]("goal-transfer", "inspect", "--goal-id", "report") + assert destination["work"] == observed["work"] and destination["local_cutover"]["target_runtime_admitted"] is True + server, thread, call = _http(target) + try: + assert call({"action": "inspect", "goal_id": "report"}) == (200, destination) + finally: + server.shutdown() + thread.join(5) + server.server_close() + assert not source["runtime"].exists() and not source["project"].exists() + + +@pytest.mark.parametrize("fault", ["context", "materials", "mode"]) +def test_changed_review_refuses_before_source_final(source: dict, tmp_path: Path, fault: str): + target = _staged(source, tmp_path, source["provider"]) + args = ("--goal-id", "report", "--operation-id", OPERATION, "--destination-registry", str(target["registry"])) + preview = source["cli"]("goal-transfer", "cutover-preview", *args, "--continuation", "reviewed_context") + if fault != "mode": + path = target["project"] / ".loopx/goals/report" / ("TRANSFER_CONTEXT.json" if fault == "context" else "TRANSFER_MATERIALS.json") + path.write_text("{}") + result = source["cli"]("goal-transfer", "cutover", *args, + "--continuation", "task_only" if fault == "mode" else "reviewed_context", + "--expected-preview-sha256", preview["preview_sha256"], "--execute", expected_code=1) + assert result["ok"] is False + assert "context changed" in result["error"] if fault != "mode" else "preview changed" in result["error"] + assert (target["project"] / ".loopx/goals/report/ACTIVE_GOAL_STATE.md").is_file() + assert not (target["runtime"] / "goals/report/turn-sessions").exists() + assert source["cli"]("goal-transfer", "inspect", "--goal-id", "report")["source_state"] == "prepared" + + +@pytest.mark.parametrize("fault", ["agent", "todo", "context", "registration"]) +def test_current_binding_or_context_failure_cannot_start_a_host(source: dict, tmp_path: Path, fault: str): + target = _staged(source, tmp_path, source["provider"]) + args = ("--goal-id", "report", "--operation-id", OPERATION, "--destination-registry", str(target["registry"]), + "--continuation", "reviewed_context") + preview = source["cli"]("goal-transfer", "cutover-preview", *args) + source["cli"]("goal-transfer", "cutover", *args, "--expected-preview-sha256", preview["preview_sha256"], "--execute") + if fault == "context": + (target["project"] / ".loopx/goals/report/TRANSFER_CONTEXT.json").write_text("{}") + elif fault == "registration": + with project_registry_transaction(target["registry"], operation="stop-current-Goal") as transaction: + payload = transaction.payload_copy() + payload["goals"][0]["activation"]["state"] = "stopped" + transaction.commit(payload) + failed = target["cli"]("goal-transfer", "context", "--goal-id", "report", + "--agent-id", "foreign" if fault == "agent" else "agent-a", "--todo-id", + "todo_foreign" if fault == "todo" else source["todo"], expected_code=1) + assert failed["ok"] is False + assert not (target["runtime"] / "goals/report/turn-sessions").exists() + assert not (target["project"] / "report.txt").exists() + if fault == "registration": + # A stop denies Host work but cannot lock the ordinary Goal owner out + # of resume. A new explanatory reason is metadata, not a new grant. + with project_registry_transaction(target["registry"], operation="resume-current-Goal") as transaction: + payload = transaction.payload_copy() + payload["goals"][0]["activation"].update(state="active", reason="Owner resumed this Goal") + transaction.commit(payload) + FirstPartyHostGoalAdmission.for_plan(registry_path=target["registry"], goal_id="report", planned_goal_ref=None).require_current() diff --git a/tests/control_plane_ts/turn_journal_effects.test.ts b/tests/control_plane_ts/turn_journal_effects.test.ts index d484160691..a324284548 100644 --- a/tests/control_plane_ts/turn_journal_effects.test.ts +++ b/tests/control_plane_ts/turn_journal_effects.test.ts @@ -172,6 +172,30 @@ test("journal checkpoint retry is idempotent and operation-scoped", async () => }); }); +test("one caller settlement cannot allocate two canonical journals concurrently", async () => { + const directory = await mkdtemp(join(tmpdir(), "loopx-ts-original-turn-")); + try { + const snapshots = ["a", "b"].map(digit => { + const key = `sha256:${digit.repeat(64)}`; + const value = journal("in_progress", [], "fixture-agent", key); + const transaction = (value.plan as Record).transaction as Record; + transaction.turn_instance_id = "stable-caller-turn"; + Object.assign((transaction.settlement_plan as Record).identity as object, + {turn_instance_id: "stable-caller-turn", effect_id: effectId("fixture-agent", "stable-caller-turn")}); + return {value, path: join(directory, "goals", "fixture-goal", "turns", `${digit.repeat(64)}.json`)}; + }); + const results = await Promise.allSettled(snapshots.map(({value, path}) => commitTurnJournal({path, journal: value}))); + assert.equal(results.filter(row => row.status === "fulfilled").length, 1); + const denied = results.find(row => row.status === "rejected") as PromiseRejectedResult; + assert.match(String(denied.reason), /Original settlement already belongs to Turn/); + const first = results.findIndex(row => row.status === "fulfilled"); + assert.equal((await commitTurnJournal({path: snapshots[first]!.path, journal: snapshots[first]!.value})).replayed, true); + await assert.rejects(readFile(snapshots[1 - first]!.path), {code: "ENOENT"}); + } finally { + await rm(directory, {recursive: true, force: true}); + } +}); + test("contradictory settlement binding cannot create or replace a durable journal", async () => { await withJournalPath(async (path) => { const valid = journal(); From a678ba28a8c1ef30c9137b38c8e0e29df85cb7ac Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:25:51 +0800 Subject: [PATCH 18/55] feat(migration): prepare and confirm local original-work moves Compose local preparation through existing Goal, custody and registry owners, then confirm the retained review from original Settings or CLI. Preserve cancellation, same-operation recovery and current original-work readback. Validation uses disposable File/SQLite owners and native Codex tools with deterministic local inference. Complete installed migration, automatic active-work drain, portable privacy and configuration/credential rebinding remain unqualified. Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 10 ++ .../goal-transfer-settings.tsx | 30 +++- docs/reference/research-checkpoint.md | 23 +++ .../cli_commands/support_control_transfer.py | 36 ++-- .../planned_transfer_authority.ts | 7 +- .../control_plane/effect_runtime_handlers.ts | 3 + .../goals/planned_transfer_cutover.ts | 87 +++++++++- .../goals/planned_transfer_io.py | 85 +++++++++- .../goals/planned_transfer_payload.ts | 12 +- loopx/presentation/goal_transfer_api.py | 9 +- .../test_planned_transfer_continuation.py | 22 ++- .../test_planned_transfer_product.py | 159 ++++++++++++++++++ 12 files changed, 446 insertions(+), 37 deletions(-) create mode 100644 tests/control_plane/test_planned_transfer_product.py diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 17c34ca9d8..ef9225c53d 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -46,6 +46,8 @@ const goalTransferViewSchema = z.object({ ok: z.literal(true), goal_id: z.string(), source_state: z.enum(["available", "prepared", "cutover_recorded"]), operation_id: z.string().nullable(), cancel_available: z.boolean(), continuation_available: z.literal(false), local_cutover: z.object({source_final: z.literal(true), target_task_admitted: z.boolean(), target_runtime_admitted: z.boolean().optional()}).optional(), + prepared_cutover: z.object({operation_id: z.string(), preview_sha256: z.string(), destination_name: z.string()}).optional(), + prepared_cutover_unavailable: z.boolean().optional(), work: z.object({objective: z.string(), agents: z.array(z.string()), todos: z.array(z.object({todo_id: z.string(), text: z.string(), status: z.string(), note: z.string().nullish(), evidence: z.string().nullish()}))}), @@ -66,6 +68,14 @@ export async function cancelGoalTransfer(goalId: string, operationId: string) { body: JSON.stringify({action: "cancel", goal_id: goalId, operation_id: operationId})})); } +export async function confirmGoalTransfer(goalId: string, review: NonNullable) { + return z.object({ok: z.literal(true), recorded: z.literal(true), operation_id: z.string(), + readback: goalTransferViewSchema.nullable()}).parse(await requestJson("/api/chat/goal-transfer", { + method: "POST", body: JSON.stringify({action: "confirm", goal_id: goalId, + operation_id: review.operation_id, preview_sha256: review.preview_sha256}), + })); +} + function chatApiUrl(path: string) { if (!configuredChatOrigin || /^https?:\/\//.test(path)) { return path; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx index 0fe2c9463b..9b741a20ef 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx @@ -1,10 +1,10 @@ import {useRef, useState} from "react"; -import {cancelGoalTransfer, inspectGoalTransfer, type GoalTransferView} from "../../data/chat"; +import {cancelGoalTransfer, confirmGoalTransfer, inspectGoalTransfer, type GoalTransferView} from "../../data/chat"; import {useWorkspaceI18n} from "./i18n"; import "./goal-transfer-settings.css"; -/** Source inspection/recovery is a partial step of the same transfer journey. - * There is intentionally no cutover control before destination admission exists. */ +/** The Agent prepares the existing local owners; this entry confirms only the + * retained exact review and reads actual destination work. Full journey held. */ export function GoalTransferSettings({goalId}: {goalId: string}) { const {locale} = useWorkspaceI18n(); const zh = locale === "zh-CN"; @@ -12,6 +12,7 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [cancelled, setCancelled] = useState(null); + const [cutoverRecorded, setCutoverRecorded] = useState(false); const inFlight = useRef(false); const holds: Record = { retained_leases: zh ? "仍有未释放的任务租约,需由原任务结算。" : "Original tasks still hold unreleased leases.", @@ -21,11 +22,17 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { transfer_journey_incomplete: zh ? "此处可读取本地切换、原任务状态和证据引用;完整迁移流程与安装环境验证仍在开发中。" : "Local cutover, original-task status and evidence references can be read here. The complete migration flow and installed-environment validation are still in development.", }; - async function perform(cancel = false) { + async function perform(action: "read" | "cancel" | "confirm" = "read") { if (inFlight.current) return; inFlight.current = true; setBusy(true); setError(""); setView(null); try { - if (cancel && view?.operation_id) { + if (action === "confirm" && view?.prepared_cutover) { + const result = await confirmGoalTransfer(goalId, view.prepared_cutover); + setCutoverRecorded(true); setView(result.readback); + if (!result.readback) setError(zh ? "切换已接受,请读取当前状态以恢复结果。" : "The switch was accepted. Read current status to recover the result."); + return; + } + if (action === "cancel" && view?.operation_id) { const result = await cancelGoalTransfer(goalId, view.operation_id); setCancelled(result.readback.original_writer_fence_restored); // A later read failure must not describe a recorded cancellation as @@ -46,6 +53,7 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { {cancelled !== null ?

{cancelled ? (zh ? "准备已取消,源环境的原写入边界已恢复。" : "Preparation cancelled. The original source write boundary is restored.") : (zh ? "取消决定已记录;源环境已有后续状态,请读取当前状态。" : "Cancellation is recorded. The source has later state; read its current status.")}

: null} + {cutoverRecorded ?

{zh ? "本次切换已接受,源环境保持停写。" : "This switch was accepted. Source writes remain closed."}

: null} {view ? <>

{view.local_cutover ? (view.local_cutover.target_runtime_admitted ? (zh ? "目标环境已取得新的本地执行准入,可用审阅过的上下文继续原任务。下方为当前目标结果;完整迁移流程仍待验证。" : "The destination has new local execution admission and can continue the original work with reviewed context. Current destination results appear below; the complete migration flow still needs qualification.") @@ -57,6 +65,9 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { : (zh ? "源环境可供检查;尚未切换到目标环境。" : "The source is available for inspection. No destination cutover has occurred.")}

{view.work.objective}

    {view.holds.map(hold =>
  • {holds[hold.code]}
  • )}
+ {view.prepared_cutover_unavailable ?

{zh + ? "已准备目标的当前状态无法确认。可重新读取;尚未切换时也可取消准备,恢复源环境。" + : "The prepared destination could not be verified. Read current status again, or cancel preparation if the switch has not happened."}

: null}
{zh ? "原工作" : "Original work"} · {view.work.todos.length} {view.work.agents.length ?

{view.work.agents.join(" · ")}

: null} @@ -67,9 +78,16 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { {todo.evidence ?

{zh ? "证据引用:" : "Evidence reference: "}{todo.evidence}

: null} )}
+ {view.prepared_cutover ? <> +

{zh ? "已准备的本地目标:" : "Prepared local destination: "}{view.prepared_cutover.destination_name}

+

{zh ? "确认后,由目标环境取得新的执行准入并继续上面的原工作。" : "Confirm to admit the destination for new execution and continue the original work above."}

+ + : null} {view.cancel_available && view.operation_id ? <>

{zh ? "取消本次准备,恢复源环境的原写入边界,以便重新领取并继续原任务。" : "Cancel this preparation to restore source writes so the original tasks can be claimed and continued."}

- : null} diff --git a/docs/reference/research-checkpoint.md b/docs/reference/research-checkpoint.md index 3e2996eeec..d14b6903ec 100644 --- a/docs/reference/research-checkpoint.md +++ b/docs/reference/research-checkpoint.md @@ -142,6 +142,29 @@ This is a source-online, same-host development profile; it does not establish cross-machine authorization, portable-artifact privacy certification or the complete installed Settings journey. Those product qualifications remain held. +An Agent can prepare this local composition with one +`goal-transfer prepare-local --goal-id GOAL --operation-id MOVE --destination-root DIRECTORY --material INPUT --execute` +invocation. Repeat `--material` for each explicitly reviewed workspace input. +The existing owners prepare the source, recipient-bound artifact, stopped +destination and retained cutover review. This does not discover every dependency, +certify credential-free input or move capability settings. An unsupported or +unsettled owner keeps its hold; cancellation remains in the existing Settings +entry. Native source writes remain fenced until preparation is cancelled or the +move is confirmed. + +Settings → Capability Center → the original Goal → Move Agent shows the +prepared destination and original work. **Confirm local move** performs the +reviewed cutover; **Resume this move** recovers an already recorded cutover whose +destination admission is incomplete. The browser supplies identifiers and the exact reviewed digest, +never a registry path. The CLI counterpart is +`goal-transfer confirm --goal-id GOAL --operation-id MOVE --expected-preview-sha256 DIGEST --execute`. +Changed materials or destination bindings refuse confirmation. Duplicate +confirmation reads the original decision and preserves destination writes. +An accepted switch followed by missing readback stays visibly accepted and +offers a current-state read. The complete installed migration flow remains +unqualified, including automatic active-work drain and necessary configuration, +dependency and credential rebinding. + For a governed CLI Turn with an explicit Todo and `--turn-instance-id`, retrying that same identity now reads the original journal before rebuilding a live plan. A changed plan hash cannot allocate a second canonical journal for the same diff --git a/loopx/cli_commands/support_control_transfer.py b/loopx/cli_commands/support_control_transfer.py index 6a49af6e95..46e45d823a 100644 --- a/loopx/cli_commands/support_control_transfer.py +++ b/loopx/cli_commands/support_control_transfer.py @@ -12,13 +12,15 @@ inspect_registered_goal_transfer, local_goal_transfer_cutover, read_goal_transfer_host_context, + prepare_local_goal_transfer, + confirm_registered_goal_transfer, ) def register_goal_transfer(subparsers: argparse._SubParsersAction[argparse.ArgumentParser], add_format: Callable[..., Any]) -> None: parser = subparsers.add_parser("goal-transfer", help="Inspect original Agent work, recover preparation, or review a local cutover (development profile).") actions = parser.add_subparsers(dest="goal_transfer_action", required=True) - for action in ("inspect", "cancel", "cutover-preview", "cutover", "context"): + for action in ("inspect", "cancel", "cutover-preview", "cutover", "context", "prepare-local", "confirm"): command = actions.add_parser(action) add_format(command) command.add_argument("--goal-id", required=True) @@ -27,28 +29,36 @@ def register_goal_transfer(subparsers: argparse._SubParsersAction[argparse.Argum if action == "context": command.add_argument("--agent-id", required=True) command.add_argument("--todo-id", required=True) + if action == "prepare-local": + command.add_argument("--destination-root", required=True, type=Path) + command.add_argument("--material", action="append", type=Path, default=[], + help="Agent-reviewed workspace input; does not certify complete dependencies or credential isolation.") if action in ("cutover-preview", "cutover"): command.add_argument("--continuation", choices=("task_only", "reviewed_context"), default=None, help="Review original decision context and new local runtime admission; never copy native sessions or credentials.") command.add_argument("--destination-registry", required=True, type=Path, help="Existing staged destination registry on this POSIX host; not a portable authorization token.") - if action in ("cancel", "cutover"): + if action in ("cancel", "cutover", "prepare-local", "confirm"): command.add_argument("--execute", required=True, action="store_true") - if action == "cutover": + if action in ("cutover", "confirm"): command.add_argument("--expected-preview-sha256", required=True) def render_goal_transfer(payload: dict[str, Any]) -> str: lines = ["# Agent transfer", ""] - for key in ("goal_id", "source_state", "cancel_available", "continuation_available", "preview_sha256", "source_final", "target_task_admitted", "error"): + for key in ("goal_id", "source_state", "cancel_available", "continuation_available", "preview_sha256", "source_final", "target_task_admitted", "recorded", "error"): if key in payload: lines.append(f"- {key}: {payload[key]}") - if "work" in payload: - lines.append(f"- Original work: {payload['work']['objective']}") - lines.extend(f" - {row['text']}" for row in payload["work"]["todos"]) - lines.extend(f"- Hold: {row['code']} ({row['owner']})" for row in payload.get("holds", [])) - if "readback" in payload: - lines.append(f"- Original source restored: {payload['readback']['original_writer_fence_restored']}") + readback = payload.get("readback") + view = readback if payload.get("recorded") is True and isinstance(readback, dict) else payload + if "work" in view: + lines.append(f"- Original work: {view['work']['objective']}") + lines.extend(f" - {row['text']}" for row in view["work"]["todos"]) + lines.extend(f"- Hold: {row['code']} ({row['owner']})" for row in view.get("holds", [])) + if isinstance(readback, dict) and "original_writer_fence_restored" in readback: + lines.append(f"- Original source restored: {readback['original_writer_fence_restored']}") + if payload.get("recorded") is True and readback is None: + lines.append("- Switch accepted; read current status to recover the result.") if "context" in payload: lines.extend(["", "```json", json.dumps(payload["context"], ensure_ascii=False, indent=2), "```"]) return "\n".join(lines) + "\n" @@ -65,6 +75,12 @@ def handle_goal_transfer(args: argparse.Namespace, *, registry_path: Path, elif args.goal_transfer_action == "cancel": payload = {"ok": True, **cancel_registered_goal_transfer(registry_path=registry_path, goal_id=args.goal_id, operation_id=args.operation_id)} + elif args.goal_transfer_action == "prepare-local": + payload = prepare_local_goal_transfer(registry_path=registry_path, goal_id=args.goal_id, + operation_id=args.operation_id, destination_root=args.destination_root, materials=args.material) + elif args.goal_transfer_action == "confirm": + payload = confirm_registered_goal_transfer(registry_path=registry_path, goal_id=args.goal_id, + operation_id=args.operation_id, expected_preview_sha256=args.expected_preview_sha256) else: payload = local_goal_transfer_cutover(registry_path=registry_path, goal_id=args.goal_id, operation_id=args.operation_id, destination_registry=args.destination_registry, diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index 4512f0871c..d82afb149f 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -254,6 +254,8 @@ export async function inspectRegisteredGoalTransfer(value: JsonObject): Promise< if (requireJsonObject(preview.quota_history, "original quota history").closed !== true) holds.push({code: "quota_unsettled", owner: "quota"}); const cutover = preparedOperation === null ? null : await optionalJson(join(shadowManagementDirectory(String(context.runtime_root), String(goal.id)), "planned-transfers", canonicalAuthoritySha256(preparedOperation), "cutover.json")); + const {readLocalGoalTransferCutoverReview} = await import("../goals/planned_transfer_cutover.ts"); + const review = preparedOperation === null ? null : await readLocalGoalTransferCutoverReview(String(context.runtime_root), String(goal.id), preparedOperation); if (cutover) { const {readLocalGoalTransferContinuation} = await import("../goals/planned_transfer_cutover.ts"); const current = await readLocalGoalTransferContinuation(cutover); @@ -262,18 +264,21 @@ export async function inspectRegisteredGoalTransfer(value: JsonObject): Promise< authority_preparation_ready: false, cancel_available: false, continuation_available: false, local_cutover: {source_final: true, target_task_admitted: current.target_task_admitted, target_runtime_admitted: current.target_runtime_admitted}, + ...(review && !current.target_runtime_admitted ? {_local_cutover_review: review} : {}), work: current.work ?? {objective: source.objective, requirements: context.state_text, agents: coordination.registered_agents ?? [], todos: todos.todo_ids.map(id => todos.todos.get(id)!)}, holds: [{code: "transfer_journey_incomplete" satisfies GoalTransferHoldCode, owner: "goal_transfer"}]}; } // Staging still has no lawful destination grant. A green source preview, // a copied Task or a released lease must never erase that product gap. - holds.push({code: "destination_admission_unavailable", owner: "goal_admission"}); + holds.push(review ? {code: "transfer_journey_incomplete", owner: "goal_transfer"} + : {code: "destination_admission_unavailable", owner: "goal_admission"}); return {ok: true, schema_version: "loopx_planned_transfer_operation_view_v0", goal_id: goal.id, source_state: preparedOperation === null ? "available" : "prepared", operation_id: preparedOperation, source_provider: preview.provider, source_revision: preview.provider_revision, authority_preparation_ready: preview.ready_for_authority_preparation, cancel_available: preparedOperation !== null, continuation_available: false, + ...(review ? {_local_cutover_review: review} : {}), work: {objective: source.objective, requirements: context.state_text, agents: coordination.registered_agents ?? [], todos: todos.todo_ids.map(id => todos.todos.get(id)!)}, holds}; }); diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 6b5f89f0b5..954e79aa0b 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -432,6 +432,9 @@ export function createEffectRuntimeHandlers( ["coordination.planned_transfer.inspect_target_history", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({inspectStagedGoalTransferHistory}) => inspectStagedGoalTransferHistory)], ["coordination.planned_transfer.read_target_runs", lazyHandler(() => import("./coordination/planned_transfer_target.ts"), ({readStagedGoalTransferRuns}) => readStagedGoalTransferRuns)], ["coordination.planned_transfer.plan_local_cutover", lazyHandler(() => import("./goals/planned_transfer_cutover.ts"), ({planLocalGoalTransferCutover}) => planLocalGoalTransferCutover)], + ["coordination.planned_transfer.plan_local_preparation", lazyHandler(() => import("./goals/planned_transfer_cutover.ts"), ({planLocalGoalTransferPreparation}) => planLocalGoalTransferPreparation)], + ["coordination.planned_transfer.retain_local_cutover_review", lazyHandler(() => import("./goals/planned_transfer_cutover.ts"), ({retainLocalGoalTransferCutoverReview}) => retainLocalGoalTransferCutoverReview)], + ["coordination.planned_transfer.read_local_cutover_review", lazyHandler(() => import("./goals/planned_transfer_cutover.ts"), ({readRegisteredLocalCutoverReview}) => readRegisteredLocalCutoverReview)], ["coordination.planned_transfer.finish_local_cutover", lazyHandler(() => import("./goals/planned_transfer_cutover.ts"), ({finishLocalGoalTransferCutover}) => finishLocalGoalTransferCutover)], ["coordination.planned_transfer.host_context", lazyHandler(() => import("./goals/planned_transfer_continuation.ts"), ({readGoalTransferHostContext}) => readGoalTransferHostContext)], ["configuration.backup", lazyHandler(() => import("./configuration_backup.ts"), ({configurationBackupOperation}) => configurationBackupOperation)], diff --git a/loopx/control_plane/goals/planned_transfer_cutover.ts b/loopx/control_plane/goals/planned_transfer_cutover.ts index 6a07b8841c..51f1b7f747 100644 --- a/loopx/control_plane/goals/planned_transfer_cutover.ts +++ b/loopx/control_plane/goals/planned_transfer_cutover.ts @@ -6,8 +6,8 @@ * Neither profile copies a session or credential. This is not cross-machine * authorization or qualification of the complete installed user journey. */ -import {join, resolve} from "node:path"; -import {stat} from "node:fs/promises"; +import {basename, dirname, isAbsolute, join, relative, resolve, sep} from "node:path"; +import {readdir, stat} from "node:fs/promises"; import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; import {durableWriteJson} from "../effect_runtime_io.ts"; @@ -27,10 +27,17 @@ import {requirePhysicalTransferPath, inspectTransferFile} from "./planned_transf import {readGoalTransferSnapshot, withVerifiedGoalTransferContext} from "./planned_transfer.ts"; import {planReviewedTransferContinuation, readReviewedTransferContext} from "./planned_transfer_continuation.ts"; import {observeLocalWorkspaceHostIdentity} from "../work_items/task_lease_workspace.ts"; +import {reviewGoalTransferMaterialInputs} from "./planned_transfer_payload.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; const CUTOVER_SCHEMA = "loopx_local_goal_transfer_cutover_v0"; const ADMISSION_SCHEMA = "loopx_local_goal_transfer_task_admission_v0"; const RUNTIME_ADMISSION_SCHEMA = "loopx_local_goal_transfer_runtime_admission_v0"; +const REVIEW_SCHEMA = "loopx_local_goal_transfer_review_v0"; +function confirmation(decision: JsonObject, digest: unknown): JsonObject { + return {operation_id: decision.operation_id, preview_sha256: digest, + destination_name: basename(dirname(String(requireJsonObject(decision.target, "original destination").project)))}; +} function reject(message: string): never {throw new EffectRuntimeConflictError(message);} function same(a: unknown, b: unknown): boolean {return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b));} function operationDirectory(root: string, goal: string, operation: string, source: boolean): string { @@ -52,6 +59,74 @@ async function locationIdentity(root: string): Promise { return canonicalAuthoritySha256({device: entry.dev, inode: entry.ino}); } +/** One Agent-facing preparation composes existing owners. Paths and provider + * come from this Goal owner; the Python caller only performs their codec IO. + * No credentials, native sessions or optional capability settings are adopted. */ +export async function planLocalGoalTransferPreparation(value: JsonObject): Promise { + const rawMaterials = value.materials; + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id", "source_context", "registry_witness", "destination_root", "materials"]) || + !Array.isArray(rawMaterials) || process.platform === "win32") reject("Local preparation requires its explicit original Goal, destination and reviewed materials"); + return visitRegisteredTransferSource(value, requireAuthorityStoreId(value.operation_id, "original operation"), async source => { + if (source.preview.ready_for_authority_preparation !== true) reject("Original work is not settled; use its existing Task/Turn owners before preparing this move"); + const destination = await requirePhysicalTransferPath(value.destination_root); + const goal = requireJsonObject(source.context.goal, "original Goal"); + const overlaps = (a: string, b: string) => { + const suffix = relative(a, b); + return suffix === "" || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)); + }; + if ([String(goal.repo), String(source.context.runtime_root)].some(path => overlaps(path, destination) || overlaps(destination, path))) { + reject("Local destination must not overlap the original project or runtime"); + } + let entries: string[] = []; + try {entries = await readdir(destination);} catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} + if (entries.some(name => !["recipient", "portable", "workspace", "runtime"].includes(name))) reject("Local destination is occupied; preserve it and choose another directory"); + const materials: JsonObject[] = []; + for (const path of rawMaterials) { + const physical = await requirePhysicalTransferPath(path); + materials.push({path: physical, sha256: (await inspectTransferFile(physical)).sha256}); + } + await reviewGoalTransferMaterialInputs(materials, source.context); + return {source_preview_sha256: source.preview.preview_sha256, provider: source.preview.provider, materials, + project: join(destination, "workspace"), runtime_root: join(destination, "runtime"), + recipient_directory: join(destination, "recipient"), artifact_directory: join(destination, "portable")}; + }); +} + +/** A retained review is only an address and an exact reviewed digest. It lets + * the original Settings entry discover a prepared destination without accepting + * a browser-supplied filesystem route. Every confirmation rechecks real owners. */ +export async function readLocalGoalTransferCutoverReview(root: string, goal: string, operation: string): Promise { + const review = await optional(join(operationDirectory(root, goal, operation, true), "cutover-review.json")); + if (review && (!hasExactAuthorityKeys(review, ["schema_version", "goal_id", "operation_id", "target_registry_path", "preview_sha256", "destination_name"]) || + review.schema_version !== REVIEW_SCHEMA || review.goal_id !== goal || review.operation_id !== operation || + !BARE_SHA256_PATTERN.test(String(review.preview_sha256)) || typeof review.destination_name !== "string")) reject("Original local cutover review is unavailable; preserve its operation"); + return review; +} + +export async function readRegisteredLocalCutoverReview(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id", "source_context", "registry_witness"])) reject("Read the original registered cutover review"); + return visitRegisteredTransferSource(value, requireAuthorityStoreId(value.operation_id, "original operation"), async source => { + const review = await readLocalGoalTransferCutoverReview(String(source.context.runtime_root), String(value.goal_id), String(value.operation_id)); + if (!review || source.preparedOperation !== value.operation_id) reject("Original local cutover has no retained review"); + return review; + }); +} + +export async function retainLocalGoalTransferCutoverReview(value: JsonObject): Promise { + const plan = await planLocalGoalTransferCutover(value); + const decision = requireJsonObject(plan.decision, "reviewed original cutover"), target = requireJsonObject(decision.target, "reviewed destination"); + if (decision.profile !== "source_online_local_runtime_v0") reject("Product confirmation requires reviewed original-work continuation"); + const source = requireJsonObject(value.source, "original source"); + return visitRegisteredTransferSource(source, String(decision.operation_id), async current => { + if (current.preparedOperation !== decision.operation_id) reject("Original source is no longer prepared for this move"); + const review = {schema_version: REVIEW_SCHEMA, goal_id: decision.goal_id, operation_id: decision.operation_id, + target_registry_path: target.registry_path, preview_sha256: plan.preview_sha256, + destination_name: basename(dirname(String(target.project)))}; + await retain(join(operationDirectory(String(source.runtime_root), String(decision.goal_id), String(decision.operation_id), true), "cutover-review.json"), review); + return {operation_id: review.operation_id, preview_sha256: review.preview_sha256, destination_name: review.destination_name}; + }); +} + /** A positive, explicitly narrow owner profile, not absence inferred from an * empty backup. Unknown Goal/adapter/config fields require their actual owner. * Source sessions and credentials remain historical; none is auto-installed. @@ -182,7 +257,9 @@ export async function readLocalGoalTransferContinuation(decision: JsonObject): P export async function planLocalGoalTransferCutover(value: JsonObject): Promise { const execute = value.execute === true; if (!hasExactAuthorityKeys(value, ["source", "target_snapshot", "target_registry", - ...(Object.hasOwn(value, "continuation") ? ["continuation"] : []), ...(execute ? ["execute", "expected_preview_sha256"] : [])])) reject("Local cutover requires complete current source and target observations"); + ...(Object.hasOwn(value, "continuation") ? ["continuation"] : []), + ...(Object.hasOwn(value, "reviewed_preview_sha256") ? ["reviewed_preview_sha256"] : []), + ...(execute ? ["execute", "expected_preview_sha256"] : [])])) reject("Local cutover requires complete current source and target observations"); const source = requireJsonObject(value.source, "original source request"); if (!hasExactAuthorityKeys(source, ["runtime_root", "goal_id", "operation_id", "source_context", "registry_witness"])) reject("Local cutover requires its original registered source"); const root = requireLocalAuthorityRuntimeRoot(source.runtime_root), goal = requireAuthorityStoreId(source.goal_id, "original Goal"); @@ -213,12 +290,14 @@ export async function planLocalGoalTransferCutover(value: JsonObject): Promise row.id === goal ? intent.goal : row)}}; @@ -282,6 +361,7 @@ export async function planLocalGoalTransferCutover(value: JsonObject): Promise row.id === goal ? admittedGoal : row)}}; })); }); diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index 8305af285a..876996c45d 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -96,6 +96,19 @@ def inspect_registered_goal_transfer(*, registry_path: Path, goal_id: str) -> di result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.inspect_operation", { "runtime_root": context["source_context"]["runtime_root"], "goal_id": goal_id, **context, }, timeout=60.0, retry_safe=True) + review = result.pop("_local_cutover_review", None) + if review is not None: + try: + preview = local_goal_transfer_cutover(registry_path=registry_path, goal_id=goal_id, + operation_id=review["operation_id"], destination_registry=Path(review["target_registry_path"]), + continuation="reviewed_context", reviewed_preview_sha256=review["preview_sha256"]) + except Exception: + # Only this read-only destination observation failed. Preserve the + # native source view/cancellation, including after a browser reload; + # no cached review becomes a confirmation or an execution grant. + result["prepared_cutover_unavailable"] = True + else: + result["prepared_cutover"] = preview["confirmation"] return result @@ -109,6 +122,7 @@ def cancel_registered_goal_transfer(*, registry_path: Path, goal_id: str, operat def local_goal_transfer_cutover( *, registry_path: Path, goal_id: str, operation_id: str, destination_registry: Path, expected_preview_sha256: str | None = None, continuation: str | None = None, + reviewed_preview_sha256: str | None = None, ) -> dict[str, Any]: """One source-online local cutover; native owners decide every transition. @@ -116,14 +130,10 @@ def local_goal_transfer_cutover( readable through their actual codec. A final source decision has no reverse compensation; same-operation recovery preserves the stopped destination. """ - context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) + request, snapshot, before = _local_cutover_request(registry_path, goal_id, operation_id, destination_registry, continuation) destination_registry = destination_registry.expanduser().absolute() - snapshot = _read_goal_transfer_snapshot(registry_path=destination_registry, goal_id=goal_id) - before = load_registry(destination_registry) - request: dict[str, Any] = {"source": {**context, "runtime_root": context["source_context"]["runtime_root"], - "goal_id": goal_id, "operation_id": operation_id}, "target_snapshot": snapshot, "target_registry": before} - if continuation is not None: - request["continuation"] = continuation + if reviewed_preview_sha256 is not None: + request["reviewed_preview_sha256"] = reviewed_preview_sha256 if expected_preview_sha256 is not None: request.update(execute=True, expected_preview_sha256=expected_preview_sha256) result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.plan_local_cutover", request, @@ -144,6 +154,67 @@ def local_goal_transfer_cutover( return {"ok": True, **result, **admitted, "registry_readback_verified": True} +def _local_cutover_request(registry_path: Path, goal_id: str, operation_id: str, + destination_registry: Path, continuation: str | None) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + destination_registry = destination_registry.expanduser().absolute() + context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) + snapshot = _read_goal_transfer_snapshot(registry_path=destination_registry, goal_id=goal_id) + before = load_registry(destination_registry) + request: dict[str, Any] = {"source": {**context, "runtime_root": context["source_context"]["runtime_root"], + "goal_id": goal_id, "operation_id": operation_id}, "target_snapshot": snapshot, "target_registry": before} + if continuation is not None: + request["continuation"] = continuation + return request, snapshot, before + + +def prepare_local_goal_transfer(*, registry_path: Path, goal_id: str, operation_id: str, + destination_root: Path, materials: list[Path]) -> dict[str, Any]: + """Fixed IO composition of existing Goal, custody and registry owners. + + The Agent supplies an explicit reviewed file selection. TypeScript chooses + paths/provider and every business transition; this adds no Python policy. + Preparation stops the source but never performs the final confirmation. + """ + context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) + plan = effect_runtime_result("coordination.planned_transfer.plan_local_preparation", { + **context, "runtime_root": context["source_context"]["runtime_root"], "goal_id": goal_id, + "operation_id": operation_id, "destination_root": str(destination_root.expanduser().absolute()), + "materials": [str(path.expanduser().absolute()) for path in materials], + }, timeout=60.0, retry_safe=True) + recipient = create_planned_transfer_recipient(destination=Path(plan["recipient_directory"])) + exported = export_registered_goal_transfer(registry_path=registry_path, goal_id=goal_id, operation_id=operation_id, + expected_preview_sha256=plan["source_preview_sha256"], recipient=recipient, destination=Path(plan["artifact_directory"]), + materials=[(Path(row["path"]), row["sha256"]) for row in plan["materials"]]) + target = dict(directory=Path(plan["artifact_directory"]), private_key=Path(plan["recipient_directory"]) / "private-key.pem", + expected_artifact_sha256=exported["protected_artifact"]["artifact_sha256"], + project=Path(plan["project"]), runtime_root=Path(plan["runtime_root"]), provider=plan["provider"]) + workspace = preview_protected_goal_transfer_target(**target) + stage_protected_goal_transfer(**target, expected_workspace_preview_sha256=workspace["reviewed_preview_sha256"]) + request, _, _ = _local_cutover_request(registry_path, goal_id, operation_id, + Path(plan["project"]) / ".loopx/registry.json", "reviewed_context") + effect_runtime_result("coordination.planned_transfer.retain_local_cutover_review", request, + timeout=300.0, retry_safe=False) + return inspect_registered_goal_transfer(registry_path=registry_path, goal_id=goal_id) + + +def confirm_registered_goal_transfer(*, registry_path: Path, goal_id: str, operation_id: str, + expected_preview_sha256: str) -> dict[str, Any]: + """Confirm/recover the retained original review, never a browser path.""" + context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) + review = effect_runtime_result("coordination.planned_transfer.read_local_cutover_review", { + **context, "runtime_root": context["source_context"]["runtime_root"], "goal_id": goal_id, "operation_id": operation_id, + }, timeout=60.0, retry_safe=True) + local_goal_transfer_cutover(registry_path=registry_path, goal_id=goal_id, operation_id=operation_id, + destination_registry=Path(review["target_registry_path"]), continuation="reviewed_context", + expected_preview_sha256=expected_preview_sha256, reviewed_preview_sha256=review["preview_sha256"]) + # A lost later read cannot turn the already accepted cutover into failure. + try: + readback = inspect_registered_goal_transfer(registry_path=registry_path, goal_id=goal_id) + except Exception: + readback = None + return {"ok": True, "goal_id": goal_id, "recorded": True, "operation_id": operation_id, "readback": readback} + + def read_goal_transfer_host_context(*, registry_path: Path, goal_id: str, agent_id: str, todo_id: str) -> dict[str, Any]: """Read the current Goal owner's context; this IO adapter grants nothing.""" diff --git a/loopx/control_plane/goals/planned_transfer_payload.ts b/loopx/control_plane/goals/planned_transfer_payload.ts index 1a3b3d9993..43379dfc89 100644 --- a/loopx/control_plane/goals/planned_transfer_payload.ts +++ b/loopx/control_plane/goals/planned_transfer_payload.ts @@ -43,12 +43,20 @@ export async function reviewProtectedGoalTransferInputs(raw: unknown, context: J } const recipient = validatePlannedTransferRecipient(transport.recipient); const destination = await requirePhysicalTransferPath(transport.destination), sourceVersion = version(packageVersion); + return {recipient, destination, sourceVersion, ...await reviewGoalTransferMaterialInputs(transport.materials, context)}; +} + +/** The Agent's explicit workspace selection is checked before preparation and + * again under the source owner's export locks. This is not a credential scan + * or a claim that every dependency has been selected. */ +export async function reviewGoalTransferMaterialInputs(raw: unknown, context: JsonObject) { + if (!Array.isArray(raw)) reject("Review an explicit workspace material selection"); const goal = requireJsonObject(context.goal, "original Goal"); const project = await requirePhysicalTransferPath(goal.repo), runtime = await requirePhysicalTransferPath(context.runtime_root); const registry = await requirePhysicalTransferPath(context.registry_path); const files: TransferContainerFile[] = []; const materials: JsonObject[] = [], seen = new Set(); - for (const [index, rawMaterial] of transport.materials.entries()) { + for (const [index, rawMaterial] of raw.entries()) { const material = requireJsonObject(rawMaterial, "reviewed Goal material"); if (!hasExactAuthorityKeys(material, ["path", "sha256"]) || !BARE_SHA256_PATTERN.test(String(material.sha256))) reject("Goal material requires its reviewed content digest"); const path = await requirePhysicalTransferPath(material.path), suffix = relative(project, path); @@ -61,7 +69,7 @@ export async function reviewProtectedGoalTransferInputs(raw: unknown, context: J materials.push({id: (index + 2).toString(16).padStart(8, "0"), source_relative_path: suffix, ...facts}); } - return {recipient, destination, sourceVersion, files, materials}; + return {files, materials}; } export async function writeProtectedGoalTransfer(reviewed: Awaited>, diff --git a/loopx/presentation/goal_transfer_api.py b/loopx/presentation/goal_transfer_api.py index 480057bd91..cf84e713ea 100644 --- a/loopx/presentation/goal_transfer_api.py +++ b/loopx/presentation/goal_transfer_api.py @@ -6,6 +6,7 @@ from ..control_plane.goals.planned_transfer_io import ( cancel_registered_goal_transfer, inspect_registered_goal_transfer, + confirm_registered_goal_transfer, ) GOAL_TRANSFER_PATH = "/api/chat/goal-transfer" @@ -23,8 +24,9 @@ def _goal_transfer(self) -> None: try: body = self._read_json() action = body.get("action") - fields = {"action", "goal_id", "operation_id"} if action == "cancel" else {"action", "goal_id"} - if set(body) != fields or action not in ("inspect", "cancel"): + fields = {"action", "goal_id", "operation_id", "preview_sha256"} if action == "confirm" else ( + {"action", "goal_id", "operation_id"} if action == "cancel" else {"action", "goal_id"}) + if set(body) != fields or action not in ("inspect", "cancel", "confirm"): raise ValueError("Unsupported transfer request") if any(not isinstance(value, str) or not value.strip() for value in body.values()): raise ValueError("Transfer request requires original identifiers") @@ -32,6 +34,9 @@ def _goal_transfer(self) -> None: # registry codec. Browser data cannot redirect filesystem owners. if action == "inspect": result = inspect_registered_goal_transfer(registry_path=self.server.registry_path, goal_id=body["goal_id"]) + elif action == "confirm": + result = confirm_registered_goal_transfer(registry_path=self.server.registry_path, goal_id=body["goal_id"], + operation_id=body["operation_id"], expected_preview_sha256=body["preview_sha256"]) else: result = {"ok": True, **cancel_registered_goal_transfer(registry_path=self.server.registry_path, goal_id=body["goal_id"], operation_id=body["operation_id"])} diff --git a/tests/control_plane/test_planned_transfer_continuation.py b/tests/control_plane/test_planned_transfer_continuation.py index c7c3217d04..047934f724 100644 --- a/tests/control_plane/test_planned_transfer_continuation.py +++ b/tests/control_plane/test_planned_transfer_continuation.py @@ -9,6 +9,7 @@ import os import shlex import sys +from time import perf_counter from pathlib import Path import pytest @@ -21,6 +22,7 @@ from tests.control_plane.test_planned_transfer_cancellation import source as source_fixture from tests.control_plane.test_planned_transfer_cutover import OPERATION, _staged, _target_worktree from tests.control_plane.test_planned_transfer_operation import _http +from tests.control_plane.test_planned_transfer_product import _prepared source = source_fixture @@ -58,12 +60,18 @@ def test_real_fresh_native_turn_finishes_original_work_and_recovers_without_reex "--agent-id", "agent-a", "--evidence", "input.txt", "--successor-todo-id", source["todo"]) source["cli"]("todo", "archive-completed", "--goal-id", "report", "--role", "user", "--max-active-done", "0", "--execute") original = source["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] - target = _staged(source, tmp_path, source["provider"]) - args = ("--goal-id", "report", "--operation-id", OPERATION, "--destination-registry", str(target["registry"]), - "--continuation", "reviewed_context") - preview = source["cli"]("goal-transfer", "cutover-preview", *args) - confirmed = source["cli"]("goal-transfer", "cutover", *args, "--expected-preview-sha256", preview["preview_sha256"], "--execute") - assert confirmed["target_runtime_admitted"] is True + started = perf_counter() + target, prepared = _prepared(source, tmp_path) + server, thread, call = _http(source) + try: + review = prepared["prepared_cutover"] + status, confirmed = call({"action": "confirm", "goal_id": "report", "operation_id": OPERATION, + "preview_sha256": review["preview_sha256"]}) + assert status == 200 and confirmed["readback"]["local_cutover"]["target_runtime_admitted"] is True + finally: + server.shutdown() + thread.join(5) + server.server_close() worktree = _target_worktree(target) lease = target["cli"]("task-lease", "acquire", "--goal-id", "report", "--todo-id", source["todo"], "--owner", "agent-a", "--idempotency-key", "new-target-native-work", "--write-scope", "report.txt", @@ -151,6 +159,8 @@ def response(body, number, message): thread.join(5) server.server_close() assert not source["runtime"].exists() and not source["project"].exists() + (tmp_path / "product-journey-timing.json").write_text(json.dumps({"seconds": perf_counter() - started, + "boundary": "Source-checkout isolated native tools and one real HTTP confirmation; not installed App or a controlled latency qualification."})) @pytest.mark.parametrize("fault", ["context", "materials", "mode"]) diff --git a/tests/control_plane/test_planned_transfer_product.py b/tests/control_plane/test_planned_transfer_product.py new file mode 100644 index 0000000000..1f2f4ee566 --- /dev/null +++ b/tests/control_plane/test_planned_transfer_product.py @@ -0,0 +1,159 @@ +"""Agent preparation and one product confirmation use the original owners. + +These disposable local profiles retain the full installed-journey hold. No +active Goal, paid model, imported session or copied credential is involved. +""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from loopx.control_plane.goals import planned_transfer_io +from loopx.control_plane.effect_runtime import restart_effect_runtime +from tests.control_plane.test_planned_transfer_cancellation import source as source_fixture +from tests.control_plane.test_planned_transfer_cutover import OPERATION +from tests.control_plane.test_planned_transfer_operation import _http + +source = source_fixture + + +def _prepared(source: dict, root: Path) -> tuple[dict, dict]: + destination = root / "local-move" + view = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", "--operation-id", OPERATION, + "--destination-root", str(destination), "--material", str(source["project"] / "input.txt"), "--execute") + project, runtime = destination / "workspace", destination / "runtime" + registry = project / ".loopx/registry.json" + + def cli(*arguments: str, expected_code: int = 0): + result = subprocess.run([sys.executable, "-I", "-m", "loopx.cli", "--format", "json", "--registry", str(registry), + "--runtime-root", str(runtime), *arguments], cwd=project, text=True, capture_output=True, timeout=90) + assert result.returncode == expected_code, result.stdout + result.stderr + return json.loads(result.stdout) + + return {"project": project, "runtime": runtime, "registry": registry, "cli": cli}, view + + +def test_agent_prepares_then_settings_confirms_original_operation(source: dict, tmp_path: Path): + original = source["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] + target, prepared = _prepared(source, tmp_path) + review = prepared["prepared_cutover"] + assert review["operation_id"] == OPERATION and review["destination_name"] == "local-move" + assert set(review) == {"operation_id", "preview_sha256", "destination_name"} + assert prepared["continuation_available"] is False # Not full installed qualification. + assert json.loads(target["registry"].read_text())["goals"][0]["activation"]["state"] == "stopped" + restarted, repeated = _prepared(source, tmp_path) + assert restarted["registry"] == target["registry"] and repeated == prepared + restart_effect_runtime(wait_for_exit=True) + server, thread, call = _http(source) + try: + assert call({"action": "inspect", "goal_id": "report"}) == (200, prepared) + body = {"action": "confirm", "goal_id": "report", "operation_id": OPERATION, + "preview_sha256": review["preview_sha256"]} + assert call({**body, "destination_registry": str(tmp_path / "forged")})[0] == 400 + assert call({**body, "preview_sha256": "0" * 64})[0] == 400 + assert json.loads(target["registry"].read_text())["goals"][0]["activation"]["state"] == "stopped" + status, confirmed = call(body) + assert status == 200 and confirmed["recorded"] is True + assert confirmed["readback"]["local_cutover"]["target_runtime_admitted"] is True + assert "prepared_cutover" not in confirmed["readback"] + registry = target["registry"].read_bytes() + assert call(body) == (200, confirmed) + assert target["registry"].read_bytes() == registry + assert call({"action": "cancel", "goal_id": "report", "operation_id": OPERATION})[0] == 400 + rendered = subprocess.run([sys.executable, "-I", "-m", "loopx.cli", "--registry", str(source["registry"]), + "goal-transfer", "confirm", "--goal-id", "report", "--operation-id", OPERATION, + "--expected-preview-sha256", review["preview_sha256"], "--execute"], text=True, capture_output=True, timeout=60) + assert rendered.returncode == 0 and "recorded: True" in rendered.stdout and "Original work:" in rendered.stdout + finally: + server.shutdown() + thread.join(5) + server.server_close() + assert target["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] == original + assert not (tmp_path / "forged").exists() + + +def test_changed_destination_refuses_product_confirmation_and_keeps_cancel(source: dict, tmp_path: Path): + target, prepared = _prepared(source, tmp_path) + (target["project"] / "input.txt").write_text("Changed after review\n") + before = target["registry"].read_bytes() + server, thread, call = _http(source) + try: + body = {"action": "confirm", "goal_id": "report", "operation_id": OPERATION, + "preview_sha256": prepared["prepared_cutover"]["preview_sha256"]} + assert call(body)[0] == 400 + assert target["registry"].read_bytes() == before + restart_effect_runtime(wait_for_exit=True) + status, current = call({"action": "inspect", "goal_id": "report"}) + assert status == 200 and current["cancel_available"] is True and current["prepared_cutover_unavailable"] is True + assert "prepared_cutover" not in current + assert call({"action": "cancel", "goal_id": "report", "operation_id": OPERATION})[0] == 200 + assert call({"action": "inspect", "goal_id": "report"})[1]["source_state"] == "available" + assert source["acquire"]("resume-after-material-refusal")["ok"] is True + finally: + server.shutdown() + thread.join(5) + server.server_close() + + +@pytest.mark.parametrize("fault", ["source_overlap", "missing_material", "unreleased_task"]) +def test_preparation_input_holds_precede_source_fencing(source: dict, tmp_path: Path, fault: str): + destination = tmp_path / "unprepared" + material = source["project"] / "input.txt" + lease = None + if fault == "source_overlap": + destination = source["project"] / "overlap" + elif fault == "missing_material": + material = source["project"] / "missing.txt" + else: + lease = source["acquire"]("still-running-original-work")["lease"] + before = source["fence"].read_bytes(), source["registry"].read_bytes() + failed = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", "--operation-id", OPERATION, + "--destination-root", str(destination), "--material", str(material), "--execute", expected_code=1) + assert failed["ok"] is False and not destination.exists() + assert before == (source["fence"].read_bytes(), source["registry"].read_bytes()) + if lease is not None: + source["release"](lease) + assert _prepared(source, tmp_path)[1]["cancel_available"] is True + + +@pytest.mark.parametrize("fault", ["finish", "readback"]) +def test_same_product_operation_recovers_after_recorded_source_final(source: dict, tmp_path: Path, monkeypatch, fault: str): + target, prepared = _prepared(source, tmp_path) + original_effect = planned_transfer_io.effect_runtime_result + + def interrupted(method, params, **kwargs): + if method == "coordination.planned_transfer.finish_local_cutover" and fault == "finish": + raise OSError("Disconnected before destination admission") + if method == "coordination.planned_transfer.inspect_operation" and fault == "readback": + raise OSError("Disconnected after known accepted switch") + return original_effect(method, params, **kwargs) + + monkeypatch.setattr(planned_transfer_io, "effect_runtime_result", interrupted) + server, thread, call = _http(source) + body = {"action": "confirm", "goal_id": "report", "operation_id": OPERATION, + "preview_sha256": prepared["prepared_cutover"]["preview_sha256"]} + try: + status, result = call(body) + if fault == "finish": + assert status == 400 + else: + assert status == 200 and result["recorded"] is True and result["readback"] is None + original_registry = target["registry"].read_bytes() + monkeypatch.setattr(planned_transfer_io, "effect_runtime_result", original_effect) + restart_effect_runtime(wait_for_exit=True) + current = call({"action": "inspect", "goal_id": "report"})[1] + if fault == "finish": + assert current["local_cutover"]["target_runtime_admitted"] is False + assert current["prepared_cutover"] == prepared["prepared_cutover"] + accepted = call(body) + assert accepted[0] == 200 and accepted[1]["readback"]["local_cutover"]["target_runtime_admitted"] is True + assert call(body) == accepted and target["registry"].read_bytes() == original_registry + assert source["acquire"]("old-source-must-stay-closed", expected_code=1)["ok"] is False + finally: + server.shutdown() + thread.join(5) + server.server_close() From f323dd5fc1cb5da78196dfaae1595c5469f6b1de Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:00:28 +0800 Subject: [PATCH 19/55] fix(migration): keep stopped destination work readable Separate current result observation from execution admission in the existing Goal context owner. Project destination activation so Settings explains a stopped Goal without hiding original work. Keep normal Goal resume, Host checks, registration and context integrity unchanged. Validate File/SQLite original-work continuation through isolated wheel installs and native Codex tools, stopped result readback, default execution refusal, and Task-only and integrity boundaries. The full migration profile remains unqualified. Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 3 ++- .../goal-transfer-settings.tsx | 4 +++- docs/reference/research-checkpoint.md | 5 +++++ .../planned_transfer_authority.ts | 3 ++- .../goals/planned_transfer_continuation.ts | 12 ++++++++--- .../goals/planned_transfer_cutover.ts | 8 ++++--- .../test_planned_transfer_continuation.py | 21 +++++++++++++++++++ 7 files changed, 47 insertions(+), 9 deletions(-) diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index ef9225c53d..b14a528179 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -45,7 +45,8 @@ const goalTransferHoldSchema: z.ZodType = z.enum([ const goalTransferViewSchema = z.object({ ok: z.literal(true), goal_id: z.string(), source_state: z.enum(["available", "prepared", "cutover_recorded"]), operation_id: z.string().nullable(), cancel_available: z.boolean(), continuation_available: z.literal(false), - local_cutover: z.object({source_final: z.literal(true), target_task_admitted: z.boolean(), target_runtime_admitted: z.boolean().optional()}).optional(), + local_cutover: z.object({source_final: z.literal(true), target_task_admitted: z.boolean(), target_runtime_admitted: z.boolean().optional(), + target_activation_state: z.enum(["active", "stopped"]).optional()}).optional(), prepared_cutover: z.object({operation_id: z.string(), preview_sha256: z.string(), destination_name: z.string()}).optional(), prepared_cutover_unavailable: z.boolean().optional(), work: z.object({objective: z.string(), agents: z.array(z.string()), diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx index 9b741a20ef..9754a8ee91 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx @@ -55,7 +55,9 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { : (zh ? "取消决定已记录;源环境已有后续状态,请读取当前状态。" : "Cancellation is recorded. The source has later state; read its current status.")}

: null} {cutoverRecorded ?

{zh ? "本次切换已接受,源环境保持停写。" : "This switch was accepted. Source writes remain closed."}

: null} {view ? <> -

{view.local_cutover ? (view.local_cutover.target_runtime_admitted +

{view.local_cutover ? (view.local_cutover.target_activation_state === "stopped" + ? (zh ? "目标 Goal 已停止,仍可查看原任务结果。恢复该 Goal 后才可继续执行。" : "The destination Goal is stopped. Original work remains readable; resume that Goal before continuing execution.") + : view.local_cutover.target_runtime_admitted ? (zh ? "目标环境已取得新的本地执行准入,可用审阅过的上下文继续原任务。下方为当前目标结果;完整迁移流程仍待验证。" : "The destination has new local execution admission and can continue the original work with reviewed context. Current destination results appear below; the complete migration flow still needs qualification.") : view.local_cutover.target_task_admitted ? (zh ? "源环境已停止写入,目标可重新领取原任务的本地租约。下方为目标任务状态;Host 续办仍待验证。" : "Source writes are closed. The destination can acquire a fresh local lease for the original task. Status below comes from the destination; Host continuation still needs qualification.") diff --git a/docs/reference/research-checkpoint.md b/docs/reference/research-checkpoint.md index d14b6903ec..d54519299d 100644 --- a/docs/reference/research-checkpoint.md +++ b/docs/reference/research-checkpoint.md @@ -165,6 +165,11 @@ offers a current-state read. The complete installed migration flow remains unqualified, including automatic active-work drain and necessary configuration, dependency and credential rebinding. +Stopping the admitted destination Goal keeps original work and results readable +in this entry. New Host/runtime execution remains stopped until the existing +Goal lifecycle owner resumes it; viewing a result does not renew admission. +Ordinary Todo management keeps its existing owner rules. + For a governed CLI Turn with an explicit Todo and `--turn-instance-id`, retrying that same identity now reads the original journal before rebuilding a live plan. A changed plan hash cannot allocate a second canonical journal for the same diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index d82afb149f..b88265da87 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -263,7 +263,8 @@ export async function inspectRegisteredGoalTransfer(value: JsonObject): Promise< source_state: "cutover_recorded", operation_id: preparedOperation, source_provider: preview.provider, source_revision: preview.provider_revision, authority_preparation_ready: false, cancel_available: false, continuation_available: false, local_cutover: {source_final: true, target_task_admitted: current.target_task_admitted, - target_runtime_admitted: current.target_runtime_admitted}, + target_runtime_admitted: current.target_runtime_admitted, + ...(current.target_activation_state ? {target_activation_state: current.target_activation_state} : {})}, ...(review && !current.target_runtime_admitted ? {_local_cutover_review: review} : {}), work: current.work ?? {objective: source.objective, requirements: context.state_text, agents: coordination.registered_agents ?? [], todos: todos.todo_ids.map(id => todos.todos.get(id)!)}, diff --git a/loopx/control_plane/goals/planned_transfer_continuation.ts b/loopx/control_plane/goals/planned_transfer_continuation.ts index 26c4f21076..cd3389c69c 100644 --- a/loopx/control_plane/goals/planned_transfer_continuation.ts +++ b/loopx/control_plane/goals/planned_transfer_continuation.ts @@ -40,7 +40,8 @@ export function planReviewedTransferContinuation(mode: unknown, original: JsonOb * The physical runtime/store binding is checked by the existing cutover owner. * Stopping, revoking or redirecting this Goal immediately stops Host admission; * a harmless display-name change and unrelated Goals do not invalidate it. */ -export async function readReviewedTransferContext(receipt: JsonObject): Promise<{snapshot: JsonObject; context: JsonObject; materials: JsonObject}> { +export async function readReviewedTransferContext(receipt: JsonObject, + purpose: "execution" | "readback" = "execution"): Promise<{snapshot: JsonObject; context: JsonObject; materials: JsonObject}> { const decision = requireJsonObject(receipt.decision, "original cutover decision"); const continuation = requireJsonObject(decision.continuation, "reviewed continuation admission"); const admitted = requireJsonObject(receipt.goal, "admitted Goal"); @@ -50,7 +51,11 @@ export async function readReviewedTransferContext(receipt: JsonObject): Promise< } const snapshot = await readGoalTransferSnapshot(String(receipt.target_registry_path), String(decision.goal_id)); const current = requireJsonObject(requireJsonObject(snapshot.source_context, "current Goal context").goal, "current Goal"); - if (snapshot.registry_profile !== null || snapshot.registry_role === "global-local" || snapshot.activation_state !== "active" || + // Readback observes the existing Goal activation owner; it cannot mint an + // execution grant. Host/runtime and admission retain the strict default. + const activationAllowed = snapshot.activation_state === "active" || + (purpose === "readback" && snapshot.activation_state === "stopped"); + if (snapshot.registry_profile !== null || snapshot.registry_role === "global-local" || !activationAllowed || ["id", "repo", "state_file", "planned_transfer", "goal_instance_id", "creation_operation_id", "status", "domain", "role", "spawn_policy", "coordination", "guards", "execution_profile", "authority_sources"] .some(key => !same(current[key] ?? null, admitted[key] ?? null))) { @@ -122,6 +127,7 @@ export async function inspectAdmittedGoalTransferDestination(value: JsonObject): source_state: "cutover_recorded", operation_id: fence.fence.operation_id, authority_preparation_ready: false, cancel_available: false, continuation_available: false, local_cutover: {source_final: true, target_task_admitted: current.target_task_admitted, - target_runtime_admitted: current.target_runtime_admitted}, work: current.work, + target_runtime_admitted: current.target_runtime_admitted, + ...(current.target_activation_state ? {target_activation_state: current.target_activation_state} : {})}, work: current.work, holds: [{code: "transfer_journey_incomplete", owner: "goal_transfer"}]}; } diff --git a/loopx/control_plane/goals/planned_transfer_cutover.ts b/loopx/control_plane/goals/planned_transfer_cutover.ts index 51f1b7f747..95c45599e6 100644 --- a/loopx/control_plane/goals/planned_transfer_cutover.ts +++ b/loopx/control_plane/goals/planned_transfer_cutover.ts @@ -223,9 +223,10 @@ export async function readLocalGoalTransferContinuation(decision: JsonObject): P return {target_task_admitted: false, target_runtime_admitted: false, work: null}; } const admission = await requireLocalGoalTransferAdmission(root, goal, fence.fence); - if (fence.fence.schema_version === PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA) await readReviewedTransferContext(admission); if (!same(admission.decision, decision)) reject("Original destination admission differs from source-final"); - const snapshot = await readGoalTransferSnapshot(String(target.registry_path), goal); + const snapshot = fence.fence.schema_version === PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA + ? (await readReviewedTransferContext(admission, "readback")).snapshot + : await readGoalTransferSnapshot(String(target.registry_path), goal); const opened = await openLocalAuthorityStoreHandle(root, goal, {}, {existingOnly: true}); const head = await opened.store.loadAuthority(); if (head.status !== "loaded") reject("Original destination work is unavailable"); @@ -245,7 +246,8 @@ export async function readLocalGoalTransferContinuation(decision: JsonObject): P !same(snapshot, await readGoalTransferSnapshot(String(target.registry_path), goal))) { reject("Original destination changed during readback; retry its current state"); } - return {target_task_admitted: true, target_runtime_admitted: fence.fence.schema_version === PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA, work: {objective: snapshot.state_objective, requirements: context.state_text, + return {target_task_admitted: true, target_runtime_admitted: fence.fence.schema_version === PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA, + target_activation_state: snapshot.activation_state, work: {objective: snapshot.state_objective, requirements: context.state_text, agents: coordination.registered_agents, todos: index.todo_ids.map(id => index.todos.get(id)!)}}; } diff --git a/tests/control_plane/test_planned_transfer_continuation.py b/tests/control_plane/test_planned_transfer_continuation.py index 047934f724..ce9e3f79e3 100644 --- a/tests/control_plane/test_planned_transfer_continuation.py +++ b/tests/control_plane/test_planned_transfer_continuation.py @@ -199,9 +199,29 @@ def test_current_binding_or_context_failure_cannot_start_a_host(source: dict, tm "--agent-id", "foreign" if fault == "agent" else "agent-a", "--todo-id", "todo_foreign" if fault == "todo" else source["todo"], expected_code=1) assert failed["ok"] is False + if fault == "context": + assert target["cli"]("goal-transfer", "inspect", "--goal-id", "report", expected_code=1)["ok"] is False + assert source["cli"]("goal-transfer", "inspect", "--goal-id", "report", expected_code=1)["ok"] is False assert not (target["runtime"] / "goals/report/turn-sessions").exists() assert not (target["project"] / "report.txt").exists() if fault == "registration": + # Stopping execution must not hide original work or its result. The + # same current registration/context checks still guard this read. + restart_effect_runtime(wait_for_exit=True) + observed = target["cli"]("goal-transfer", "inspect", "--goal-id", "report") + assert observed["local_cutover"]["target_activation_state"] == "stopped" + assert any(row["todo_id"] == source["todo"] for row in observed["work"]["todos"]) + assert source["cli"]("goal-transfer", "inspect", "--goal-id", "report")["work"] == observed["work"] + server, thread, call = _http(target) + try: + assert call({"action": "inspect", "goal_id": "report"}) == (200, observed) + finally: + server.shutdown() + thread.join(5) + server.server_close() + with pytest.raises((ValueError, EffectRuntimeRemoteError)): + FirstPartyHostGoalAdmission.for_plan(registry_path=target["registry"], goal_id="report", planned_goal_ref=None).require_current() + assert not (target["runtime"] / "goals/report/turn-sessions").exists() # A stop denies Host work but cannot lock the ordinary Goal owner out # of resume. A new explanatory reason is metadata, not a new grant. with project_registry_transaction(target["registry"], operation="resume-current-Goal") as transaction: @@ -209,3 +229,4 @@ def test_current_binding_or_context_failure_cannot_start_a_host(source: dict, tm payload["goals"][0]["activation"].update(state="active", reason="Owner resumed this Goal") transaction.commit(payload) FirstPartyHostGoalAdmission.for_plan(registry_path=target["registry"], goal_id="report", planned_goal_ref=None).require_current() + assert target["cli"]("goal-transfer", "inspect", "--goal-id", "report")["local_cutover"]["target_activation_state"] == "active" From 19a1d5826c98c5b0ddbecf520549133e90cca469 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:28:50 +0800 Subject: [PATCH 20/55] fix(migration): carry required Goal documents before cutover Reuse the original configuration and Task capability profile before source preparation, so unsupported dependencies leave the source available. Discover bootstrap primary Goal documents through the existing protected material and workspace owners and map live references during cutover. Keep historical references intact and expose the current registration to native continuation. Characterize File/SQLite preparation, source-unavailable context, later destination edits and cancellation after document changes. Preserve holds for unsupported settings, credentials, automatic App dispatch and the full migration qualification. Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/research-checkpoint.md | 17 ++++ .../goals/planned_transfer_continuation.ts | 78 ++++++++++++++- .../goals/planned_transfer_cutover.ts | 61 ++++-------- .../test_planned_transfer_continuation.py | 9 +- .../test_planned_transfer_cutover.py | 4 +- .../test_planned_transfer_product.py | 95 +++++++++++++++++++ 6 files changed, 216 insertions(+), 48 deletions(-) diff --git a/docs/reference/research-checkpoint.md b/docs/reference/research-checkpoint.md index d54519299d..da44e8a60f 100644 --- a/docs/reference/research-checkpoint.md +++ b/docs/reference/research-checkpoint.md @@ -152,6 +152,23 @@ unsettled owner keeps its hold; cancellation remains in the existing Settings entry. Native source writes remain fenced until preparation is cancelled or the move is confirmed. +For a normal generic Goal created with `bootstrap --goal-doc`, preparation also +includes its registered primary Goal document. The document must be a physical +workspace file accepted by the existing material owner. It needs no separate +`--material` argument. Cutover maps its live reference to the reviewed destination; +the original context and receipts retain their historical source paths. The +native continuation context supplies `current_goal` for current document reads. +Later destination edits survive retries of the original confirmation. + +The local configuration and required-Task capability checks run before source +preparation as well as at final cutover. Missing, unsafe or unmapped documents, +unsupported capability configuration and required capabilities outside this +profile hold before source writes are fenced or a destination is created. +Configure or rebind those dependencies through their actual owners; this profile +does not silently drop them, copy credentials or adopt optional settings. App +dispatch as the responsible Agent and the complete migration qualification +remain separate work. + Settings → Capability Center → the original Goal → Move Agent shows the prepared destination and original work. **Confirm local move** performs the reviewed cutover; **Resume this move** recovers an already recorded cutover whose diff --git a/loopx/control_plane/goals/planned_transfer_continuation.ts b/loopx/control_plane/goals/planned_transfer_continuation.ts index cd3389c69c..3238505caa 100644 --- a/loopx/control_plane/goals/planned_transfer_continuation.ts +++ b/loopx/control_plane/goals/planned_transfer_continuation.ts @@ -4,7 +4,7 @@ * and current Goal/Task owners; it creates no session, lease or second ledger. * Historical source bindings and receipts are context, never executable proof. */ -import {dirname, join, resolve} from "node:path"; +import {dirname, join, relative, resolve} from "node:path"; import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; import {requireJsonObject} from "../runtime_decode.ts"; @@ -19,6 +19,77 @@ import {readGoalTransferHistoryJson} from "./planned_transfer_history.ts"; function reject(message: string): never {throw new EffectRuntimeConflictError(message);} function same(a: unknown, b: unknown): boolean {return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b));} +/** Reuse bootstrap's primary Goal document vocabulary. Other material/provider + * contracts need their own mapping; never omit them to obtain admission. */ +export function localTransferGoalDocuments(original: JsonObject): JsonObject[] { + if (!Array.isArray(original.authority_sources)) reject("Original Goal sources require their owning continuation profile"); + return original.authority_sources.map(raw => { + const source = requireJsonObject(raw, "original Goal document"); + if (!hasExactAuthorityKeys(source, ["kind", "path", "role"]) || source.kind !== "goal_doc" || + source.role !== "primary_goal_document" || typeof source.path !== "string" || !source.path.trim()) { + reject("Original Goal source requires its owning material mapping before preparation"); + } + return source; + }); +} + +/** The same positive configuration profile runs before preparation and again + * at cutover. Unsupported dependencies must not strand a fenced source. */ +export function localTransferGoalConfiguration(original: JsonObject): JsonObject { + const keys = ["id", "display_name", "domain", "status", "role", "parent_goal_id", "repo", "state_file", + "authority_sources", "adapter", "spawn_policy", "coordination", "execution_profile", "next_probe", "guards", + "creation_operation_id", "activation"]; + if (Object.keys(original).some(key => !keys.includes(key)) || original.parent_goal_id != null) { + reject("Source Goal configuration requires its owning continuation profile"); + } + if (original.activation !== undefined && requireJsonObject(original.activation, "original activation").state !== "active") { + reject("A stopped original Goal requires its lifecycle decision before target activation"); + } + const adapter = requireJsonObject(original.adapter, "source adapter"); + const spawn = requireJsonObject(original.spawn_policy, "source spawn policy"); + const coordination = requireJsonObject(original.coordination, "source coordination"); + if (!hasExactAuthorityKeys(adapter, ["kind", "status"]) || adapter.kind !== "generic_project_goal_v0" || + spawn.allowed !== false || + Object.keys(coordination).some(key => !["write_scope", "requires_parent_approval", "registered_agents", "storage_target", "agent_model"].includes(key)) || + coordination.agent_model !== "peer_v1" || + !Array.isArray(coordination.registered_agents) || coordination.registered_agents.length !== 1 || + coordination.registered_agents.some(agent => typeof agent !== "string") || !Array.isArray(coordination.write_scope)) { + reject("Local Task transfer needs its qualified single-Agent configuration; required materials, capabilities or Host bindings need their original owner"); + } + localTransferGoalDocuments(original); + const {storage_target: _originalCreationTarget, ...currentCoordination} = coordination; + return {domain: original.domain, role: original.role, parent_goal_id: null, + spawn_policy: spawn, coordination: currentCoordination, + ...(original.execution_profile === undefined ? {} : {execution_profile: original.execution_profile}), + ...(original.guards === undefined ? {} : {guards: original.guards})}; +} + +export function requireLocalTransferTaskBindings(original: JsonObject, projection: JsonObject): void { + const coordination = requireJsonObject(original.coordination, "original coordination"); + const supported = new Set(["filesystem_read", "filesystem_write", "shell"]); + for (const todo of indexCoordinationProjection(projection, String(original.id)).todos.values()) { + if (todo.done === true || todo.archive_state === "archive") continue; + if ((todo.required_capabilities !== undefined && !Array.isArray(todo.required_capabilities)) || + (Array.isArray(todo.required_capabilities) && todo.required_capabilities.some(capability => !supported.has(String(capability)))) || + (todo.claimed_by && !(coordination.registered_agents as unknown[]).includes(todo.claimed_by))) { + reject("Original work needs an unsupported capability or Agent binding; retain its owning continuation hold"); + } + } +} + +/** Live document references follow the already reviewed material mapping. + * Historical source context and receipts keep their original paths/bytes. */ +export function localTransferMappedGoalDocuments(original: JsonObject, materials: JsonObject): JsonObject[] { + if (!Array.isArray(materials.materials) || !Array.isArray(materials.workspace_destinations)) reject("Original material mapping is unavailable"); + return localTransferGoalDocuments(original).map(source => { + const path = relative(String(original.repo), resolve(String(original.repo), String(source.path))); + const material = (materials.materials as JsonObject[]).find(row => row.source_relative_path === path); + const destination = (materials.workspace_destinations as JsonObject[]).find(row => row.id === material?.id); + if (!material || !destination) reject("Required Goal document has no reviewed destination material; preserve the source"); + return {...source, path: destination.relative_path}; + }); +} + /** Local extension of the existing cutover profile, explicitly reviewed in its * preview. Current registration and execution permissions remain independent. */ export function planReviewedTransferContinuation(mode: unknown, original: JsonObject, @@ -105,10 +176,11 @@ export async function readGoalTransferHostContext(value: JsonObject): Promise index.todos.get(id)!), materials, original_accounting: history.provenance, source_sessions_adopted: false, credentials_adopted: false, - guidance: "Continue the original work using current Task/lease and Host authority. Historical decisions and receipts grant no execution permission."}}; + guidance: "Continue the original work using current Task/lease and Host authority. Read required Goal documents through current_goal and the reviewed destination material paths; original_goal retains historical source references. Historical decisions and receipts grant no execution permission."}}; } /** The existing Settings/CLI entry also works after moving to the destination. diff --git a/loopx/control_plane/goals/planned_transfer_cutover.ts b/loopx/control_plane/goals/planned_transfer_cutover.ts index 95c45599e6..789bf03517 100644 --- a/loopx/control_plane/goals/planned_transfer_cutover.ts +++ b/loopx/control_plane/goals/planned_transfer_cutover.ts @@ -24,8 +24,10 @@ import {loadLegacyCoordinationWriterFence, admitPlannedTransferTaskWriterUnderLo import {shadowManagementDirectory, withShadowMaintenanceLock} from "../coordination/shadow_management.ts"; import {readGoalTransferHistoryJson} from "./planned_transfer_history.ts"; import {requirePhysicalTransferPath, inspectTransferFile} from "./planned_transfer_container.ts"; -import {readGoalTransferSnapshot, withVerifiedGoalTransferContext} from "./planned_transfer.ts"; -import {planReviewedTransferContinuation, readReviewedTransferContext} from "./planned_transfer_continuation.ts"; +import {decodePlannedTransferMaterial, readGoalTransferSnapshot, withVerifiedGoalTransferContext} from "./planned_transfer.ts"; +import {localTransferGoalConfiguration, localTransferGoalDocuments, localTransferMappedGoalDocuments, + requireLocalTransferTaskBindings, planReviewedTransferContinuation, readReviewedTransferContext} from "./planned_transfer_continuation.ts"; +import {planGoalTransferWorkspace} from "./planned_transfer_workspace.ts"; import {observeLocalWorkspaceHostIdentity} from "../work_items/task_lease_workspace.ts"; import {reviewGoalTransferMaterialInputs} from "./planned_transfer_payload.ts"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; @@ -70,6 +72,8 @@ export async function planLocalGoalTransferPreparation(value: JsonObject): Promi if (source.preview.ready_for_authority_preparation !== true) reject("Original work is not settled; use its existing Task/Turn owners before preparing this move"); const destination = await requirePhysicalTransferPath(value.destination_root); const goal = requireJsonObject(source.context.goal, "original Goal"); + localTransferGoalConfiguration(goal); + requireLocalTransferTaskBindings(goal, source.projection); const overlaps = (a: string, b: string) => { const suffix = relative(a, b); return suffix === "" || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)); @@ -85,7 +89,15 @@ export async function planLocalGoalTransferPreparation(value: JsonObject): Promi const physical = await requirePhysicalTransferPath(path); materials.push({path: physical, sha256: (await inspectTransferFile(physical)).sha256}); } - await reviewGoalTransferMaterialInputs(materials, source.context); + for (const document of localTransferGoalDocuments(goal)) { + const physical = await requirePhysicalTransferPath(resolve(String(goal.repo), String(document.path))); + if (!materials.some(row => row.path === physical)) materials.push({path: physical, sha256: (await inspectTransferFile(physical)).sha256}); + } + const reviewed = await reviewGoalTransferMaterialInputs(materials, source.context); + // Discover reserved/colliding destinations before any source fence or key + // directory is published, using the same workspace placement owner. + planGoalTransferWorkspace(join(destination, "workspace"), join(destination, "runtime"), + reviewed.materials.map((row, index) => ({...decodePlannedTransferMaterial(row, index), path: String(materials[index]!.path)})), null); return {source_preview_sha256: source.preview.preview_sha256, provider: source.preview.provider, materials, project: join(destination, "workspace"), runtime_root: join(destination, "runtime"), recipient_directory: join(destination, "recipient"), artifact_directory: join(destination, "portable")}; @@ -131,32 +143,9 @@ export async function retainLocalGoalTransferCutoverReview(value: JsonObject): P * empty backup. Unknown Goal/adapter/config fields require their actual owner. * Source sessions and credentials remain historical; none is auto-installed. */ -function localAdmittedGoal(original: JsonObject, staged: JsonObject, continuation: JsonObject | null): JsonObject { - const keys = ["id", "display_name", "domain", "status", "role", "parent_goal_id", "repo", "state_file", - "authority_sources", "adapter", "spawn_policy", "coordination", "execution_profile", "next_probe", "guards", - "creation_operation_id", "activation"]; - if (Object.keys(original).some(key => !keys.includes(key)) || original.parent_goal_id != null) { - reject("Source Goal configuration requires its owning continuation profile"); - } - if (original.activation !== undefined && requireJsonObject(original.activation, "original activation").state !== "active") { - reject("A stopped original Goal requires its lifecycle decision before target activation"); - } - const adapter = requireJsonObject(original.adapter, "source adapter"); - const spawn = requireJsonObject(original.spawn_policy, "source spawn policy"); - const coordination = requireJsonObject(original.coordination, "source coordination"); - if (!hasExactAuthorityKeys(adapter, ["kind", "status"]) || adapter.kind !== "generic_project_goal_v0" || - spawn.allowed !== false || !Array.isArray(original.authority_sources) || original.authority_sources.length !== 0 || - Object.keys(coordination).some(key => !["write_scope", "requires_parent_approval", "registered_agents", "storage_target", "agent_model"].includes(key)) || - coordination.agent_model !== "peer_v1" || - !Array.isArray(coordination.registered_agents) || coordination.registered_agents.length !== 1 || - coordination.registered_agents.some(agent => typeof agent !== "string") || !Array.isArray(coordination.write_scope)) { - reject("Local Task transfer needs its qualified single-Agent configuration; required materials, capabilities or Host bindings need their original owner"); - } - const {storage_target: _originalCreationTarget, ...currentCoordination} = coordination; - return {...staged, domain: original.domain, role: original.role, - parent_goal_id: null, spawn_policy: spawn, coordination: currentCoordination, - ...(original.execution_profile === undefined ? {} : {execution_profile: original.execution_profile}), - ...(original.guards === undefined ? {} : {guards: original.guards}), +function localAdmittedGoal(original: JsonObject, staged: JsonObject, continuation: JsonObject | null, materials: JsonObject): JsonObject { + return {...staged, ...localTransferGoalConfiguration(original), + authority_sources: [...localTransferMappedGoalDocuments(original, materials), ...(staged.authority_sources as JsonObject[])], activation: {schema_version: "loopx_goal_activation_v1", state: "active", reason: continuation ? "Reviewed original work continues in this new local runtime" : "Native local Task admission; Host continuation remains unqualified"}, planned_transfer: {...requireJsonObject(staged.planned_transfer, "staged transfer"), state: continuation ? "runtime_admitted" : "task_admitted", @@ -342,17 +331,9 @@ export async function planLocalGoalTransferCutover(value: JsonObject): Promise !supportedCapabilities.has(String(capability)))) || - (todo.claimed_by && !(originalCoordination.registered_agents as unknown[]).includes(todo.claimed_by))) { - reject("Original work needs an unsupported capability or Agent binding; retain its owning continuation hold"); - } - } + const originalGoal = requireJsonObject(current.context.goal, "original Goal"); + const admittedGoal = localAdmittedGoal(originalGoal, targetGoal, continuation, materials); + requireLocalTransferTaskBindings(originalGoal, current.projection); const decision = {schema_version: CUTOVER_SCHEMA, goal_id: goal, operation_id: operation, source_runtime_root: root, source_preparation_sha256: canonicalAuthoritySha256(pending), source_receipt_sha256: canonicalAuthoritySha256(sourceReceipt), target: {...target, identity_sha256: receipt.identity_sha256, store_identity: storeIdentity.store_identity, diff --git a/tests/control_plane/test_planned_transfer_continuation.py b/tests/control_plane/test_planned_transfer_continuation.py index ce9e3f79e3..a893a8f915 100644 --- a/tests/control_plane/test_planned_transfer_continuation.py +++ b/tests/control_plane/test_planned_transfer_continuation.py @@ -22,7 +22,7 @@ from tests.control_plane.test_planned_transfer_cancellation import source as source_fixture from tests.control_plane.test_planned_transfer_cutover import OPERATION, _staged, _target_worktree from tests.control_plane.test_planned_transfer_operation import _http -from tests.control_plane.test_planned_transfer_product import _prepared +from tests.control_plane.test_planned_transfer_product import _goal_document, _prepared source = source_fixture @@ -52,6 +52,7 @@ def test_reviewed_context_admits_a_new_runtime_without_copying_a_session(source: def test_real_fresh_native_turn_finishes_original_work_and_recovers_without_reexecution(source: dict, tmp_path: Path, monkeypatch): from tests.control_plane.codex_checkpoint_fixture import real_native_source + _goal_document(source, absolute=True) # Original accepted context and its relation survive; no replacement Todo # is invented after transfer. Expected 21 is fixed before any model output. accepted = source["cli"]("todo", "add", "--goal-id", "report", "--role", "user", "--task-class", "user_action", @@ -72,12 +73,12 @@ def test_real_fresh_native_turn_finishes_original_work_and_recovers_without_reex server.shutdown() thread.join(5) server.server_close() - worktree = _target_worktree(target) + worktree = _target_worktree(target, material_paths=("input.txt", "requirements/GOAL.md")) lease = target["cli"]("task-lease", "acquire", "--goal-id", "report", "--todo-id", source["todo"], "--owner", "agent-a", "--idempotency-key", "new-target-native-work", "--write-scope", "report.txt", "--write-worktree", str(worktree))["lease"] assert lease["lease_epoch"] > 1 and lease["idempotency_key"] != "original-work" - script = "from pathlib import Path; p=Path('report.txt'); assert not p.exists(); p.write_text(str(sum(map(int,Path('input.txt').read_text().split())))+'\\n')" + script = "from pathlib import Path; assert 'produce exactly 21' in Path('requirements/GOAL.md').read_text(); p=Path('report.txt'); assert not p.exists(); p.write_text(str(sum(map(int,Path('input.txt').read_text().split())))+'\\n')" requests = [] def response(body, number, message): @@ -96,6 +97,8 @@ def response(body, number, message): assert requests, "The ordinary native driver must supply the complete current request" context = requests[0]["transfer_context"] assert context["selected_todo"]["todo_id"] == source["todo"] + assert {"kind": "goal_doc", "path": "requirements/GOAL.md", "role": "primary_goal_document"} in context["current_goal"]["authority_sources"] + assert any(row["relative_path"] == "requirements/GOAL.md" for row in context["materials"]) assert context["requirements"].find("Produce report.txt from input.txt") >= 0 archived = next(row for row in context["todos"] if row["todo_id"] == accepted) assert archived["archive_state"] == "archive" and archived["successor_todo_ids"] == [source["todo"]] diff --git a/tests/control_plane/test_planned_transfer_cutover.py b/tests/control_plane/test_planned_transfer_cutover.py index d31ffc7ee3..693ea53cc6 100644 --- a/tests/control_plane/test_planned_transfer_cutover.py +++ b/tests/control_plane/test_planned_transfer_cutover.py @@ -73,13 +73,13 @@ def _cutover(source: dict, target: dict, preview: dict, *, expected_code: int = "--execute", expected_code=expected_code) -def _target_worktree(target: dict): +def _target_worktree(target: dict, *, material_paths: tuple[str, ...] = ("input.txt",)): project = target["project"] def git(*arguments: str): subprocess.run(["git", "-C", str(project), *arguments], check=True, capture_output=True) git("init", "-q") git("remote", "add", "origin", "https://example.org/fixtures/agent-transfer.git") - git("add", "input.txt") + git("add", *material_paths) git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.org", "commit", "-qm", "Original transferred input") worktree = project.parent / "destination-agent" git("worktree", "add", "-qb", "continue-original", str(worktree)) diff --git a/tests/control_plane/test_planned_transfer_product.py b/tests/control_plane/test_planned_transfer_product.py index 1f2f4ee566..f14f9f2aea 100644 --- a/tests/control_plane/test_planned_transfer_product.py +++ b/tests/control_plane/test_planned_transfer_product.py @@ -14,6 +14,7 @@ from loopx.control_plane.goals import planned_transfer_io from loopx.control_plane.effect_runtime import restart_effect_runtime +from loopx.control_plane.projects.registry_codec import project_registry_transaction from tests.control_plane.test_planned_transfer_cancellation import source as source_fixture from tests.control_plane.test_planned_transfer_cutover import OPERATION from tests.control_plane.test_planned_transfer_operation import _http @@ -21,6 +22,100 @@ source = source_fixture +def _goal_document(source: dict, *, absolute: bool = False) -> Path: + document = source["project"] / "requirements/GOAL.md" + document.parent.mkdir() + document.write_text("# Original acceptance\nRead input.txt and produce exactly 21 in report.txt.\n") + with project_registry_transaction(source["registry"], operation="fixture-goal-document") as transaction: + registry = transaction.payload_copy() + registry["goals"][0]["authority_sources"] = [{"kind": "goal_doc", + "path": str(document) if absolute else "requirements/GOAL.md", "role": "primary_goal_document"}] + transaction.commit(registry) + return document + + +@pytest.mark.parametrize("absolute", [False, True]) +def test_preparation_carries_the_registered_goal_document_without_manual_enumeration(source: dict, tmp_path: Path, absolute: bool): + document = _goal_document(source, absolute=absolute) + expected = document.read_bytes() + target, prepared = _prepared(source, tmp_path) + assert (target["project"] / "requirements/GOAL.md").read_bytes() == expected + assert _prepared(source, tmp_path)[1] == prepared + review = prepared["prepared_cutover"] + source["cli"]("goal-transfer", "confirm", "--goal-id", "report", "--operation-id", OPERATION, + "--expected-preview-sha256", review["preview_sha256"], "--execute") + goal = json.loads(target["registry"].read_text())["goals"][0] + assert {"kind": "goal_doc", "path": "requirements/GOAL.md", "role": "primary_goal_document"} in goal["authority_sources"] + later = expected + b"\nLater destination clarification remains current.\n" + (target["project"] / "requirements/GOAL.md").write_bytes(later) + source["cli"]("goal-transfer", "confirm", "--goal-id", "report", "--operation-id", OPERATION, + "--expected-preview-sha256", review["preview_sha256"], "--execute") + assert (target["project"] / "requirements/GOAL.md").read_bytes() == later + source["project"].rename(tmp_path / "offline-source-project") + source["runtime"].rename(tmp_path / "offline-source-runtime") + context = target["cli"]("goal-transfer", "context", "--goal-id", "report", + "--agent-id", "agent-a", "--todo-id", source["todo"])["context"] + assert context["current_goal"] == goal + assert context["original_goal"]["authority_sources"][0]["path"] == (str(document) if absolute else "requirements/GOAL.md") + assert any(row["relative_path"] == "requirements/GOAL.md" for row in context["materials"]) + assert not source["project"].exists() and not source["runtime"].exists() + + +def test_changed_goal_document_keeps_the_original_product_cancel_path(source: dict, tmp_path: Path): + _goal_document(source) + target, prepared = _prepared(source, tmp_path) + (target["project"] / "requirements/GOAL.md").write_text("Changed after review\n") + server, thread, call = _http(source) + try: + review = prepared["prepared_cutover"] + assert call({"action": "confirm", "goal_id": "report", "operation_id": OPERATION, + "preview_sha256": review["preview_sha256"]})[0] == 400 + view = call({"action": "inspect", "goal_id": "report"})[1] + assert view["cancel_available"] and view["prepared_cutover_unavailable"] + assert call({"action": "cancel", "goal_id": "report", "operation_id": OPERATION})[0] == 200 + lease = source["acquire"]("resume-after-goal-document-refusal")["lease"] + source["release"](lease) + assert (target["project"] / "requirements/GOAL.md").read_bytes() == b"Changed after review\n" + finally: + server.shutdown() + thread.join(5) + server.server_close() + + +@pytest.mark.parametrize("fault", ["missing_document", "external_document", "reserved_document", "unsupported_configuration", "required_capability"]) +def test_required_goal_dependencies_are_held_before_preparation_changes_source(source: dict, tmp_path: Path, fault: str): + document = _goal_document(source) + if fault == "missing_document": + document.unlink() + elif fault == "required_capability": + source["cli"]("todo", "add", "--goal-id", "report", "--role", "agent", "--claimed-by", "agent-a", + "--text", "Retain the required evidence dependency", "--required-capability", "external_evidence_poll", + "--operation-id", "required-evidence") + else: + with project_registry_transaction(source["registry"], operation="fixture-required-dependency") as transaction: + registry = transaction.payload_copy() + if fault == "external_document": + outside = tmp_path / "external-credentials.txt" + outside.write_text("Not a workspace material\n") + registry["goals"][0]["authority_sources"][0]["path"] = str(outside) + elif fault == "reserved_document": + reserved = source["project"] / ".loopx/GOAL.md" + reserved.write_bytes(document.read_bytes()) + registry["goals"][0]["authority_sources"][0]["path"] = str(reserved) + else: + registry["goals"][0]["control_plane"] = {"capability_improvement": {"mode": "bounded"}} + transaction.commit(registry) + before = source["fence"].read_bytes(), source["registry"].read_bytes() + destination = tmp_path / "unprepared-dependencies" + result = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", "--operation-id", OPERATION, + "--destination-root", str(destination), "--material", str(source["project"] / "input.txt"), "--execute", expected_code=1) + assert result["ok"] is False + assert not destination.exists() + assert before == (source["fence"].read_bytes(), source["registry"].read_bytes()) + lease = source["acquire"]("continue-after-unprepared-dependency")["lease"] + source["release"](lease) + + def _prepared(source: dict, root: Path) -> tuple[dict, dict]: destination = root / "local-move" view = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", "--operation-id", OPERATION, From ca5ea334baaf9e1d1f335ae8dfb9d1f6b5a4bcdd Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:30:12 +0800 Subject: [PATCH 21/55] fix(migration): prepare reviewed independent Agent workspaces Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/research-checkpoint.md | 19 ++ .../goals/planned_transfer_continuation.ts | 4 + .../goals/planned_transfer_cutover.ts | 46 ++++- .../goals/planned_transfer_io.py | 1 + .../goals/planned_transfer_workspace.ts | 192 +++++++++++++++++- .../test_planned_transfer_continuation.py | 16 +- .../test_planned_transfer_product.py | 123 +++++++++++ 7 files changed, 392 insertions(+), 9 deletions(-) diff --git a/docs/reference/research-checkpoint.md b/docs/reference/research-checkpoint.md index da44e8a60f..08687c4c09 100644 --- a/docs/reference/research-checkpoint.md +++ b/docs/reference/research-checkpoint.md @@ -160,6 +160,25 @@ the original context and receipts retain their historical source paths. The native continuation context supplies `current_goal` for current document reads. Later destination edits survive retries of the original confirmation. +When unfinished Tasks name one Git repository, local preparation verifies its +actual origin and clean tracked snapshot before fencing the source. Its tracked +files join the protected material inventory; selected inputs and the primary +Goal document keep their reviewed mappings. Preparation builds a separate local +repository and an independent `codex/` worktree with those files. It uses the +pinned source commit without checkout filters or source hooks/configuration. +Unselected untracked and ignored files remain outside this snapshot. +The native continuation context provides `execution_workspace` for the existing +Task/lease and Turn entrypoints. Changed execution inputs or repository identity +refuse the final confirmation and retain source cancellation. After confirmation, +ordinary destination writes survive retries. This removes manual Git setup from +the local continuation path; responsible-Agent dispatch, required credential +rebinding and cross-machine repository delivery still need their owning journey. +The original Task's recorded workspace must also be available and match that +snapshot. A divergent Agent branch, local edits, uncarried working files or +unknown workspace mapping hold before preparation. A clean canonical checkout +must not silently replace unfinished work in the Agent's actual checkout; +capturing that working state remains part of the complete migration gap. + The local configuration and required-Task capability checks run before source preparation as well as at final cutover. Missing, unsafe or unmapped documents, unsupported capability configuration and required capabilities outside this diff --git a/loopx/control_plane/goals/planned_transfer_continuation.ts b/loopx/control_plane/goals/planned_transfer_continuation.ts index 3238505caa..59895d3af4 100644 --- a/loopx/control_plane/goals/planned_transfer_continuation.ts +++ b/loopx/control_plane/goals/planned_transfer_continuation.ts @@ -15,6 +15,7 @@ import {openLocalAuthorityStoreHandle, requireLocalAuthorityRuntimeRoot} from ". import {indexCoordinationProjection} from "../coordination/coordination_projection.ts"; import {readGoalTransferSnapshot, checkGoalTransferSourceContext} from "./planned_transfer.ts"; import {readGoalTransferHistoryJson} from "./planned_transfer_history.ts"; +import {verifyGoalTransferExecutionWorkspace} from "./planned_transfer_workspace.ts"; function reject(message: string): never {throw new EffectRuntimeConflictError(message);} function same(a: unknown, b: unknown): boolean {return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b));} @@ -158,6 +159,8 @@ export async function readGoalTransferHostContext(value: JsonObject): Promise index.todos.get(id)!), materials, original_accounting: history.provenance, source_sessions_adopted: false, credentials_adopted: false, guidance: "Continue the original work using current Task/lease and Host authority. Read required Goal documents through current_goal and the reviewed destination material paths; original_goal retains historical source references. Historical decisions and receipts grant no execution permission."}}; diff --git a/loopx/control_plane/goals/planned_transfer_cutover.ts b/loopx/control_plane/goals/planned_transfer_cutover.ts index 789bf03517..9aecf879d0 100644 --- a/loopx/control_plane/goals/planned_transfer_cutover.ts +++ b/loopx/control_plane/goals/planned_transfer_cutover.ts @@ -27,7 +27,8 @@ import {requirePhysicalTransferPath, inspectTransferFile} from "./planned_transf import {decodePlannedTransferMaterial, readGoalTransferSnapshot, withVerifiedGoalTransferContext} from "./planned_transfer.ts"; import {localTransferGoalConfiguration, localTransferGoalDocuments, localTransferMappedGoalDocuments, requireLocalTransferTaskBindings, planReviewedTransferContinuation, readReviewedTransferContext} from "./planned_transfer_continuation.ts"; -import {planGoalTransferWorkspace} from "./planned_transfer_workspace.ts"; +import {planGoalTransferWorkspace, planGoalTransferRepository, prepareGoalTransferExecutionWorkspace, + readGoalTransferExecutionWorkspace} from "./planned_transfer_workspace.ts"; import {observeLocalWorkspaceHostIdentity} from "../work_items/task_lease_workspace.ts"; import {reviewGoalTransferMaterialInputs} from "./planned_transfer_payload.ts"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; @@ -83,7 +84,7 @@ export async function planLocalGoalTransferPreparation(value: JsonObject): Promi } let entries: string[] = []; try {entries = await readdir(destination);} catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} - if (entries.some(name => !["recipient", "portable", "workspace", "runtime"].includes(name))) reject("Local destination is occupied; preserve it and choose another directory"); + if (entries.some(name => !["recipient", "portable", "workspace", "runtime", "agent-workspace"].includes(name))) reject("Local destination is occupied; preserve it and choose another directory"); const materials: JsonObject[] = []; for (const path of rawMaterials) { const physical = await requirePhysicalTransferPath(path); @@ -93,12 +94,19 @@ export async function planLocalGoalTransferPreparation(value: JsonObject): Promi const physical = await requirePhysicalTransferPath(resolve(String(goal.repo), String(document.path))); if (!materials.some(row => row.path === physical)) materials.push({path: physical, sha256: (await inspectTransferFile(physical)).sha256}); } + const repository = await planGoalTransferRepository(goal, source.projection, + materials.map(row => relative(String(goal.repo), String(row.path)))); + if (repository && entries.includes("agent-workspace") && source.preparedOperation !== value.operation_id) reject("Destination execution workspace is occupied; preserve the source and destination"); + for (const file of repository?.files ?? []) { + const physical = await requirePhysicalTransferPath(resolve(String(goal.repo), file.path)); + if (!materials.some(row => row.path === physical)) materials.push({path: physical, sha256: (await inspectTransferFile(physical)).sha256}); + } const reviewed = await reviewGoalTransferMaterialInputs(materials, source.context); // Discover reserved/colliding destinations before any source fence or key // directory is published, using the same workspace placement owner. planGoalTransferWorkspace(join(destination, "workspace"), join(destination, "runtime"), reviewed.materials.map((row, index) => ({...decodePlannedTransferMaterial(row, index), path: String(materials[index]!.path)})), null); - return {source_preview_sha256: source.preview.preview_sha256, provider: source.preview.provider, materials, + return {source_preview_sha256: source.preview.preview_sha256, provider: source.preview.provider, materials, repository, project: join(destination, "workspace"), runtime_root: join(destination, "runtime"), recipient_directory: join(destination, "recipient"), artifact_directory: join(destination, "portable")}; }); @@ -125,7 +133,23 @@ export async function readRegisteredLocalCutoverReview(value: JsonObject): Promi } export async function retainLocalGoalTransferCutoverReview(value: JsonObject): Promise { - const plan = await planLocalGoalTransferCutover(value); + const {execution_repository: repository, ...request} = value; + if (repository != null) { + const before = await planLocalGoalTransferCutover(request); + if (before.source_final === true) reject("Original cutover is final; do not recreate its execution workspace"); + const decision = requireJsonObject(before.decision, "original cutover"), target = requireJsonObject(decision.target, "staged target"); + const source = requireJsonObject(request.source, "original source"); + const context = requireJsonObject(requireJsonObject(request.target_snapshot, "target snapshot").source_context, "target context"); + const directory = operationDirectory(String(target.runtime_root), String(decision.goal_id), String(decision.operation_id), false); + await withShadowMaintenanceLock(String(target.runtime_root), String(decision.goal_id), async () => { + const staged = await readGoalTransferHistoryJson(join(directory, "preparation.json")); + await prepareGoalTransferExecutionWorkspace(directory, + requireJsonObject(requireJsonObject(source.source_context, "original context").goal, "original Goal"), + requireJsonObject(repository, "reviewed source repository"), requireJsonObject(staged.protected_transport, "staged materials"), + context, requireJsonObject(requireJsonObject(request.target_snapshot, "target snapshot").registry_witness, "target witness")); + }); + } + const plan = await planLocalGoalTransferCutover(request); const decision = requireJsonObject(plan.decision, "reviewed original cutover"), target = requireJsonObject(decision.target, "reviewed destination"); if (decision.profile !== "source_online_local_runtime_v0") reject("Product confirmation requires reviewed original-work continuation"); const source = requireJsonObject(value.source, "original source"); @@ -330,8 +354,20 @@ export async function planLocalGoalTransferCutover(value: JsonObject): Promise String(row.source_relative_path))); + if (!repository || !same(repository, executionWorkspace.repository)) reject("Original source repository changed after preparation; preserve its new work and use source cancellation"); + for (const row of materials.materials as JsonObject[]) { + if (repository.files.some(file => file.path === row.source_relative_path)) continue; + const path = await requirePhysicalTransferPath(resolve(String(originalGoal.repo), String(row.source_relative_path))); + if (!same(await inspectTransferFile(path), {sha256: row.sha256, bytes: row.bytes})) reject("Original source input changed after preparation; preserve it and use source cancellation"); + } + } + if (continuation && executionWorkspace) continuation.execution_workspace = executionWorkspace; const admittedGoal = localAdmittedGoal(originalGoal, targetGoal, continuation, materials); requireLocalTransferTaskBindings(originalGoal, current.projection); const decision = {schema_version: CUTOVER_SCHEMA, goal_id: goal, operation_id: operation, source_runtime_root: root, diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index 876996c45d..f6d471edb7 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -192,6 +192,7 @@ def prepare_local_goal_transfer(*, registry_path: Path, goal_id: str, operation_ stage_protected_goal_transfer(**target, expected_workspace_preview_sha256=workspace["reviewed_preview_sha256"]) request, _, _ = _local_cutover_request(registry_path, goal_id, operation_id, Path(plan["project"]) / ".loopx/registry.json", "reviewed_context") + request["execution_repository"] = plan["repository"] effect_runtime_result("coordination.planned_transfer.retain_local_cutover_review", request, timeout=300.0, retry_safe=False) return inspect_registered_goal_transfer(registry_path=registry_path, goal_id=goal_id) diff --git a/loopx/control_plane/goals/planned_transfer_workspace.ts b/loopx/control_plane/goals/planned_transfer_workspace.ts index 1aaf55222e..5370c41363 100644 --- a/loopx/control_plane/goals/planned_transfer_workspace.ts +++ b/loopx/control_plane/goals/planned_transfer_workspace.ts @@ -2,7 +2,10 @@ * selected workspace files, never Goal admission or authority. Credential policy * remains with its owner; selected bytes are not privacy-certified here. */ import {constants} from "node:fs"; -import {copyFile, mkdir, open, readFile} from "node:fs/promises"; +import {copyFile, mkdir, open, readFile, chmod, stat} from "node:fs/promises"; +import {execFile} from "node:child_process"; +import {promisify} from "node:util"; +import {createHash} from "node:crypto"; import {dirname, isAbsolute, join, relative, resolve, sep} from "node:path"; import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; @@ -11,7 +14,11 @@ import {canonicalAuthorityBytes, canonicalAuthoritySha256, hasExactAuthorityKeys import {requireJsonObject} from "../runtime_decode.ts"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import {inspectTransferFile, requirePhysicalTransferPath} from "./planned_transfer_container.ts"; -import type {ProtectedGoalMaterial} from "./planned_transfer.ts"; +import {decodePlannedTransferMaterial, type ProtectedGoalMaterial} from "./planned_transfer.ts"; +import {indexCoordinationProjection} from "../coordination/coordination_projection.ts"; +import {normalizeTodoRepository} from "../todos/work_requirements.ts"; +import {observeLeaseWorktree, sameLeaseWorkspace} from "../work_items/task_lease_workspace.ts"; +import {evaluateTaskLeaseWriteScopesOverlap} from "../work_items/task_lease_acquire_decision.ts"; interface WorkspaceMaterial {source: ProtectedGoalMaterial; destination: string; relativePath: string} export interface GoalTransferWorkspacePlan {materials: WorkspaceMaterial[]; destinations: JsonObject[]} @@ -139,3 +146,184 @@ export async function retainGoalTransferWorkspace(plan: GoalTransferWorkspacePla if (final.sha256 !== row.source.sha256 || final.bytes !== row.source.bytes) reject("Destination workspace material readback failed"); } } + +interface RepositoryFile extends JsonObject {path: string; mode: string; object: string} +interface TransferRepository extends JsonObject {origin: string; repository: string; commit: string; files: RepositoryFile[]} +const runGit = promisify(execFile); +const workspaceScopes = (path: string) => path !== ".git" && !path.startsWith(".git/"); + +/** Git is only local snapshot IO here. Do not inherit an alternate Git root, + * templates, credential helpers, hooks, global filters or transport settings. */ +async function gitAt(path: string, ...args: string[]): Promise { + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))); + return (await runGit("git", ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", + "-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "credential.helper=", "-C", path, ...args], + {env: {...env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0"}, + timeout: 30_000, maxBuffer: 4 * 1024 * 1024})).stdout; +} + +async function observeRepository(project: string, repository: string): Promise { + const root = await requirePhysicalTransferPath(project); + try { + if ((await gitAt(root, "rev-parse", "--show-toplevel")).trim() !== root) reject("Transfer needs the original repository root"); + const origin = (await gitAt(root, "remote", "get-url", "origin")).trim(); + // A source credential/configuration is not a destination credential. Only + // credential-free ordinary Git origins may enter this local snapshot. + const scp = /^git@([^/:]+):(.+)$/u.exec(origin); + const url = new URL(scp ? `ssh://${scp[1]}/${scp[2]}` : origin); + if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol) || !url.hostname || + url.password || url.search || url.hash || (url.username && !(url.protocol === "ssh:" && url.username === "git")) || + normalizeTodoRepository(origin) !== repository) reject("Source origin differs from the original Task repository or needs credential rebinding"); + const commit = (await gitAt(root, "rev-parse", "--verify", "HEAD^{commit}")).trim(); + const entries = (await gitAt(root, "ls-tree", "-rz", "--full-tree", commit)).split("\0").filter(Boolean); + const files: RepositoryFile[] = []; + for (const entry of entries) { + const match = /^(100644|100755) blob ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/u.exec(entry); + if (!match) reject("Repository links/submodules need their owning transfer profile before preparation"); + const mode = match[1]!, object = match[2]!, path = match[3]!; + const physical = await requirePhysicalTransferPath(resolve(root, path)); + const child = relative(root, physical); + if (!child || isAbsolute(child) || child === ".." || child.startsWith(`..${sep}`) || + child.split(sep).some(part => part === ".git" || part === ".loopx")) reject("Repository snapshot contains reserved or redirected control paths"); + const info = await stat(physical), bytes = await readFile(physical); + const objectHash = createHash(object.length === 40 ? "sha1" : "sha256") + .update(`blob ${bytes.length}\0`).update(bytes).digest("hex"); + if (!info.isFile() || info.nlink !== 1 || objectHash !== object || + ((info.mode & 0o111) !== 0) !== (mode === "100755")) reject("Tracked repository changes require their source owner before preparation"); + files.push({path, mode, object}); + } + // Deleted/added tracked entries and an index-only change must not disappear + // behind a clean-looking worktree or a selected material override. + if ((await gitAt(root, "diff-index", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", commit, "--")).trim()) reject("Staged repository changes require their source owner before preparation"); + if ((await gitAt(root, "rev-parse", "HEAD")).trim() !== commit) reject("Source repository changed during preparation"); + return {origin, repository, commit, files}; + } catch (error) { + if (error instanceof EffectRuntimeConflictError) throw error; + reject("Original repository snapshot is unavailable; preserve the source and resolve its repository owner"); + } +} + +/** Code Tasks keep their real repository and tracked dependency closure. This + * is not a directory sweep: unselected untracked/ignored files stay outside the + * snapshot. Machine/Git configuration is not adopted; selected bytes are not + * privacy-certified. */ +export async function planGoalTransferRepository(original: JsonObject, projection: JsonObject, + selectedPaths: readonly string[]): Promise { + const repositories = new Set(); + const index = indexCoordinationProjection(projection, String(original.id)); + const workspaces: JsonObject[] = []; + for (const [id, todo] of index.todos) { + if (todo.done === true || todo.archive_state === "archive" || !todo.task_repository) continue; + const repository = normalizeTodoRepository(todo.task_repository); + if (!repository) reject("Original Task repository is unavailable"); + repositories.add(repository); + const lease = index.leases.get(id); + if (!lease?.write_workspace) reject("Original code Task needs its source workspace mapping before preparation"); + workspaces.push({identity: requireJsonObject(lease.write_workspace, "original Task workspace"), scopes: lease.write_scopes}); + } + if (!repositories.size) return null; + if (repositories.size !== 1) reject("Multiple original repositories require their owning workspace mapping before preparation"); + const observed = await observeRepository(String(original.repo), [...repositories][0]!); + // A clean canonical checkout alone does not prove the Agent's unfinished + // work is carried. Historical lease identities locate its actual checkout + // for read-only comparison; they grant no current execution permission. + const paths = (await gitAt(String(original.repo), "worktree", "list", "--porcelain", "-z")) + .split("\0").filter(field => field.startsWith("worktree ")).map(field => field.slice(9)); + for (const binding of workspaces) { + const workspace = requireJsonObject(binding.identity, "original workspace identity"); + let actual: string | null = null; + for (const path of paths) { + try { + const info = await stat(path); + if (createHash("sha256").update(`${info.dev}:${info.ino}`).digest("hex") === workspace.worktree) actual = path; + } catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} + } + if (!actual || !sameLeaseWorkspace(await observeLeaseWorktree(actual, () => false), workspace)) reject("Original Agent workspace is unavailable; do not replace it with the canonical checkout"); + if (!same(await observeRepository(actual, observed.repository), observed)) reject("Original Agent branch differs from the canonical snapshot; preserve its unfinished work for workspace mapping"); + const ordinary = new Set((await gitAt(actual, "ls-files", "--others", "--exclude-standard", "-z")).split("\0").filter(Boolean)); + for (const path of (await gitAt(actual, "ls-files", "--others", "-z")).split("\0").filter(Boolean)) { + if (!ordinary.has(path) && !selectedPaths.includes(path) && + evaluateTaskLeaseWriteScopesOverlap({left: binding.scopes, right: [path]}).overlap !== true) continue; + if (!selectedPaths.includes(path) || !same( + await inspectTransferFile(await requirePhysicalTransferPath(resolve(actual, path))), + await inspectTransferFile(await requirePhysicalTransferPath(resolve(String(original.repo), path))))) { + reject("Original Agent has uncarried working files; preserve them for the workspace owner before preparation"); + } + } + } + return observed; +} + +/** The existing operation retains a workspace witness, not another admission + * grant. Before confirmation it includes unchanged input bytes and HEAD; after + * admission ordinary new writes/commits are legal under the fresh Task lease. */ +export async function verifyGoalTransferExecutionWorkspace(record: JsonObject, materials: JsonObject, + originalFiles: boolean): Promise { + const path = await requirePhysicalTransferPath(record.path); + const identity = await observeLeaseWorktree(path, workspaceScopes); + if (!same(identity, record.identity) || record.materials_sha256 !== canonicalAuthoritySha256(materials)) reject("Original execution workspace identity changed; preserve its operation"); + if (!originalFiles) return; + const repository = requireJsonObject(record.repository, "original execution repository"); + if ((await gitAt(path, "rev-parse", "HEAD")).trim() !== repository.commit) reject("Reviewed execution repository changed before confirmation"); + const destinations = materials.workspace_destinations as JsonObject[]; + const expectedPaths = new Set(destinations.map(row => row.relative_path)); + if ((await gitAt(path, "ls-files", "--cached", "--others", "-z")).split("\0").filter(Boolean) + .some(file => !expectedPaths.has(file))) reject("Execution workspace contains unreviewed files before confirmation"); + for (const row of materials.materials as JsonObject[]) { + const destination = destinations.find(item => item.id === row.id); + if (!destination) reject("Execution material has no reviewed mapping"); + const physical = await requirePhysicalTransferPath(resolve(path, String(destination.relative_path))); + if (!same(await inspectTransferFile(physical), {sha256: row.sha256, bytes: row.bytes})) reject("Reviewed execution material changed before confirmation"); + } + for (const file of repository.files as RepositoryFile[]) { + if ((((await stat(resolve(path, file.path))).mode & 0o111) !== 0) !== (file.mode === "100755")) reject("Reviewed execution file mode changed before confirmation"); + } +} + +export async function readGoalTransferExecutionWorkspace(directory: string, materials: JsonObject): Promise { + const record = await optionalRecord(join(directory, "execution-workspace.json")); + if (record) await verifyGoalTransferExecutionWorkspace(record, materials, true); + return record; +} + +/** Build a separate local repository with one pinned source commit/tree, then + * populate a new independent worktree through the existing material owner. + * No source hooks/configuration, checkout filters, network fetch or old + * lease/session is adopted. The protected material inventory carries the actual + * tracked bytes; cross-machine repository delivery remains separately held. */ +export async function prepareGoalTransferExecutionWorkspace(directory: string, original: JsonObject, + expected: JsonObject, materials: JsonObject, context: JsonObject, witness: JsonObject): Promise { + const path = join(dirname(String(requireJsonObject(context.goal, "staged Goal").repo)), "agent-workspace"), bare = join(directory, "repository.git"); + const intent = {repository: expected, materials_sha256: canonicalAuthoritySha256(materials), path}; + const previous = await optionalRecord(join(directory, "execution-intent.json")); + if (previous && !same(previous, intent)) reject("Original execution workspace preparation changed; preserve it"); + if (await optionalRecord(join(directory, "execution-workspace.json"))) { + await readGoalTransferExecutionWorkspace(directory, materials); return; + } + if (previous) reject("Execution workspace preparation was interrupted; preserve the target and use the original source cancellation before a new preparation"); + if (!same(await observeRepository(String(original.repo), String(expected.repository)), expected)) reject("Source repository changed after its initial review"); + for (const file of expected.files as RepositoryFile[]) { + if (!(materials.materials as JsonObject[]).some(row => row.source_relative_path === file.path)) reject("Tracked dependency is absent from the protected material inventory"); + } + await retainRecord(join(directory, "execution-intent.json"), intent); + await mkdir(bare, {mode: 0o700}); + await gitAt(bare, "init", "--bare", "--template=", `--object-format=${String(expected.commit).length === 64 ? "sha256" : "sha1"}`); + await gitAt(bare, "-c", "protocol.file.allow=always", "fetch", "--no-tags", "--depth=1", "--", String(original.repo), String(expected.commit)); + await gitAt(bare, "remote", "add", "origin", String(expected.origin)); + await gitAt(bare, "worktree", "add", "--no-checkout", "-b", `codex/continue-${canonicalAuthoritySha256(intent).slice(0, 16)}`, path, String(expected.commit)); + await gitAt(path, "read-tree", String(expected.commit)); + const mapping = materials.workspace_destinations as JsonObject[]; + const selected = (materials.materials as JsonObject[]).map((row, index) => ({...decodePlannedTransferMaterial(row, index), + path: resolve(String(requireJsonObject(context.goal, "staged Goal").repo), String(mapping.find(item => item.id === row.id)!.relative_path))})); + const plan = planGoalTransferWorkspace(path, String(context.runtime_root), selected, + Object.fromEntries(mapping.map(row => [String(row.id), row.relative_path]))); + const review = await reviewGoalTransferWorkspace(plan, canonicalAuthoritySha256(materials), context, witness); + const identity = {repository: expected, materials_sha256: canonicalAuthoritySha256(materials)}; + await verifyGoalTransferWorkspaceReview(plan, review, join(directory, "execution-materials"), identity, review.preview_sha256, false); + await retainGoalTransferWorkspace(plan, join(directory, "execution-materials"), identity, review.preview_sha256); + for (const file of expected.files as RepositoryFile[]) await chmod(resolve(path, file.path), file.mode === "100755" ? 0o700 : 0o600); + if (!same(await observeRepository(String(original.repo), String(expected.repository)), expected)) reject("Source repository changed while preparing its execution workspace"); + const record = {...intent, identity: await observeLeaseWorktree(path, workspaceScopes)}; + await verifyGoalTransferExecutionWorkspace(record, materials, true); + await retainRecord(join(directory, "execution-workspace.json"), record); +} diff --git a/tests/control_plane/test_planned_transfer_continuation.py b/tests/control_plane/test_planned_transfer_continuation.py index a893a8f915..f7111fd61b 100644 --- a/tests/control_plane/test_planned_transfer_continuation.py +++ b/tests/control_plane/test_planned_transfer_continuation.py @@ -8,6 +8,7 @@ import json import os import shlex +import subprocess import sys from time import perf_counter from pathlib import Path @@ -20,7 +21,7 @@ from loopx.control_plane.turn_driver.codex_sessions import select_codex_cli_session from loopx.history import collect_history from tests.control_plane.test_planned_transfer_cancellation import source as source_fixture -from tests.control_plane.test_planned_transfer_cutover import OPERATION, _staged, _target_worktree +from tests.control_plane.test_planned_transfer_cutover import OPERATION, _staged from tests.control_plane.test_planned_transfer_operation import _http from tests.control_plane.test_planned_transfer_product import _goal_document, _prepared @@ -73,7 +74,9 @@ def test_real_fresh_native_turn_finishes_original_work_and_recovers_without_reex server.shutdown() thread.join(5) server.server_close() - worktree = _target_worktree(target, material_paths=("input.txt", "requirements/GOAL.md")) + context = target["cli"]("goal-transfer", "context", "--goal-id", "report", + "--agent-id", "agent-a", "--todo-id", source["todo"])["context"] + worktree = Path(context["execution_workspace"]) lease = target["cli"]("task-lease", "acquire", "--goal-id", "report", "--todo-id", source["todo"], "--owner", "agent-a", "--idempotency-key", "new-target-native-work", "--write-scope", "report.txt", "--write-worktree", str(worktree))["lease"] @@ -138,9 +141,18 @@ def response(body, number, message): assert (worktree / "report.txt").read_bytes() == b"21\n" current = target["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] assert current["text"] == original["text"] and current["required_write_scopes"] == original["required_write_scopes"] + # A real accepted output may become a later repository commit. The + # original transfer review must not reset that branch or restore inputs. + subprocess.run(["git", "-C", str(worktree), "add", "report.txt"], check=True) + subprocess.run(["git", "-C", str(worktree), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.org", + "commit", "-qsm", "Accepted original report"], check=True) target["cli"]("todo", "complete", "--goal-id", "report", "--todo-id", source["todo"], "--agent-id", "agent-a", "--task-lease-idempotency-key", lease["idempotency_key"], "--task-lease-expected-version", str(lease["version"]), "--evidence", "report.txt", "--note", "Original result independently accepted as 21") + review = prepared["prepared_cutover"] + source["cli"]("goal-transfer", "confirm", "--goal-id", "report", "--operation-id", OPERATION, + "--expected-preview-sha256", review["preview_sha256"], "--execute") + assert (worktree / "report.txt").read_bytes() == b"21\n" and len(calls) == count observed = source["cli"]("goal-transfer", "inspect", "--goal-id", "report") assert next(row for row in observed["work"]["todos"] if row["todo_id"] == source["todo"])["done"] is True assert observed["continuation_available"] is False # Full installed journey remains unqualified. diff --git a/tests/control_plane/test_planned_transfer_product.py b/tests/control_plane/test_planned_transfer_product.py index f14f9f2aea..85e7e266e4 100644 --- a/tests/control_plane/test_planned_transfer_product.py +++ b/tests/control_plane/test_planned_transfer_product.py @@ -116,6 +116,129 @@ def test_required_goal_dependencies_are_held_before_preparation_changes_source(s source["release"](lease) + +def test_preparation_builds_an_independent_original_execution_repository(source: dict, tmp_path: Path): + document = _goal_document(source) + project = source["project"] + (project / "helper.txt").write_text("Original tracked dependency\n") + subprocess.run(["git", "-C", str(project), "add", "helper.txt"], check=True) + subprocess.run(["git", "-C", str(project), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.org", + "commit", "-qm", "Original dependency"], check=True) + revision = subprocess.run(["git", "-C", str(project), "rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip() + subprocess.run(["git", "-C", str(project.parent / "agent"), "merge", "--ff-only", revision], capture_output=True, check=True) + (project / "unselected-private.txt").write_text("Not part of this move\n") + hook = project / ".git/hooks/post-checkout" + hook.write_text("#!/bin/sh\nprintf unexpected > '" + str(tmp_path / "unexpected-hook") + "'\n") + hook.chmod(0o700) + target, prepared = _prepared(source, tmp_path) + review = prepared["prepared_cutover"] + source["cli"]("goal-transfer", "confirm", "--goal-id", "report", "--operation-id", OPERATION, + "--expected-preview-sha256", review["preview_sha256"], "--execute") + context = target["cli"]("goal-transfer", "context", "--goal-id", "report", + "--agent-id", "agent-a", "--todo-id", source["todo"])["context"] + worktree = Path(context["execution_workspace"]) + assert (worktree / ".git").is_file() + assert (worktree / "requirements/GOAL.md").read_bytes() == document.read_bytes() + assert (worktree / "helper.txt").read_text() == "Original tracked dependency\n" + assert not (worktree / "unselected-private.txt").exists() + assert not (worktree / ".loopx").exists() + assert not (tmp_path / "unexpected-hook").exists() + assert (worktree / "input.txt").stat().st_nlink == 1 + source["project"].rename(tmp_path / "offline-repository") + source["runtime"].rename(tmp_path / "offline-runtime") + # Independent repository objects and ordinary lease observation still work + # with the source unavailable. No harness Git setup or borrowed source lease. + lease = target["cli"]("task-lease", "acquire", "--goal-id", "report", "--todo-id", source["todo"], + "--owner", "agent-a", "--idempotency-key", "actual-destination-repository", "--write-scope", "report.txt", + "--write-worktree", str(worktree))["lease"] + assert lease["lease_epoch"] > 1 + git_object = subprocess.run(["git", "-C", str(worktree), "show", "HEAD:helper.txt"], capture_output=True, text=True, check=True) + assert git_object.stdout == "Original tracked dependency\n" + target["cli"]("task-lease", "release", "--goal-id", "report", "--todo-id", source["todo"], "--owner", "agent-a", + "--idempotency-key", lease["idempotency_key"], "--expected-version", str(lease["version"])) + + + +@pytest.mark.parametrize("fault", ["changed_input", "missing_document", "wrong_repository", "source_tracked_change", "source_document_change", "unreviewed_file"]) +def test_changed_execution_workspace_retains_source_cancel(source: dict, tmp_path: Path, fault: str): + _goal_document(source) + target, prepared = _prepared(source, tmp_path) + worktree = target["project"].parent / "agent-workspace" + if fault == "changed_input": + (worktree / "input.txt").write_text("Changed after review\n") + elif fault == "missing_document": + (worktree / "requirements/GOAL.md").unlink() + elif fault == "source_tracked_change": + (source["project"] / "input.txt").write_text("Later source work remains on source\n") + elif fault == "source_document_change": + (source["project"] / "requirements/GOAL.md").write_text("Later source requirements remain on source\n") + elif fault == "unreviewed_file": + (worktree / "AGENTS.md").write_text("Unreviewed instructions remain outside the cutover\n") + else: + subprocess.run(["git", "-C", str(worktree), "remote", "set-url", "origin", "https://example.org/other/repository.git"], check=True) + server, thread, call = _http(source) + try: + review = prepared["prepared_cutover"] + assert call({"action": "confirm", "goal_id": "report", "operation_id": OPERATION, + "preview_sha256": review["preview_sha256"]})[0] == 400 + view = call({"action": "inspect", "goal_id": "report"})[1] + assert view["cancel_available"] and view["prepared_cutover_unavailable"] + assert call({"action": "cancel", "goal_id": "report", "operation_id": OPERATION})[0] == 200 + lease = source["acquire"]("resume-after-execution-workspace-refusal")["lease"] + source["release"](lease) + if fault == "changed_input": + assert (worktree / "input.txt").read_text() == "Changed after review\n" + elif fault == "missing_document": + assert not (worktree / "requirements/GOAL.md").exists() + elif fault == "source_tracked_change": + assert (source["project"] / "input.txt").read_text() == "Later source work remains on source\n" + elif fault == "source_document_change": + assert (source["project"] / "requirements/GOAL.md").read_text() == "Later source requirements remain on source\n" + elif fault == "unreviewed_file": + assert (worktree / "AGENTS.md").is_file() + finally: + server.shutdown() + thread.join(5) + server.server_close() + + +@pytest.mark.parametrize("fault", ["dirty", "wrong_repository", "tracked_link", "agent_dirty", "agent_branch", "agent_untracked", "agent_ignored", "unknown_workspace"]) +def test_code_repository_dependencies_refuse_before_stopping_source(source: dict, tmp_path: Path, fault: str): + project = source["project"] + if fault == "dirty": + (project / "input.txt").write_text("Unreviewed tracked change\n") + elif fault == "wrong_repository": + subprocess.run(["git", "-C", str(project), "remote", "set-url", "origin", "https://example.org/other/project.git"], check=True) + elif fault == "unknown_workspace": + source["cli"]("todo", "add", "--goal-id", "report", "--role", "agent", "--claimed-by", "agent-a", + "--text", "Unstarted code work needs its own source workspace decision", "--task-repository", "git:example.org/fixtures/agent-transfer", + "--required-write-scope", "report.txt", "--operation-id", "no-source-workspace") + elif fault.startswith("agent_"): + agent_workspace = project.parent / "agent" + if fault in ("agent_untracked", "agent_ignored"): + (agent_workspace / "report.txt").write_text("Unfinished original Agent work\n") + if fault == "agent_ignored": + with (project / ".git/info/exclude").open("a") as ignored: + ignored.write("\nreport.txt\n") + else: + (agent_workspace / "input.txt").write_text("Unfinished original Agent edits\n") + if fault == "agent_branch": + subprocess.run(["git", "-C", str(agent_workspace), "add", "input.txt"], check=True) + subprocess.run(["git", "-C", str(agent_workspace), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.org", + "commit", "-qm", "Unmerged original Agent work"], check=True) + else: + (project / "linked").symlink_to("input.txt") + subprocess.run(["git", "-C", str(project), "add", "linked"], check=True) + subprocess.run(["git", "-C", str(project), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.org", + "commit", "-qm", "Unsupported linked source"], check=True) + before = source["fence"].read_bytes(), source["registry"].read_bytes() + destination = tmp_path / "unprepared-repository" + result = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", "--operation-id", OPERATION, + "--destination-root", str(destination), "--material", str(project / "input.txt"), "--execute", expected_code=1) + assert result["ok"] is False and not destination.exists() + assert before == (source["fence"].read_bytes(), source["registry"].read_bytes()) + + def _prepared(source: dict, root: Path) -> tuple[dict, dict]: destination = root / "local-move" view = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", "--operation-id", OPERATION, From 47c1594eaf42d2a573d7a1e1c8747f933f75419b Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:34:26 +0800 Subject: [PATCH 22/55] fix(migration): carry the original Agent unfinished working tree Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/research-checkpoint.md | 49 +++-- .../planned_transfer_authority.ts | 2 +- loopx/control_plane/goals/planned_transfer.ts | 7 +- .../goals/planned_transfer_continuation.ts | 6 +- .../goals/planned_transfer_cutover.ts | 24 +-- .../goals/planned_transfer_payload.ts | 18 +- .../goals/planned_transfer_repository.ts | 187 ++++++++++++++++++ .../goals/planned_transfer_workspace.ts | 187 +++++------------- .../test_planned_transfer_continuation.py | 16 +- .../test_planned_transfer_product.py | 116 +++++++++-- .../planned_transfer_workspace.test.ts | 22 ++- 11 files changed, 431 insertions(+), 203 deletions(-) create mode 100644 loopx/control_plane/goals/planned_transfer_repository.ts diff --git a/docs/reference/research-checkpoint.md b/docs/reference/research-checkpoint.md index 08687c4c09..b2b41fb66f 100644 --- a/docs/reference/research-checkpoint.md +++ b/docs/reference/research-checkpoint.md @@ -160,24 +160,37 @@ the original context and receipts retain their historical source paths. The native continuation context supplies `current_goal` for current document reads. Later destination edits survive retries of the original confirmation. -When unfinished Tasks name one Git repository, local preparation verifies its -actual origin and clean tracked snapshot before fencing the source. Its tracked -files join the protected material inventory; selected inputs and the primary -Goal document keep their reviewed mappings. Preparation builds a separate local -repository and an independent `codex/` worktree with those files. It uses the -pinned source commit without checkout filters or source hooks/configuration. -Unselected untracked and ignored files remain outside this snapshot. -The native continuation context provides `execution_workspace` for the existing -Task/lease and Turn entrypoints. Changed execution inputs or repository identity -refuse the final confirmation and retain source cancellation. After confirmation, -ordinary destination writes survive retries. This removes manual Git setup from -the local continuation path; responsible-Agent dispatch, required credential -rebinding and cross-machine repository delivery still need their owning journey. -The original Task's recorded workspace must also be available and match that -snapshot. A divergent Agent branch, local edits, uncarried working files or -unknown workspace mapping hold before preparation. A clean canonical checkout -must not silently replace unfinished work in the Agent's actual checkout; -capturing that working state remains part of the complete migration gap. +When unfinished Tasks name one Git repository and recorded working checkout, +local preparation verifies their real origin and Task workspace identity before +fencing the source. The clean canonical checkout does not substitute for the +Agent's branch. Preparation retains the actual branch commit, its ancestry, +exact staged index, later unstaged edits, deletions, executable modes and ordinary +untracked files. Ignored files within the original Task's write scopes or explicit +material selection are included; other ignored data stays outside this profile. +The primary Goal document and selected canonical inputs keep their own mappings. +Different Agent bytes use a separate protected material namespace and an explicit +Task-workspace relative mapping, rather than overwriting the canonical inputs. +Selected contents and Git history are not privacy-certified. + +Preparation builds a separate local repository and independent `codex/` worktree. +The original branch name remains provenance; the destination uses a fresh branch +and must obtain a fresh Task lease and runtime admission. Source Git configuration, +credential bindings, hooks, index files and native sessions are not installed. Only the +recorded Git objects and reviewed working files are used. The continuation context +provides `execution_workspace` and its `execution_materials` mapping to the +existing Task/lease and Turn entrypoints. Canonical Goal materials remain +separately identifiable when their bytes differ from the unfinished work. +A changed source tree/index or destination tree/index refuses the final confirmation +and retains source cancellation. After admission, normal new edits and commits +survive retries of the original confirmation and Turn. + +Distinct source workspaces, unknown mappings, links/submodules, conflicted or +special index states, and dirty canonical checkouts still hold before preparation. +These holds do not complete the full profile. Responsible-Agent App dispatch, +required credential/configuration rebinding, full Host/effect drain, secure +cross-machine delivery and the convenience target remain unqualified. This local +path never requires discarding or committing an Agent's pending edits merely to +satisfy a clean-checkout test. The local configuration and required-Task capability checks run before source preparation as well as at final cutover. Missing, unsafe or unmapped documents, diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index b88265da87..af653f5b0f 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -366,7 +366,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro head, identity.store_identity, goal, operation); if (value.registered_source_preview_sha256 !== preview.preview_sha256) reject("Reviewed transfer source preview changed; review the current source without executing it"); } - const protectedInputs = protectedExport ? await reviewProtectedGoalTransferInputs(value.protected_transport, context!, value.source_package_version) : undefined; + const protectedInputs = protectedExport ? await reviewProtectedGoalTransferInputs(value.protected_transport, context!, value.source_package_version, head.head) : undefined; await mkdir(directory, {recursive: true, mode: 0o700}); await retain(pendingPath, preparation); await preparePlannedTransferWriterFenceUnderLock(root, goal, priorFence, sourcePreparationFence(goal, operation, preparation)); diff --git a/loopx/control_plane/goals/planned_transfer.ts b/loopx/control_plane/goals/planned_transfer.ts index 759ab03963..958094b4cf 100644 --- a/loopx/control_plane/goals/planned_transfer.ts +++ b/loopx/control_plane/goals/planned_transfer.ts @@ -24,7 +24,7 @@ export const PREPARATION_SCHEMA = "loopx_planned_transfer_authority_preparation_ const executeFile = promisify(execFile); export interface PlannedTransferMaterialIdentity extends JsonObject { - id: string; source_relative_path: string; sha256: string; bytes: number; + id: string; source_relative_path: string; sha256: string; bytes: number; task_workspace_relative_path?: string; } export interface ProtectedGoalMaterial extends PlannedTransferMaterialIdentity {path: string} @@ -33,11 +33,14 @@ export interface ProtectedGoalMaterial extends PlannedTransferMaterialIdentity { * The custody reader separately bounds and verifies the actual file bytes. */ export function decodePlannedTransferMaterial(raw: unknown, index: number): PlannedTransferMaterialIdentity { const row = requireJsonObject(raw, "original Goal material"); - if (!hasExactAuthorityKeys(row, ["id", "source_relative_path", "sha256", "bytes"]) || + if (!hasExactAuthorityKeys(row, ["id", "source_relative_path", "sha256", "bytes", ...(row.task_workspace_relative_path !== undefined ? ["task_workspace_relative_path"] : [])]) || row.id !== (index + 2).toString(16).padStart(8, "0") || !BARE_SHA256_PATTERN.test(String(row.sha256)) || typeof row.bytes !== "number" || !Number.isSafeInteger(row.bytes) || row.bytes < 0 || typeof row.source_relative_path !== "string" || !row.source_relative_path || row.source_relative_path.startsWith("/") || row.source_relative_path.includes("\0") || row.source_relative_path.split("/").some(part => part === "" || part === "." || part === "..")) reject("Original Goal material identity is invalid"); + if (row.task_workspace_relative_path !== undefined && (typeof row.task_workspace_relative_path !== "string" || + !row.task_workspace_relative_path || row.task_workspace_relative_path.startsWith("/") || row.task_workspace_relative_path.includes("\0") || + row.task_workspace_relative_path.split("/").some(part => !part || [".", "..", ".git", ".loopx"].includes(part)))) reject("Original Task workspace material path is invalid"); return row as PlannedTransferMaterialIdentity; } diff --git a/loopx/control_plane/goals/planned_transfer_continuation.ts b/loopx/control_plane/goals/planned_transfer_continuation.ts index 59895d3af4..2cecec5116 100644 --- a/loopx/control_plane/goals/planned_transfer_continuation.ts +++ b/loopx/control_plane/goals/planned_transfer_continuation.ts @@ -15,6 +15,7 @@ import {openLocalAuthorityStoreHandle, requireLocalAuthorityRuntimeRoot} from ". import {indexCoordinationProjection} from "../coordination/coordination_projection.ts"; import {readGoalTransferSnapshot, checkGoalTransferSourceContext} from "./planned_transfer.ts"; import {readGoalTransferHistoryJson} from "./planned_transfer_history.ts"; +import {repositoryMaterialMapping, type TransferRepository} from "./planned_transfer_repository.ts"; import {verifyGoalTransferExecutionWorkspace} from "./planned_transfer_workspace.ts"; function reject(message: string): never {throw new EffectRuntimeConflictError(message);} @@ -181,10 +182,11 @@ export async function readGoalTransferHostContext(value: JsonObject): Promise ({...item.row, relative_path: item.path}))} : {}), todos: index.todo_ids.map(id => index.todos.get(id)!), materials, original_accounting: history.provenance, source_sessions_adopted: false, credentials_adopted: false, - guidance: "Continue the original work using current Task/lease and Host authority. Read required Goal documents through current_goal and the reviewed destination material paths; original_goal retains historical source references. Historical decisions and receipts grant no execution permission."}}; + guidance: "Continue the original work using current Task/lease and Host authority. Read required Goal documents through current_goal and the reviewed destination material paths. When present, execution_materials maps the actual unfinished inputs within execution_workspace; original_goal retains historical source references. Historical decisions and receipts grant no execution permission."}}; } /** The existing Settings/CLI entry also works after moving to the destination. diff --git a/loopx/control_plane/goals/planned_transfer_cutover.ts b/loopx/control_plane/goals/planned_transfer_cutover.ts index 9aecf879d0..9ed0eae030 100644 --- a/loopx/control_plane/goals/planned_transfer_cutover.ts +++ b/loopx/control_plane/goals/planned_transfer_cutover.ts @@ -27,8 +27,9 @@ import {requirePhysicalTransferPath, inspectTransferFile} from "./planned_transf import {decodePlannedTransferMaterial, readGoalTransferSnapshot, withVerifiedGoalTransferContext} from "./planned_transfer.ts"; import {localTransferGoalConfiguration, localTransferGoalDocuments, localTransferMappedGoalDocuments, requireLocalTransferTaskBindings, planReviewedTransferContinuation, readReviewedTransferContext} from "./planned_transfer_continuation.ts"; -import {planGoalTransferWorkspace, planGoalTransferRepository, prepareGoalTransferExecutionWorkspace, +import {planGoalTransferWorkspace, prepareGoalTransferExecutionWorkspace, readGoalTransferExecutionWorkspace} from "./planned_transfer_workspace.ts"; +import {planGoalTransferRepository, goalTransferMaterialSource, type TransferRepository} from "./planned_transfer_repository.ts"; import {observeLocalWorkspaceHostIdentity} from "../work_items/task_lease_workspace.ts"; import {reviewGoalTransferMaterialInputs} from "./planned_transfer_payload.ts"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; @@ -95,13 +96,13 @@ export async function planLocalGoalTransferPreparation(value: JsonObject): Promi if (!materials.some(row => row.path === physical)) materials.push({path: physical, sha256: (await inspectTransferFile(physical)).sha256}); } const repository = await planGoalTransferRepository(goal, source.projection, - materials.map(row => relative(String(goal.repo), String(row.path)))); + materials.map(row => String(row.path))); if (repository && entries.includes("agent-workspace") && source.preparedOperation !== value.operation_id) reject("Destination execution workspace is occupied; preserve the source and destination"); for (const file of repository?.files ?? []) { - const physical = await requirePhysicalTransferPath(resolve(String(goal.repo), file.path)); + const physical = await requirePhysicalTransferPath(file.source_path); if (!materials.some(row => row.path === physical)) materials.push({path: physical, sha256: (await inspectTransferFile(physical)).sha256}); } - const reviewed = await reviewGoalTransferMaterialInputs(materials, source.context); + const reviewed = await reviewGoalTransferMaterialInputs(materials, source.context, source.projection); // Discover reserved/colliding destinations before any source fence or key // directory is published, using the same workspace placement owner. planGoalTransferWorkspace(join(destination, "workspace"), join(destination, "runtime"), @@ -141,13 +142,14 @@ export async function retainLocalGoalTransferCutoverReview(value: JsonObject): P const source = requireJsonObject(request.source, "original source"); const context = requireJsonObject(requireJsonObject(request.target_snapshot, "target snapshot").source_context, "target context"); const directory = operationDirectory(String(target.runtime_root), String(decision.goal_id), String(decision.operation_id), false); - await withShadowMaintenanceLock(String(target.runtime_root), String(decision.goal_id), async () => { + await visitRegisteredTransferSource(source, String(decision.operation_id), current => + withShadowMaintenanceLock(String(target.runtime_root), String(decision.goal_id), async () => { const staged = await readGoalTransferHistoryJson(join(directory, "preparation.json")); await prepareGoalTransferExecutionWorkspace(directory, requireJsonObject(requireJsonObject(source.source_context, "original context").goal, "original Goal"), - requireJsonObject(repository, "reviewed source repository"), requireJsonObject(staged.protected_transport, "staged materials"), - context, requireJsonObject(requireJsonObject(request.target_snapshot, "target snapshot").registry_witness, "target witness")); - }); + requireJsonObject(repository, "reviewed source repository") as TransferRepository, requireJsonObject(staged.protected_transport, "staged materials"), + context, requireJsonObject(requireJsonObject(request.target_snapshot, "target snapshot").registry_witness, "target witness"), current.projection); + })); } const plan = await planLocalGoalTransferCutover(request); const decision = requireJsonObject(plan.decision, "reviewed original cutover"), target = requireJsonObject(decision.target, "reviewed destination"); @@ -359,11 +361,11 @@ export async function planLocalGoalTransferCutover(value: JsonObject): Promise String(row.source_relative_path))); + (materials.materials as JsonObject[]).map(row => goalTransferMaterialSource(String(originalGoal.repo), requireJsonObject(executionWorkspace.repository, "original repository"), row))); if (!repository || !same(repository, executionWorkspace.repository)) reject("Original source repository changed after preparation; preserve its new work and use source cancellation"); for (const row of materials.materials as JsonObject[]) { - if (repository.files.some(file => file.path === row.source_relative_path)) continue; - const path = await requirePhysicalTransferPath(resolve(String(originalGoal.repo), String(row.source_relative_path))); + if (repository.files.some(file => file.material_path === row.source_relative_path)) continue; + const path = await requirePhysicalTransferPath(goalTransferMaterialSource(String(originalGoal.repo), repository, row)); if (!same(await inspectTransferFile(path), {sha256: row.sha256, bytes: row.bytes})) reject("Original source input changed after preparation; preserve it and use source cancellation"); } } diff --git a/loopx/control_plane/goals/planned_transfer_payload.ts b/loopx/control_plane/goals/planned_transfer_payload.ts index 43379dfc89..0303db3233 100644 --- a/loopx/control_plane/goals/planned_transfer_payload.ts +++ b/loopx/control_plane/goals/planned_transfer_payload.ts @@ -13,6 +13,7 @@ import {PREPARATION_SCHEMA, decodePlannedTransferMaterial, type ProtectedGoalMat import {inspectTransferFile, requirePhysicalTransferPath, withVerifiedTransferContainer, validatePlannedTransferRecipient, writeTransferContainer, type TransferContainerFile} from "./planned_transfer_container.ts"; +import {goalTransferMaterialPath, locateGoalTransferAgentWorkspace} from "./planned_transfer_repository.ts"; import {decodeGoalTransferHistory, goalTransferHistoryInputs, type ProtectedGoalTransferHistory} from "./planned_transfer_history.ts"; const PAYLOAD_SCHEMA = "loopx_planned_transfer_payload_v1"; @@ -35,7 +36,7 @@ export interface VerifiedGoalTransferPayload { * original registry, native revision, complete archive and source fence check. * Materials are an explicit reviewed selection, not a completeness/privacy * certification. No machine root, registry inventory or Git metadata is swept. */ -export async function reviewProtectedGoalTransferInputs(raw: unknown, context: JsonObject, packageVersion: unknown) { +export async function reviewProtectedGoalTransferInputs(raw: unknown, context: JsonObject, packageVersion: unknown, projection?: JsonObject) { if (process.platform === "win32") reject("Protected source transfer requires the local POSIX profile before preparation"); const transport = requireJsonObject(raw, "protected transfer request"); if (!hasExactAuthorityKeys(transport, ["destination", "recipient", "materials"]) || !Array.isArray(transport.materials)) { @@ -43,31 +44,32 @@ export async function reviewProtectedGoalTransferInputs(raw: unknown, context: J } const recipient = validatePlannedTransferRecipient(transport.recipient); const destination = await requirePhysicalTransferPath(transport.destination), sourceVersion = version(packageVersion); - return {recipient, destination, sourceVersion, ...await reviewGoalTransferMaterialInputs(transport.materials, context)}; + return {recipient, destination, sourceVersion, ...await reviewGoalTransferMaterialInputs(transport.materials, context, projection)}; } /** The Agent's explicit workspace selection is checked before preparation and * again under the source owner's export locks. This is not a credential scan * or a claim that every dependency has been selected. */ -export async function reviewGoalTransferMaterialInputs(raw: unknown, context: JsonObject) { +export async function reviewGoalTransferMaterialInputs(raw: unknown, context: JsonObject, projection?: JsonObject) { if (!Array.isArray(raw)) reject("Review an explicit workspace material selection"); const goal = requireJsonObject(context.goal, "original Goal"); const project = await requirePhysicalTransferPath(goal.repo), runtime = await requirePhysicalTransferPath(context.runtime_root); const registry = await requirePhysicalTransferPath(context.registry_path); const files: TransferContainerFile[] = []; const materials: JsonObject[] = [], seen = new Set(); + const outside = raw.some(item => !beneath(project, String(requireJsonObject(item, "reviewed Goal material").path))); + const workspace = outside && projection ? (await locateGoalTransferAgentWorkspace(goal, projection))?.path ?? null : null; for (const [index, rawMaterial] of raw.entries()) { const material = requireJsonObject(rawMaterial, "reviewed Goal material"); if (!hasExactAuthorityKeys(material, ["path", "sha256"]) || !BARE_SHA256_PATTERN.test(String(material.sha256))) reject("Goal material requires its reviewed content digest"); - const path = await requirePhysicalTransferPath(material.path), suffix = relative(project, path); - if (!beneath(project, path) || path === project || beneath(runtime, path) || path === registry || - beneath(join(project, ".git"), path) || seen.has(path)) reject("Transfer material must be unique workspace input outside runtime, registry and Git metadata"); + const path = await requirePhysicalTransferPath(material.path), suffix = goalTransferMaterialPath(project, workspace, path); + if (beneath(runtime, path) || path === registry || seen.has(suffix)) reject("Transfer material must be unique workspace input outside runtime, registry and Git metadata"); if ((await lstat(path)).nlink !== 1) reject("Transfer material must not alias another file through a hard link"); const facts = await inspectTransferFile(path); if (facts.sha256 !== material.sha256) reject("Reviewed Goal material changed; review its current bytes before exporting"); - seen.add(path); files.push({path, ...facts}); + seen.add(suffix); files.push({path, ...facts}); materials.push({id: (index + 2).toString(16).padStart(8, "0"), source_relative_path: suffix, - ...facts}); + ...(!beneath(project, path) ? {task_workspace_relative_path: relative(workspace!, path)} : {}), ...facts}); } return {files, materials}; } diff --git a/loopx/control_plane/goals/planned_transfer_repository.ts b/loopx/control_plane/goals/planned_transfer_repository.ts new file mode 100644 index 0000000000..2082df58c4 --- /dev/null +++ b/loopx/control_plane/goals/planned_transfer_repository.ts @@ -0,0 +1,187 @@ +/** Local Git data capture for the existing planned-transfer owner. Historical + * Task workspace identities locate data only; they grant no execution. */ +import {execFile} from "node:child_process"; +import {readFile, stat} from "node:fs/promises"; +import {createHash} from "node:crypto"; +import {isAbsolute, relative, resolve, sep} from "node:path"; +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; +import {canonicalAuthorityBytes} from "../coordination/authority_store_codec.ts"; +import {indexCoordinationProjection} from "../coordination/coordination_projection.ts"; +import {normalizeTodoRepository} from "../todos/work_requirements.ts"; +import {observeLeaseWorktree, sameLeaseWorkspace} from "../work_items/task_lease_workspace.ts"; +import {evaluateTaskLeaseWriteScopesOverlap} from "../work_items/task_lease_acquire_decision.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {inspectTransferFile, requirePhysicalTransferPath} from "./planned_transfer_container.ts"; + +interface RepositoryEntry extends JsonObject {path: string; mode: string; object: string} +interface WorkingFile extends JsonObject {path: string; mode: string; source_path: string; material_path: string; sha256: string; bytes: number} +export interface TransferRepository extends JsonObject { + origin: string; repository: string; commit: string; branch: string; source_workspace: string; + index: RepositoryEntry[]; tracked_paths: string[]; files: WorkingFile[]; +} +const EXECUTION_MATERIAL_DIRECTORY = "agent-workspace/"; +export const executionWorkspaceScopes = (path: string) => path !== ".git" && !path.startsWith(".git/"); +function reject(message: string): never {throw new EffectRuntimeConflictError(message);} +function same(a: unknown, b: unknown): boolean {return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b));} +function beneath(root: string, path: string): boolean { + const suffix = relative(root, path); + return suffix === "" || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)); +} +function filePath(path: string): string { + if (!path || isAbsolute(path) || path.includes("\0") || path.split(sep).some(part => + !part || [".", "..", ".git", ".loopx"].includes(part))) reject("Repository material contains reserved or redirected control paths"); + return path; +} + +/** Fixed local IO; never inherit alternate Git roots, templates, helpers, + * hooks, global filters or transport configuration. Stdin is data, not shell. */ +export async function gitAt(path: string, args: readonly string[], input?: string): Promise { + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))); + return new Promise((accept, refuse) => { + const child = execFile("git", ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", + "-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "credential.helper=", "-C", path, ...args], + {env: {...env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0"}, + timeout: 30_000, maxBuffer: 4 * 1024 * 1024}, (error, stdout) => error ? refuse(error) : accept(stdout)); + child.stdin?.on("error", refuse); + child.stdin?.end(input); + }); +} +async function repositoryIdentity(root: string, repository: string) { + await requirePhysicalTransferPath(root); + if ((await gitAt(root, ["rev-parse", "--show-toplevel"])).trim() !== root) reject("Transfer needs the original repository root"); + const origin = (await gitAt(root, ["remote", "get-url", "origin"])).trim(); + const scp = /^git@([^/:]+):(.+)$/u.exec(origin); + let url: URL; + try {url = new URL(scp ? `ssh://${scp[1]}/${scp[2]}` : origin);} catch {reject("Source repository origin is unavailable");} + if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol) || !url.hostname || url.password || url.search || url.hash || + (url.username && !(url.protocol === "ssh:" && url.username === "git")) || normalizeTodoRepository(origin) !== repository) { + reject("Source origin differs from the original Task repository or needs credential rebinding"); + } + return {origin, repository, commit: (await gitAt(root, ["rev-parse", "--verify", "HEAD^{commit}"])).trim()}; +} +function entries(raw: string, index: boolean): RepositoryEntry[] { + return raw.split("\0").filter(Boolean).map(line => { + const match = (index ? /^(100644|100755) ([a-f0-9]{40}|[a-f0-9]{64}) 0\t([\s\S]+)$/u + : /^(100644|100755) blob ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/u).exec(line); + if (!match) reject("Repository links, submodules or conflicted index require their owning transfer profile"); + return {mode: match[1]!, object: match[2]!, path: filePath(match[3]!)}; + }); +} + +/** Only the original canonical Task leases select source data locations. A + * caller-supplied path cannot nominate another checkout with a similar name. */ +export async function locateGoalTransferAgentWorkspace(original: JsonObject, projection: JsonObject) { + const index = indexCoordinationProjection(projection, String(original.id)); + const repositories = new Set(), bindings: JsonObject[] = []; + for (const [id, todo] of index.todos) { + if (todo.done === true || todo.archive_state === "archive" || !todo.task_repository) continue; + const repository = normalizeTodoRepository(todo.task_repository); + if (!repository) reject("Original Task repository is unavailable"); + repositories.add(repository); + const lease = index.leases.get(id); + if (!lease?.write_workspace) reject("Original code Task needs its source workspace mapping before preparation"); + bindings.push({identity: requireJsonObject(lease.write_workspace, "original Task workspace"), scopes: lease.write_scopes}); + } + if (!repositories.size) return null; + if (repositories.size !== 1) reject("Multiple original repositories require their owning workspace mapping before preparation"); + const project = String(original.repo), repository = [...repositories][0]!; + const paths = (await gitAt(project, ["worktree", "list", "--porcelain", "-z"])) + .split("\0").filter(field => field.startsWith("worktree ")).map(field => field.slice(9)); + const matched = new Set(); + for (const binding of bindings) { + const workspace = requireJsonObject(binding.identity, "original workspace identity"); + let actual: string | null = null; + for (const path of paths) { + try {const info = await stat(path); + if (createHash("sha256").update(`${info.dev}:${info.ino}`).digest("hex") === workspace.worktree) actual = path; + } catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} + } + if (!actual || !sameLeaseWorkspace(await observeLeaseWorktree(actual, () => false), workspace)) { + reject("Original Agent workspace is unavailable; do not replace it with the canonical checkout"); + } + await repositoryIdentity(actual, repository); + matched.add(actual); + } + if (matched.size !== 1) reject("Distinct original Agent workspaces require their owning mapping before preparation"); + return {path: [...matched][0]!, repository, scopes: bindings.flatMap(binding => binding.scopes as string[])}; +} + +/** A transport namespace separates canonical Goal inputs from different actual + * Agent bytes. It is a material mapping, never a new runtime/authority root. */ +export function goalTransferMaterialPath(project: string, workspace: string | null, path: string): string { + if (beneath(project, path)) return filePath(relative(project, path)); + if (workspace && beneath(workspace, path)) return EXECUTION_MATERIAL_DIRECTORY + filePath(relative(workspace, path)); + reject("Transfer material must belong to the original Goal or its recorded Task workspace"); +} +export function goalTransferMaterialSource(project: string, repository: JsonObject, row: JsonObject): string { + return row.task_workspace_relative_path !== undefined + ? resolve(String(repository.source_workspace), filePath(String(row.task_workspace_relative_path))) + : resolve(project, filePath(String(row.source_relative_path))); +} + +export async function planGoalTransferRepository(original: JsonObject, projection: JsonObject, + selectedPaths: readonly string[]): Promise { + const binding = await locateGoalTransferAgentWorkspace(original, projection); + if (!binding) return null; + const project = String(original.repo), root = binding.path; + const canonical = await repositoryIdentity(project, binding.repository); + // Keep the prior canonical-checkout hold; the actual Agent's branch/index + // and dirty tree below are now captured rather than silently substituted. + for (const entry of entries(await gitAt(project, ["ls-tree", "-rz", "--full-tree", canonical.commit]), false)) { + const path = await requirePhysicalTransferPath(resolve(project, entry.path)), info = await stat(path), bytes = await readFile(path); + const object = createHash(entry.object.length === 40 ? "sha1" : "sha256").update(`blob ${bytes.length}\0`).update(bytes).digest("hex"); + if (!info.isFile() || info.nlink !== 1 || object !== entry.object || ((info.mode & 0o111) !== 0) !== (entry.mode === "100755")) reject("Tracked canonical repository changed before preparation"); + } + if ((await gitAt(project, ["diff-index", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", canonical.commit, "--"])).trim()) reject("Canonical index changed before preparation"); + const identity = await repositoryIdentity(root, binding.repository); + const branch = (await gitAt(root, ["rev-parse", "--symbolic-full-name", "HEAD"])).trim(); + const rawIndex = await gitAt(root, ["ls-files", "--stage", "-z"]), index = entries(rawIndex, true); + const flags = (await gitAt(root, ["ls-files", "-v", "-z"])).split("\0").filter(Boolean); + if (flags.some(row => !row.startsWith("H ")) || + await gitAt(root, ["diff", "--cached", "--raw", "--ita-visible-in-index"]) !== await gitAt(root, ["diff", "--cached", "--raw", "--ita-invisible-in-index"])) { + reject("Sparse, assumed-unchanged or intent-to-add index needs its owning transfer profile"); + } + const tracked = new Set([...entries(await gitAt(root, ["ls-tree", "-rz", "--full-tree", identity.commit]), false), ...index].map(row => row.path)); + const selected = new Set(selectedPaths); + const ordinary = new Set((await gitAt(root, ["ls-files", "--others", "--exclude-standard", "-z"])).split("\0").filter(Boolean)); + const paths = new Set(tracked); + for (const path of (await gitAt(root, ["ls-files", "--others", "-z"])).split("\0").filter(Boolean)) { + if (ordinary.has(path) || selected.has(resolve(root, path)) || selected.has(resolve(project, path)) || + evaluateTaskLeaseWriteScopesOverlap({left: binding.scopes, right: [path]}).overlap === true) paths.add(filePath(path)); + } + const files: WorkingFile[] = []; + for (const path of [...paths].sort()) { + const source = await requirePhysicalTransferPath(resolve(root, filePath(path))); + let info; + try {info = await stat(source);} catch (error) {if ((error as NodeJS.ErrnoException).code === "ENOENT" && tracked.has(path)) continue; throw error;} + if (!info.isFile() || info.nlink !== 1) reject("Original working input must be a regular, unlinked file"); + const facts = await inspectTransferFile(source); + let input = source; + const canonicalPath = resolve(project, path); + try {if (same(await inspectTransferFile(await requirePhysicalTransferPath(canonicalPath)), facts)) input = canonicalPath;} + catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} + files.push({path, mode: (info.mode & 0o111) !== 0 ? "100755" : "100644", source_path: input, + material_path: goalTransferMaterialPath(project, root, input), ...facts}); + } + if (!same(identity, await repositoryIdentity(root, binding.repository)) || branch !== (await gitAt(root, ["rev-parse", "--symbolic-full-name", "HEAD"])).trim() || + rawIndex !== await gitAt(root, ["ls-files", "--stage", "-z"])) reject("Original Agent repository changed during preparation"); + return {...identity, branch, source_workspace: root, index, tracked_paths: [...tracked].sort(), files}; +} + +/** Preserve current file/deletion state while keeping the exact staged index. + * Canonical-only primary documents remain available unless the actual tracked + * tree explicitly deleted them. Conflicting destinations are refused. */ +export function repositoryMaterialMapping(repository: TransferRepository, materials: JsonObject) { + const selected = new Map(); + for (const row of materials.materials as JsonObject[]) { + const path = String(row.source_relative_path); + if (row.task_workspace_relative_path === undefined && !repository.tracked_paths.includes(path)) selected.set(path, row); + } + for (const file of repository.files) { + const row = (materials.materials as JsonObject[]).find(item => item.source_relative_path === file.material_path); + if (!row || row.sha256 !== file.sha256 || row.bytes !== file.bytes) reject("Original working file is absent from the protected inventory"); + selected.set(file.path, row); + } + return [...selected].map(([path, row]) => ({row, path})); +} diff --git a/loopx/control_plane/goals/planned_transfer_workspace.ts b/loopx/control_plane/goals/planned_transfer_workspace.ts index 5370c41363..6c087fa2c8 100644 --- a/loopx/control_plane/goals/planned_transfer_workspace.ts +++ b/loopx/control_plane/goals/planned_transfer_workspace.ts @@ -3,9 +3,6 @@ * remains with its owner; selected bytes are not privacy-certified here. */ import {constants} from "node:fs"; import {copyFile, mkdir, open, readFile, chmod, stat} from "node:fs/promises"; -import {execFile} from "node:child_process"; -import {promisify} from "node:util"; -import {createHash} from "node:crypto"; import {dirname, isAbsolute, join, relative, resolve, sep} from "node:path"; import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeConflictError} from "../effect_runtime_errors.ts"; @@ -15,10 +12,9 @@ import {requireJsonObject} from "../runtime_decode.ts"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import {inspectTransferFile, requirePhysicalTransferPath} from "./planned_transfer_container.ts"; import {decodePlannedTransferMaterial, type ProtectedGoalMaterial} from "./planned_transfer.ts"; -import {indexCoordinationProjection} from "../coordination/coordination_projection.ts"; -import {normalizeTodoRepository} from "../todos/work_requirements.ts"; -import {observeLeaseWorktree, sameLeaseWorkspace} from "../work_items/task_lease_workspace.ts"; -import {evaluateTaskLeaseWriteScopesOverlap} from "../work_items/task_lease_acquire_decision.ts"; +import {observeLeaseWorktree} from "../work_items/task_lease_workspace.ts"; + +import {gitAt, executionWorkspaceScopes, repositoryMaterialMapping, planGoalTransferRepository, goalTransferMaterialSource, type TransferRepository} from "./planned_transfer_repository.ts"; interface WorkspaceMaterial {source: ProtectedGoalMaterial; destination: string; relativePath: string} export interface GoalTransferWorkspacePlan {materials: WorkspaceMaterial[]; destinations: JsonObject[]} @@ -147,137 +143,37 @@ export async function retainGoalTransferWorkspace(plan: GoalTransferWorkspacePla } } -interface RepositoryFile extends JsonObject {path: string; mode: string; object: string} -interface TransferRepository extends JsonObject {origin: string; repository: string; commit: string; files: RepositoryFile[]} -const runGit = promisify(execFile); -const workspaceScopes = (path: string) => path !== ".git" && !path.startsWith(".git/"); - -/** Git is only local snapshot IO here. Do not inherit an alternate Git root, - * templates, credential helpers, hooks, global filters or transport settings. */ -async function gitAt(path: string, ...args: string[]): Promise { - const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))); - return (await runGit("git", ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", - "-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "credential.helper=", "-C", path, ...args], - {env: {...env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0"}, - timeout: 30_000, maxBuffer: 4 * 1024 * 1024})).stdout; -} - -async function observeRepository(project: string, repository: string): Promise { - const root = await requirePhysicalTransferPath(project); - try { - if ((await gitAt(root, "rev-parse", "--show-toplevel")).trim() !== root) reject("Transfer needs the original repository root"); - const origin = (await gitAt(root, "remote", "get-url", "origin")).trim(); - // A source credential/configuration is not a destination credential. Only - // credential-free ordinary Git origins may enter this local snapshot. - const scp = /^git@([^/:]+):(.+)$/u.exec(origin); - const url = new URL(scp ? `ssh://${scp[1]}/${scp[2]}` : origin); - if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol) || !url.hostname || - url.password || url.search || url.hash || (url.username && !(url.protocol === "ssh:" && url.username === "git")) || - normalizeTodoRepository(origin) !== repository) reject("Source origin differs from the original Task repository or needs credential rebinding"); - const commit = (await gitAt(root, "rev-parse", "--verify", "HEAD^{commit}")).trim(); - const entries = (await gitAt(root, "ls-tree", "-rz", "--full-tree", commit)).split("\0").filter(Boolean); - const files: RepositoryFile[] = []; - for (const entry of entries) { - const match = /^(100644|100755) blob ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/u.exec(entry); - if (!match) reject("Repository links/submodules need their owning transfer profile before preparation"); - const mode = match[1]!, object = match[2]!, path = match[3]!; - const physical = await requirePhysicalTransferPath(resolve(root, path)); - const child = relative(root, physical); - if (!child || isAbsolute(child) || child === ".." || child.startsWith(`..${sep}`) || - child.split(sep).some(part => part === ".git" || part === ".loopx")) reject("Repository snapshot contains reserved or redirected control paths"); - const info = await stat(physical), bytes = await readFile(physical); - const objectHash = createHash(object.length === 40 ? "sha1" : "sha256") - .update(`blob ${bytes.length}\0`).update(bytes).digest("hex"); - if (!info.isFile() || info.nlink !== 1 || objectHash !== object || - ((info.mode & 0o111) !== 0) !== (mode === "100755")) reject("Tracked repository changes require their source owner before preparation"); - files.push({path, mode, object}); - } - // Deleted/added tracked entries and an index-only change must not disappear - // behind a clean-looking worktree or a selected material override. - if ((await gitAt(root, "diff-index", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", commit, "--")).trim()) reject("Staged repository changes require their source owner before preparation"); - if ((await gitAt(root, "rev-parse", "HEAD")).trim() !== commit) reject("Source repository changed during preparation"); - return {origin, repository, commit, files}; - } catch (error) { - if (error instanceof EffectRuntimeConflictError) throw error; - reject("Original repository snapshot is unavailable; preserve the source and resolve its repository owner"); - } -} - -/** Code Tasks keep their real repository and tracked dependency closure. This - * is not a directory sweep: unselected untracked/ignored files stay outside the - * snapshot. Machine/Git configuration is not adopted; selected bytes are not - * privacy-certified. */ -export async function planGoalTransferRepository(original: JsonObject, projection: JsonObject, - selectedPaths: readonly string[]): Promise { - const repositories = new Set(); - const index = indexCoordinationProjection(projection, String(original.id)); - const workspaces: JsonObject[] = []; - for (const [id, todo] of index.todos) { - if (todo.done === true || todo.archive_state === "archive" || !todo.task_repository) continue; - const repository = normalizeTodoRepository(todo.task_repository); - if (!repository) reject("Original Task repository is unavailable"); - repositories.add(repository); - const lease = index.leases.get(id); - if (!lease?.write_workspace) reject("Original code Task needs its source workspace mapping before preparation"); - workspaces.push({identity: requireJsonObject(lease.write_workspace, "original Task workspace"), scopes: lease.write_scopes}); - } - if (!repositories.size) return null; - if (repositories.size !== 1) reject("Multiple original repositories require their owning workspace mapping before preparation"); - const observed = await observeRepository(String(original.repo), [...repositories][0]!); - // A clean canonical checkout alone does not prove the Agent's unfinished - // work is carried. Historical lease identities locate its actual checkout - // for read-only comparison; they grant no current execution permission. - const paths = (await gitAt(String(original.repo), "worktree", "list", "--porcelain", "-z")) - .split("\0").filter(field => field.startsWith("worktree ")).map(field => field.slice(9)); - for (const binding of workspaces) { - const workspace = requireJsonObject(binding.identity, "original workspace identity"); - let actual: string | null = null; - for (const path of paths) { - try { - const info = await stat(path); - if (createHash("sha256").update(`${info.dev}:${info.ino}`).digest("hex") === workspace.worktree) actual = path; - } catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} - } - if (!actual || !sameLeaseWorkspace(await observeLeaseWorktree(actual, () => false), workspace)) reject("Original Agent workspace is unavailable; do not replace it with the canonical checkout"); - if (!same(await observeRepository(actual, observed.repository), observed)) reject("Original Agent branch differs from the canonical snapshot; preserve its unfinished work for workspace mapping"); - const ordinary = new Set((await gitAt(actual, "ls-files", "--others", "--exclude-standard", "-z")).split("\0").filter(Boolean)); - for (const path of (await gitAt(actual, "ls-files", "--others", "-z")).split("\0").filter(Boolean)) { - if (!ordinary.has(path) && !selectedPaths.includes(path) && - evaluateTaskLeaseWriteScopesOverlap({left: binding.scopes, right: [path]}).overlap !== true) continue; - if (!selectedPaths.includes(path) || !same( - await inspectTransferFile(await requirePhysicalTransferPath(resolve(actual, path))), - await inspectTransferFile(await requirePhysicalTransferPath(resolve(String(original.repo), path))))) { - reject("Original Agent has uncarried working files; preserve them for the workspace owner before preparation"); - } - } - } - return observed; -} - /** The existing operation retains a workspace witness, not another admission * grant. Before confirmation it includes unchanged input bytes and HEAD; after * admission ordinary new writes/commits are legal under the fresh Task lease. */ export async function verifyGoalTransferExecutionWorkspace(record: JsonObject, materials: JsonObject, originalFiles: boolean): Promise { const path = await requirePhysicalTransferPath(record.path); - const identity = await observeLeaseWorktree(path, workspaceScopes); + const identity = await observeLeaseWorktree(path, executionWorkspaceScopes); if (!same(identity, record.identity) || record.materials_sha256 !== canonicalAuthoritySha256(materials)) reject("Original execution workspace identity changed; preserve its operation"); if (!originalFiles) return; - const repository = requireJsonObject(record.repository, "original execution repository"); - if ((await gitAt(path, "rev-parse", "HEAD")).trim() !== repository.commit) reject("Reviewed execution repository changed before confirmation"); - const destinations = materials.workspace_destinations as JsonObject[]; - const expectedPaths = new Set(destinations.map(row => row.relative_path)); - if ((await gitAt(path, "ls-files", "--cached", "--others", "-z")).split("\0").filter(Boolean) - .some(file => !expectedPaths.has(file))) reject("Execution workspace contains unreviewed files before confirmation"); - for (const row of materials.materials as JsonObject[]) { - const destination = destinations.find(item => item.id === row.id); - if (!destination) reject("Execution material has no reviewed mapping"); - const physical = await requirePhysicalTransferPath(resolve(path, String(destination.relative_path))); - if (!same(await inspectTransferFile(physical), {sha256: row.sha256, bytes: row.bytes})) reject("Reviewed execution material changed before confirmation"); + const repository = requireJsonObject(record.repository, "original execution repository") as TransferRepository; + if ((await gitAt(path, ["rev-parse", "HEAD"])).trim() !== repository.commit || + (await gitAt(path, ["rev-parse", "--symbolic-full-name", "HEAD"])).trim() !== record.branch) reject("Reviewed execution repository changed before confirmation"); + const selected = repositoryMaterialMapping(repository, materials); + const expectedPaths = new Set(selected.map(item => item.path)); + const index = repository.index.map(row => `${row.mode} ${row.object} 0\t${row.path}\0`).join(""); + if (await gitAt(path, ["ls-files", "--stage", "-z"]) !== index || + (await gitAt(path, ["ls-files", "-v", "-z"])).split("\0").filter(Boolean).some(row => !row.startsWith("H "))) reject("Reviewed execution index changed before confirmation"); + if ((await gitAt(path, ["ls-files", "--cached", "--others", "-z"])).split("\0").filter(Boolean) + .some(file => !expectedPaths.has(file) && !repository.tracked_paths.includes(file))) reject("Execution workspace contains unreviewed files before confirmation"); + for (const item of selected) { + const physical = await requirePhysicalTransferPath(resolve(path, item.path)); + if (!same(await inspectTransferFile(physical), {sha256: item.row.sha256, bytes: item.row.bytes})) reject("Reviewed execution material changed before confirmation"); } - for (const file of repository.files as RepositoryFile[]) { + for (const file of repository.files) { if ((((await stat(resolve(path, file.path))).mode & 0o111) !== 0) !== (file.mode === "100755")) reject("Reviewed execution file mode changed before confirmation"); } + for (const file of repository.tracked_paths) { + if (expectedPaths.has(file)) continue; + try {await stat(resolve(path, file)); reject("Reviewed execution deletion changed before confirmation");} + catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} + } } export async function readGoalTransferExecutionWorkspace(directory: string, materials: JsonObject): Promise { @@ -292,7 +188,7 @@ export async function readGoalTransferExecutionWorkspace(directory: string, mate * lease/session is adopted. The protected material inventory carries the actual * tracked bytes; cross-machine repository delivery remains separately held. */ export async function prepareGoalTransferExecutionWorkspace(directory: string, original: JsonObject, - expected: JsonObject, materials: JsonObject, context: JsonObject, witness: JsonObject): Promise { + expected: TransferRepository, materials: JsonObject, context: JsonObject, witness: JsonObject, projection: JsonObject): Promise { const path = join(dirname(String(requireJsonObject(context.goal, "staged Goal").repo)), "agent-workspace"), bare = join(directory, "repository.git"); const intent = {repository: expected, materials_sha256: canonicalAuthoritySha256(materials), path}; const previous = await optionalRecord(join(directory, "execution-intent.json")); @@ -301,29 +197,34 @@ export async function prepareGoalTransferExecutionWorkspace(directory: string, o await readGoalTransferExecutionWorkspace(directory, materials); return; } if (previous) reject("Execution workspace preparation was interrupted; preserve the target and use the original source cancellation before a new preparation"); - if (!same(await observeRepository(String(original.repo), String(expected.repository)), expected)) reject("Source repository changed after its initial review"); - for (const file of expected.files as RepositoryFile[]) { - if (!(materials.materials as JsonObject[]).some(row => row.source_relative_path === file.path)) reject("Tracked dependency is absent from the protected material inventory"); - } + const current = () => planGoalTransferRepository(original, projection, + (materials.materials as JsonObject[]).map(row => goalTransferMaterialSource(String(original.repo), expected, row))); + if (!same(await current(), expected)) reject("Source repository changed after its initial review"); + const selectedFiles = repositoryMaterialMapping(expected, materials); await retainRecord(join(directory, "execution-intent.json"), intent); await mkdir(bare, {mode: 0o700}); - await gitAt(bare, "init", "--bare", "--template=", `--object-format=${String(expected.commit).length === 64 ? "sha256" : "sha1"}`); - await gitAt(bare, "-c", "protocol.file.allow=always", "fetch", "--no-tags", "--depth=1", "--", String(original.repo), String(expected.commit)); - await gitAt(bare, "remote", "add", "origin", String(expected.origin)); - await gitAt(bare, "worktree", "add", "--no-checkout", "-b", `codex/continue-${canonicalAuthoritySha256(intent).slice(0, 16)}`, path, String(expected.commit)); - await gitAt(path, "read-tree", String(expected.commit)); - const mapping = materials.workspace_destinations as JsonObject[]; - const selected = (materials.materials as JsonObject[]).map((row, index) => ({...decodePlannedTransferMaterial(row, index), - path: resolve(String(requireJsonObject(context.goal, "staged Goal").repo), String(mapping.find(item => item.id === row.id)!.relative_path))})); + await gitAt(bare, ["init", "--bare", "--template=", `--object-format=${expected.commit.length === 64 ? "sha256" : "sha1"}`]); + // Include uncommitted index objects as well as the branch ancestry. Only Git + // objects are imported; source refs/config/hooks/index files are not adopted. + const objects = [...new Set([expected.commit, ...expected.index.map(row => row.object)])]; + await gitAt(bare, ["-c", "protocol.file.allow=always", "fetch", "--no-tags", "--stdin", "--", expected.source_workspace], objects.join("\n") + "\n"); + await gitAt(bare, ["remote", "add", "origin", expected.origin]); + await gitAt(bare, ["worktree", "add", "--no-checkout", "-b", `codex/continue-${canonicalAuthoritySha256(intent).slice(0, 16)}`, path, expected.commit]); + await gitAt(path, ["read-tree", "--empty"]); + await gitAt(path, ["update-index", "-z", "--index-info"], expected.index.map(row => `${row.mode} ${row.object}\t${row.path}\0`).join("")); + const originalMapping = materials.workspace_destinations as JsonObject[]; + const mapping = selectedFiles.map(item => ({id: item.row.id, relative_path: item.path})); + const selected = selectedFiles.map(item => ({...decodePlannedTransferMaterial(item.row, (materials.materials as JsonObject[]).indexOf(item.row)), + path: resolve(String(requireJsonObject(context.goal, "staged Goal").repo), String(originalMapping.find(row => row.id === item.row.id)!.relative_path))})); const plan = planGoalTransferWorkspace(path, String(context.runtime_root), selected, Object.fromEntries(mapping.map(row => [String(row.id), row.relative_path]))); const review = await reviewGoalTransferWorkspace(plan, canonicalAuthoritySha256(materials), context, witness); const identity = {repository: expected, materials_sha256: canonicalAuthoritySha256(materials)}; await verifyGoalTransferWorkspaceReview(plan, review, join(directory, "execution-materials"), identity, review.preview_sha256, false); await retainGoalTransferWorkspace(plan, join(directory, "execution-materials"), identity, review.preview_sha256); - for (const file of expected.files as RepositoryFile[]) await chmod(resolve(path, file.path), file.mode === "100755" ? 0o700 : 0o600); - if (!same(await observeRepository(String(original.repo), String(expected.repository)), expected)) reject("Source repository changed while preparing its execution workspace"); - const record = {...intent, identity: await observeLeaseWorktree(path, workspaceScopes)}; + for (const file of expected.files) await chmod(resolve(path, file.path), file.mode === "100755" ? 0o700 : 0o600); + if (!same(await current(), expected)) reject("Source repository changed while preparing its execution workspace"); + const record = {...intent, branch: (await gitAt(path, ["rev-parse", "--symbolic-full-name", "HEAD"])).trim(), identity: await observeLeaseWorktree(path, executionWorkspaceScopes)}; await verifyGoalTransferExecutionWorkspace(record, materials, true); await retainRecord(join(directory, "execution-workspace.json"), record); } diff --git a/tests/control_plane/test_planned_transfer_continuation.py b/tests/control_plane/test_planned_transfer_continuation.py index f7111fd61b..9a963bb3d1 100644 --- a/tests/control_plane/test_planned_transfer_continuation.py +++ b/tests/control_plane/test_planned_transfer_continuation.py @@ -49,15 +49,24 @@ def test_reviewed_context_admits_a_new_runtime_without_copying_a_session(source: assert "Produce report.txt from input.txt" in context["context"]["requirements"] +@pytest.mark.parametrize("working_state", ["clean", "unfinished"]) @pytest.mark.skipif(not os.environ.get("LOOPX_NATIVE_CHECKPOINT_BIN"), reason="requires the isolated real Codex fixture") -def test_real_fresh_native_turn_finishes_original_work_and_recovers_without_reexecution(source: dict, tmp_path: Path, monkeypatch): +def test_real_fresh_native_turn_finishes_original_work_and_recovers_without_reexecution(source: dict, tmp_path: Path, monkeypatch, working_state: str): from tests.control_plane.codex_checkpoint_fixture import real_native_source _goal_document(source, absolute=True) + if working_state == "unfinished": + actual = source["project"].parent / "agent" + (actual / "branch-context.txt").write_text("Continue this unmerged original branch\n") + subprocess.run(["git", "-C", str(actual), "add", "branch-context.txt"], check=True) + subprocess.run(["git", "-C", str(actual), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.org", + "commit", "-qm", "Original unmerged context"], check=True) + (actual / "input.txt").write_text("5\n7\n9\n") + (actual / "report.txt").write_text("Original unfinished output\n") # Original accepted context and its relation survive; no replacement Todo # is invented after transfer. Expected 21 is fixed before any model output. accepted = source["cli"]("todo", "add", "--goal-id", "report", "--role", "user", "--task-class", "user_action", - "--text", "Accept exactly 21 from the unchanged three input values", "--operation-id", "original-criteria")["todo_id"] + "--text", "Accept exactly 21 from the original three work values", "--operation-id", "original-criteria")["todo_id"] source["cli"]("todo", "complete", "--goal-id", "report", "--role", "user", "--todo-id", accepted, "--agent-id", "agent-a", "--evidence", "input.txt", "--successor-todo-id", source["todo"]) source["cli"]("todo", "archive-completed", "--goal-id", "report", "--role", "user", "--max-active-done", "0", "--execute") @@ -82,6 +91,9 @@ def test_real_fresh_native_turn_finishes_original_work_and_recovers_without_reex "--write-worktree", str(worktree))["lease"] assert lease["lease_epoch"] > 1 and lease["idempotency_key"] != "original-work" script = "from pathlib import Path; assert 'produce exactly 21' in Path('requirements/GOAL.md').read_text(); p=Path('report.txt'); assert not p.exists(); p.write_text(str(sum(map(int,Path('input.txt').read_text().split())))+'\\n')" + if working_state == "unfinished": + assert (worktree / "report.txt").read_bytes() == b"Original unfinished output\n" + script = script.replace("assert not p.exists()", "assert p.read_text() == 'Original unfinished output\\n'; assert 'unmerged original branch' in Path('branch-context.txt').read_text()") requests = [] def response(body, number, message): diff --git a/tests/control_plane/test_planned_transfer_product.py b/tests/control_plane/test_planned_transfer_product.py index 85e7e266e4..9fc2044443 100644 --- a/tests/control_plane/test_planned_transfer_product.py +++ b/tests/control_plane/test_planned_transfer_product.py @@ -159,7 +159,7 @@ def test_preparation_builds_an_independent_original_execution_repository(source: -@pytest.mark.parametrize("fault", ["changed_input", "missing_document", "wrong_repository", "source_tracked_change", "source_document_change", "unreviewed_file"]) +@pytest.mark.parametrize("fault", ["changed_input", "missing_document", "wrong_repository", "source_tracked_change", "source_document_change", "unreviewed_file", "changed_index", "source_index", "source_working_file"]) def test_changed_execution_workspace_retains_source_cancel(source: dict, tmp_path: Path, fault: str): _goal_document(source) target, prepared = _prepared(source, tmp_path) @@ -174,6 +174,11 @@ def test_changed_execution_workspace_retains_source_cancel(source: dict, tmp_pat (source["project"] / "requirements/GOAL.md").write_text("Later source requirements remain on source\n") elif fault == "unreviewed_file": (worktree / "AGENTS.md").write_text("Unreviewed instructions remain outside the cutover\n") + elif fault in ("changed_index", "source_index"): + actual = worktree if fault == "changed_index" else source["project"].parent / "agent" + subprocess.run(["git", "-C", str(actual), "rm", "--cached", "-q", "input.txt"], check=True) + elif fault == "source_working_file": + (source["project"].parent / "agent/report.txt").write_text("Later unfinished source output\n") else: subprocess.run(["git", "-C", str(worktree), "remote", "set-url", "origin", "https://example.org/other/repository.git"], check=True) server, thread, call = _http(source) @@ -202,7 +207,7 @@ def test_changed_execution_workspace_retains_source_cancel(source: dict, tmp_pat server.server_close() -@pytest.mark.parametrize("fault", ["dirty", "wrong_repository", "tracked_link", "agent_dirty", "agent_branch", "agent_untracked", "agent_ignored", "unknown_workspace"]) +@pytest.mark.parametrize("fault", ["dirty", "wrong_repository", "tracked_link", "unknown_workspace"]) def test_code_repository_dependencies_refuse_before_stopping_source(source: dict, tmp_path: Path, fault: str): project = source["project"] if fault == "dirty": @@ -213,19 +218,7 @@ def test_code_repository_dependencies_refuse_before_stopping_source(source: dict source["cli"]("todo", "add", "--goal-id", "report", "--role", "agent", "--claimed-by", "agent-a", "--text", "Unstarted code work needs its own source workspace decision", "--task-repository", "git:example.org/fixtures/agent-transfer", "--required-write-scope", "report.txt", "--operation-id", "no-source-workspace") - elif fault.startswith("agent_"): - agent_workspace = project.parent / "agent" - if fault in ("agent_untracked", "agent_ignored"): - (agent_workspace / "report.txt").write_text("Unfinished original Agent work\n") - if fault == "agent_ignored": - with (project / ".git/info/exclude").open("a") as ignored: - ignored.write("\nreport.txt\n") - else: - (agent_workspace / "input.txt").write_text("Unfinished original Agent edits\n") - if fault == "agent_branch": - subprocess.run(["git", "-C", str(agent_workspace), "add", "input.txt"], check=True) - subprocess.run(["git", "-C", str(agent_workspace), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.org", - "commit", "-qm", "Unmerged original Agent work"], check=True) + else: (project / "linked").symlink_to("input.txt") subprocess.run(["git", "-C", str(project), "add", "linked"], check=True) @@ -239,6 +232,99 @@ def test_code_repository_dependencies_refuse_before_stopping_source(source: dict assert before == (source["fence"].read_bytes(), source["registry"].read_bytes()) +@pytest.mark.parametrize("fault", ["symlink", "hardlink", "intent_to_add", "assume_unchanged", "conflicted_index"]) +def test_unsupported_actual_working_state_is_held_before_fencing(source: dict, tmp_path: Path, fault: str): + agent = source["project"].parent / "agent" + if fault == "symlink": + (agent / "report.txt").symlink_to("input.txt") + elif fault == "hardlink": + (agent / "report.txt").hardlink_to(agent / "input.txt") + elif fault == "intent_to_add": + (agent / "report.txt").write_text("Intent to add remains a distinct index state\n") + subprocess.run(["git", "-C", str(agent), "add", "-N", "report.txt"], check=True) + elif fault == "assume_unchanged": + subprocess.run(["git", "-C", str(agent), "update-index", "--assume-unchanged", "input.txt"], check=True) + else: + blob = subprocess.run(["git", "-C", str(agent), "rev-parse", "HEAD:input.txt"], + capture_output=True, text=True, check=True).stdout.strip() + subprocess.run(["git", "-C", str(agent), "update-index", "--index-info"], + input=f"0 {'0' * 40}\tinput.txt\n100644 {blob} 1\tinput.txt\n100644 {blob} 2\tinput.txt\n", + text=True, check=True) + original = source["fence"].read_bytes(), source["registry"].read_bytes() + destination = tmp_path / "unsupported-working-state" + response = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", "--operation-id", OPERATION, + "--destination-root", str(destination), "--material", str(source["project"] / "input.txt"), "--execute", expected_code=1) + assert response["ok"] is False and not destination.exists() + assert original == (source["fence"].read_bytes(), source["registry"].read_bytes()) + + +def test_preparation_preserves_actual_agent_branch_index_and_unfinished_files(source: dict, tmp_path: Path): + """The source Agent's unfinished tree, not a clean default checkout, continues.""" + _goal_document(source) + agent = source["project"].parent / "agent" + + def git(*args: str) -> bytes: + return subprocess.run(["git", "-C", str(agent), *args], capture_output=True, check=True).stdout + + for name in ("staged.txt", "deleted.txt", "removed.txt"): + (agent / name).write_text("Original branch content\n") + git("add", "staged.txt", "deleted.txt", "removed.txt") + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.org", "commit", "-qm", "Unmerged Agent branch") + revision = git("rev-parse", "HEAD").strip() + (agent / "staged.txt").write_text("Staged original change\n") + (agent / "added.txt").write_text("New staged content\n") + git("add", "staged.txt", "added.txt") + (agent / "staged.txt").write_text("Later unstaged change\n") + (agent / "deleted.txt").unlink() + git("rm", "-q", "removed.txt") + (agent / "input.txt").write_text("5\n7\n9\n") + (agent / "notes-原工作.txt").write_bytes(b"Untracked original bytes\x00\xff") + (agent / "report.txt").write_text("Unfinished output\n") + (source["project"] / ".git/info/exclude").write_text("report.txt\nprivate-cache/\n") + (agent / "private-cache").mkdir() + (agent / "private-cache/key").write_text("Ignored outside the Task scope\n") + before = {name: git(*args) for name, args in { + "head": ("rev-parse", "HEAD"), "index": ("ls-files", "--stage", "-z"), + "staged": ("diff", "--cached", "--binary", "--no-ext-diff", "--no-textconv"), + "working": ("diff", "--binary", "--no-ext-diff", "--no-textconv"), + }.items()} + target, prepared = _prepared(source, tmp_path) + review = prepared["prepared_cutover"] + source["cli"]("goal-transfer", "confirm", "--goal-id", "report", "--operation-id", OPERATION, + "--expected-preview-sha256", review["preview_sha256"], "--execute") + context = target["cli"]("goal-transfer", "context", "--goal-id", "report", + "--agent-id", "agent-a", "--todo-id", source["todo"])["context"] + worktree = Path(context["execution_workspace"]) + assert (worktree / "input.txt").read_bytes() == b"5\n7\n9\n" + actual_input = next(row for row in context["execution_materials"] if row["relative_path"] == "input.txt") + canonical_input = next(row for row in context["materials"] if row["relative_path"] == "input.txt") + assert actual_input["sha256"] != canonical_input["sha256"] + assert actual_input["task_workspace_relative_path"] == "input.txt" + assert (target["project"] / "input.txt").read_bytes() == b"6\n7\n8\n" + assert (worktree / "staged.txt").read_bytes() == b"Later unstaged change\n" + assert (worktree / "added.txt").read_bytes() == b"New staged content\n" + assert (worktree / "notes-原工作.txt").read_bytes() == b"Untracked original bytes\x00\xff" + assert (worktree / "report.txt").read_bytes() == b"Unfinished output\n" + assert not (worktree / "deleted.txt").exists() and not (worktree / "removed.txt").exists() + assert not (worktree / "private-cache").exists() + for name, args in {"head": ("rev-parse", "HEAD"), "index": ("ls-files", "--stage", "-z"), + "staged": ("diff", "--cached", "--binary", "--no-ext-diff", "--no-textconv"), + "working": ("diff", "--binary", "--no-ext-diff", "--no-textconv")}.items(): + assert git(*args) == before[name], "preparation must not modify the source" + observed = subprocess.run(["git", "-C", str(worktree), *args], capture_output=True, check=True).stdout + assert observed == before[name], name + source["project"].rename(tmp_path / "offline-source") + source["runtime"].rename(tmp_path / "offline-runtime") + assert subprocess.run(["git", "-C", str(worktree), "show", revision.decode() + "^:input.txt"], + capture_output=True, check=True).stdout == b"6\n7\n8\n" + lease = target["cli"]("task-lease", "acquire", "--goal-id", "report", "--todo-id", source["todo"], + "--owner", "agent-a", "--idempotency-key", "continue-unfinished-tree", "--write-scope", "report.txt", + "--write-worktree", str(worktree))["lease"] + assert lease["lease_epoch"] > 1 + target["cli"]("task-lease", "release", "--goal-id", "report", "--todo-id", source["todo"], "--owner", "agent-a", + "--idempotency-key", lease["idempotency_key"], "--expected-version", str(lease["version"])) + + def _prepared(source: dict, root: Path) -> tuple[dict, dict]: destination = root / "local-move" view = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", "--operation-id", OPERATION, diff --git a/tests/control_plane_ts/planned_transfer_workspace.test.ts b/tests/control_plane_ts/planned_transfer_workspace.test.ts index 1e8ecf88a6..88d75af637 100644 --- a/tests/control_plane_ts/planned_transfer_workspace.test.ts +++ b/tests/control_plane_ts/planned_transfer_workspace.test.ts @@ -5,8 +5,9 @@ import {join} from "node:path"; import {tmpdir} from "node:os"; import {planGoalTransferWorkspace, readGoalTransferWorkspaceReview, retainGoalTransferWorkspace, reviewGoalTransferWorkspace, verifyGoalTransferWorkspaceReview} from "../../loopx/control_plane/goals/planned_transfer_workspace.ts"; +import {decodePlannedTransferMaterial} from "../../loopx/control_plane/goals/planned_transfer.ts"; import {inspectTransferFile} from "../../loopx/control_plane/goals/planned_transfer_container.ts"; -import {reviewProtectedGoalTransferInputs} from "../../loopx/control_plane/goals/planned_transfer_payload.ts"; +import {reviewProtectedGoalTransferInputs, reviewGoalTransferMaterialInputs} from "../../loopx/control_plane/goals/planned_transfer_payload.ts"; async function fixture(t: TestContext) { const root = await realpath(await mkdtemp(join(tmpdir(), "loopx-workspace-transfer-"))); @@ -105,3 +106,22 @@ test("unsupported source platform refuses before recipient validation or prepara try { await assert.rejects(reviewProtectedGoalTransferInputs({}, {}, "test"), /POSIX profile before preparation/); } finally { Object.defineProperty(process, "platform", original); } }); + + +test("working-input mapping cannot nominate another source or enter control metadata", async t => { + const f = await fixture(t); await mkdir(f.project); await mkdir(f.runtime); + const context = {goal: {id: "original", repo: f.project}, runtime_root: f.runtime, + registry_path: join(f.project, ".loopx/registry.json")}; + await assert.rejects(reviewGoalTransferMaterialInputs([{path: f.source, sha256: f.material.sha256}], context), /original Goal or its recorded Task workspace/); + const path = join(f.project, "input.txt"); await writeFile(path, "Canonical input"); + const input = {path, ...await inspectTransferFile(path)}; + await assert.rejects(reviewGoalTransferMaterialInputs([input], context), /reviewed content digest/); + const reviewed = await reviewGoalTransferMaterialInputs([{path, sha256: input.sha256}], context); + assert.equal(reviewed.materials.length, 1); + assert.deepEqual(Object.keys(reviewed.materials[0]!).sort(), ["bytes", "id", "sha256", "source_relative_path"]); + const legacy = {...f.material}; delete (legacy as Partial).path; + assert.deepEqual(decodePlannedTransferMaterial(legacy, 0), legacy); + for (const relative of ["../escape", "/elsewhere", ".git/config", ".loopx/registry.json", "data//input"]) { + assert.throws(() => decodePlannedTransferMaterial({...legacy, task_workspace_relative_path: relative}, 0), /Task workspace material path/); + } +}); From 90f99ffe41639a16f17e3d6053215bf86be76d54 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:57:26 +0800 Subject: [PATCH 23/55] Continue explicitly prepared original Agent work after move confirmation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 7 +- .../goal-transfer-settings.tsx | 25 +- docs/reference/research-checkpoint.md | 45 +++- .../cli_commands/support_control_transfer.py | 3 +- loopx/cli_commands/turn.py | 12 + loopx/cli_commands/turn_registration.py | 4 + loopx/cli_commands/turn_run_once.py | 26 +- loopx/cli_commands/turn_todo_writeback.py | 8 + .../planned_transfer_authority.ts | 11 +- .../coordination/planned_transfer_target.ts | 3 +- .../control_plane/effect_runtime_handlers.ts | 2 + .../goals/planned_transfer_continuation.ts | 84 ++++++- .../goals/planned_transfer_cutover.ts | 30 ++- .../goals/planned_transfer_history.ts | 56 ++++- .../goals/planned_transfer_io.py | 154 +++++++++++- .../todos/completion_validation.py | 36 ++- loopx/presentation/goal_transfer_api.py | 4 +- .../test_planned_transfer_execution.py | 232 ++++++++++++++++++ 18 files changed, 687 insertions(+), 55 deletions(-) create mode 100644 tests/control_plane/test_planned_transfer_execution.py diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index b14a528179..47768b0145 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -47,7 +47,9 @@ const goalTransferViewSchema = z.object({ operation_id: z.string().nullable(), cancel_available: z.boolean(), continuation_available: z.literal(false), local_cutover: z.object({source_final: z.literal(true), target_task_admitted: z.boolean(), target_runtime_admitted: z.boolean().optional(), target_activation_state: z.enum(["active", "stopped"]).optional()}).optional(), - prepared_cutover: z.object({operation_id: z.string(), preview_sha256: z.string(), destination_name: z.string()}).optional(), + prepared_cutover: z.object({operation_id: z.string(), preview_sha256: z.string(), destination_name: z.string(), + continue_work: z.object({todo_id: z.string(), agent_id: z.string(), host: z.literal("codex-cli"), + sandbox: z.literal("workspace-write"), validation_workspace: z.literal("reviewed_working_tree"), validation_sha256: z.string()}).optional()}).optional(), prepared_cutover_unavailable: z.boolean().optional(), work: z.object({objective: z.string(), agents: z.array(z.string()), todos: z.array(z.object({todo_id: z.string(), text: z.string(), status: z.string(), @@ -71,7 +73,8 @@ export async function cancelGoalTransfer(goalId: string, operationId: string) { export async function confirmGoalTransfer(goalId: string, review: NonNullable) { return z.object({ok: z.literal(true), recorded: z.literal(true), operation_id: z.string(), - readback: goalTransferViewSchema.nullable()}).parse(await requestJson("/api/chat/goal-transfer", { + readback: goalTransferViewSchema.nullable(), + execution: z.object({ok: z.boolean(), reason_code: z.string().nullable()}).optional()}).parse(await requestJson("/api/chat/goal-transfer", { method: "POST", body: JSON.stringify({action: "confirm", goal_id: goalId, operation_id: review.operation_id, preview_sha256: review.preview_sha256}), })); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx index 9754a8ee91..5ca5b9fd38 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx @@ -3,6 +3,8 @@ import {cancelGoalTransfer, confirmGoalTransfer, inspectGoalTransfer, type GoalT import {useWorkspaceI18n} from "./i18n"; import "./goal-transfer-settings.css"; +type TransferAction = "read" | "cancel" | "confirm"; + /** The Agent prepares the existing local owners; this entry confirms only the * retained exact review and reads actual destination work. Full journey held. */ export function GoalTransferSettings({goalId}: {goalId: string}) { @@ -10,9 +12,11 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { const zh = locale === "zh-CN"; const [view, setView] = useState(null); const [busy, setBusy] = useState(false); + const [pendingAction, setPendingAction] = useState(null); const [error, setError] = useState(""); const [cancelled, setCancelled] = useState(null); const [cutoverRecorded, setCutoverRecorded] = useState(false); + const [executionAccepted, setExecutionAccepted] = useState(null); const inFlight = useRef(false); const holds: Record = { retained_leases: zh ? "仍有未释放的任务租约,需由原任务结算。" : "Original tasks still hold unreleased leases.", @@ -22,13 +26,15 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { transfer_journey_incomplete: zh ? "此处可读取本地切换、原任务状态和证据引用;完整迁移流程与安装环境验证仍在开发中。" : "Local cutover, original-task status and evidence references can be read here. The complete migration flow and installed-environment validation are still in development.", }; - async function perform(action: "read" | "cancel" | "confirm" = "read") { + async function perform(action: TransferAction = "read") { if (inFlight.current) return; - inFlight.current = true; setBusy(true); setError(""); setView(null); + inFlight.current = true; setBusy(true); setPendingAction(action); setError(""); + if (action === "read") setView(null); try { if (action === "confirm" && view?.prepared_cutover) { const result = await confirmGoalTransfer(goalId, view.prepared_cutover); setCutoverRecorded(true); setView(result.readback); + if (result.execution) setExecutionAccepted(result.execution.ok); if (!result.readback) setError(zh ? "切换已接受,请读取当前状态以恢复结果。" : "The switch was accepted. Read current status to recover the result."); return; } @@ -42,18 +48,24 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { setView(await inspectGoalTransfer(goalId)); } catch { setError(zh ? "无法读取原迁移状态,请重试。" : "The original transfer state could not be read. Retry."); - } finally {inFlight.current = false; setBusy(false);} + } finally {inFlight.current = false; setBusy(false); setPendingAction(null);} } return

{ if (event.currentTarget.open && !view && !busy && !error) void perform(); }}> {zh ? "迁移 Agent" : "Move Agent"} - {busy ?

{zh ? "读取原工作与迁移状态…" : "Reading original work and transfer state…"}

: null} + {busy ?

{pendingAction === "confirm" + ? (zh ? "正在确认切换并继续原任务…" : "Confirming the move and continuing original work…") + : pendingAction === "cancel" ? (zh ? "正在取消迁移准备…" : "Cancelling move preparation…") + : (zh ? "读取原工作与迁移状态…" : "Reading original work and transfer state…")}

: null} {cancelled !== null ?

{cancelled ? (zh ? "准备已取消,源环境的原写入边界已恢复。" : "Preparation cancelled. The original source write boundary is restored.") : (zh ? "取消决定已记录;源环境已有后续状态,请读取当前状态。" : "Cancellation is recorded. The source has later state; read its current status.")}

: null} {cutoverRecorded ?

{zh ? "本次切换已接受,源环境保持停写。" : "This switch was accepted. Source writes remain closed."}

: null} + {executionAccepted !== null ?

{executionAccepted + ? (zh ? "续办请求已处理;下方为原任务的当前状态和独立验收结果。" : "The continuation request was processed. Its original task's current state and independent checks appear below.") + : (zh ? "切换已接受,原任务续办尚未成功。可恢复同一次切换;已记录的执行和结果不会重复。" : "The switch was accepted, but original work has not continued successfully. Resume this move to recover its recorded execution without duplication.")}

: null} {view ? <>

{view.local_cutover ? (view.local_cutover.target_activation_state === "stopped" ? (zh ? "目标 Goal 已停止,仍可查看原任务结果。恢复该 Goal 后才可继续执行。" : "The destination Goal is stopped. Original work remains readable; resume that Goal before continuing execution.") @@ -82,7 +94,10 @@ export function GoalTransferSettings({goalId}: {goalId: string}) {

{view.prepared_cutover ? <>

{zh ? "已准备的本地目标:" : "Prepared local destination: "}{view.prepared_cutover.destination_name}

-

{zh ? "确认后,由目标环境取得新的执行准入并继续上面的原工作。" : "Confirm to admit the destination for new execution and continue the original work above."}

+

{view.prepared_cutover.continue_work + ? (zh ? `确认后,${view.prepared_cutover.continue_work.agent_id} 使用目标环境当前的 Codex 登录,在目标工作区继续原任务并运行原验收。不会安装源环境凭证。` + : `Confirm to let ${view.prepared_cutover.continue_work.agent_id} continue the original task in its destination workspace with the destination's current Codex login and original independent checks. Source credentials are not installed.`) + : (zh ? "确认后,由目标环境取得新的执行准入并继续上面的原工作。" : "Confirm to admit the destination for new execution and continue the original work above.")}

diff --git a/docs/reference/research-checkpoint.md b/docs/reference/research-checkpoint.md index b2b41fb66f..8c6532ffd0 100644 --- a/docs/reference/research-checkpoint.md +++ b/docs/reference/research-checkpoint.md @@ -184,11 +184,46 @@ A changed source tree/index or destination tree/index refuses the final confirma and retains source cancellation. After admission, normal new edits and commits survive retries of the original confirmation and Turn. +An Agent may explicitly add `--continue-todo-id ORIGINAL_TODO` to that same +preparation. This prepares one existing Task for automatic continuation after +confirmation. Its preview identifies the responsible registered Agent, the +current local Codex login, workspace-write sandbox and original independent +checks. Confirmation composes a fresh Task lease, native session and stable Turn; +this is execution by the responsible Agent, rather than an owner Goal Chat +assuming that role. Without this option, confirmation retains its existing +admission/readback behavior and launches no Host. + +Canonical Todos retain validation commitments rather than private command argv. +Preparation now captures those selected private declarations inside the encrypted +history and restores them through the existing validation store. Missing or +changed declarations hold before source preparation or final confirmation. +Their private paths and arguments are retained, not rewritten for another +machine, and their contents are not privacy-certified. No source credential +binding or native session is installed. + +The opt-in preview also selects validation of the original pending working +files. Its native transfer owner requires the same admitted Goal, original Task, +validator commitment and physical execution workspace. The original independent +checks still run; the Agent need not commit or discard unrelated pending edits +just to pass a clean-tree test. Generic delivery validation keeps its existing +clean-worktree rule. The fresh caller-held lease proof is retained in the original +Turn and rechecked by the native terminal owner. Manual Turn callers can supply +that proof with `--task-lease-idempotency-key` and +`--task-lease-expected-version`; reading a lease or changing those arguments on a +journal retry cannot supply a new grant. + +An accepted cutover stays recorded when continuation fails. The original entry +reads the current Task and independent acceptance. Repeated confirmation reads or +recovers the same native Turn; already recorded work and debits are not repeated. A pending +settlement retains its existing lease proof; its owner may still hold recovery. +A successful request does not itself prove Task completion. + Distinct source workspaces, unknown mappings, links/submodules, conflicted or special index states, and dirty canonical checkouts still hold before preparation. -These holds do not complete the full profile. Responsible-Agent App dispatch, -required credential/configuration rebinding, full Host/effect drain, secure -cross-machine delivery and the convenience target remain unqualified. This local +These holds do not complete the full profile. Automatic continuation is limited +to the explicitly prepared local Codex Task above. Required credential/configuration +rebinding, full Host/effect drain, secure cross-machine delivery and the +convenience target remain unqualified. This local path never requires discarding or committing an Agent's pending edits merely to satisfy a clean-checkout test. @@ -198,8 +233,8 @@ unsupported capability configuration and required capabilities outside this profile hold before source writes are fenced or a destination is created. Configure or rebind those dependencies through their actual owners; this profile does not silently drop them, copy credentials or adopt optional settings. App -dispatch as the responsible Agent and the complete migration qualification -remain separate work. +automatic dispatch beyond that explicit local Codex profile and the complete +migration qualification remain separate work. Settings → Capability Center → the original Goal → Move Agent shows the prepared destination and original work. **Confirm local move** performs the diff --git a/loopx/cli_commands/support_control_transfer.py b/loopx/cli_commands/support_control_transfer.py index 46e45d823a..140c5b3be7 100644 --- a/loopx/cli_commands/support_control_transfer.py +++ b/loopx/cli_commands/support_control_transfer.py @@ -31,6 +31,7 @@ def register_goal_transfer(subparsers: argparse._SubParsersAction[argparse.Argum command.add_argument("--todo-id", required=True) if action == "prepare-local": command.add_argument("--destination-root", required=True, type=Path) + command.add_argument("--continue-todo-id", help="Prepare this original Task for automatic continuation after confirmation with the current local Codex CLI login and workspace-write sandbox; requires its original independent argv validator.") command.add_argument("--material", action="append", type=Path, default=[], help="Agent-reviewed workspace input; does not certify complete dependencies or credential isolation.") if action in ("cutover-preview", "cutover"): @@ -77,7 +78,7 @@ def handle_goal_transfer(args: argparse.Namespace, *, registry_path: Path, operation_id=args.operation_id)} elif args.goal_transfer_action == "prepare-local": payload = prepare_local_goal_transfer(registry_path=registry_path, goal_id=args.goal_id, - operation_id=args.operation_id, destination_root=args.destination_root, materials=args.material) + operation_id=args.operation_id, destination_root=args.destination_root, materials=args.material, continue_todo_id=args.continue_todo_id) elif args.goal_transfer_action == "confirm": payload = confirm_registered_goal_transfer(registry_path=registry_path, goal_id=args.goal_id, operation_id=args.operation_id, expected_preview_sha256=args.expected_preview_sha256) diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index 6109c0edb6..5966eef741 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -326,6 +326,18 @@ def handle_turn_command( if isinstance(transaction, dict): transaction.pop("settlement_plan", None) elif args.turn_command == "run-once": + # Opaque caller proof is private Turn input. Existing journals keep + # their original proof; the native terminal owner alone decides its + # current validity. Reading a lease never manufactures this input. + lease_key = args.task_lease_idempotency_key + lease_version = args.task_lease_expected_version + if lease_key is not None or lease_version is not None: + if not args.todo_id or not lease_key or lease_key != lease_key.strip() or lease_version is None or lease_version < 1: + raise ValueError("Task completion lease proof requires an exact Todo, idempotency key and positive version") + proof = {"lease_idempotency_key": lease_key, "lease_expected_version": lease_version} + if retry_key and payload.get("completion_lease_fence") != proof: + raise ValueError("Original Turn completion lease proof changed; retain its original journal") + payload["completion_lease_fence"] = proof if payload.get("route", {}).get("kind") == "capability_action_required": # The normal host transaction forbids Core mutations. A # capability may prepare artifacts and require authored input; diff --git a/loopx/cli_commands/turn_registration.py b/loopx/cli_commands/turn_registration.py index e538a426ea..276e3bf22f 100644 --- a/loopx/cli_commands/turn_registration.py +++ b/loopx/cli_commands/turn_registration.py @@ -191,6 +191,10 @@ def register_turn_commands( execution_mode_choices=["isolated-headless"], default_execution_mode="isolated-headless", ) + run_once.add_argument("--task-lease-idempotency-key", + help="Caller-held current lease proof for canonical Task completion; retained by this original Turn, never inferred from a lease read.") + run_once.add_argument("--task-lease-expected-version", type=int, + help="Current version paired with --task-lease-idempotency-key. Native terminal ownership revalidates it at completion.") run_once.add_argument("--project", required=True) run_once.add_argument( "--automation-id", diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index e5c8810f90..680a031f3f 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -271,6 +271,8 @@ def writeback( note=str(result.get("summary") or result["classification"]), evidence=f"LoopX Turn {result_kind}: {result['next_action']}", agent_id=args.agent_id, + task_lease_idempotency_key=(payload.get("completion_lease_fence") or {}).get("lease_idempotency_key"), + task_lease_expected_version=(payload.get("completion_lease_fence") or {}).get("lease_expected_version"), ) refresh = refresh_state_run( registry_path=registry_path, @@ -368,24 +370,26 @@ def todo_completion( agent_id=args.agent_id, completion_delivery_workspace=completion_delivery_workspace, completion_validation_workspace_path=delivery_workspace_path, + task_lease_idempotency_key=(payload.get("completion_lease_fence") or {}).get("lease_idempotency_key"), + task_lease_expected_version=(payload.get("completion_lease_fence") or {}).get("lease_expected_version"), ) + if completion.get("ok") is not True: + return {"ok": False, "appended": False, + "reason": str(completion.get("reason") or "Native Todo completion was not accepted")} # Project the continuation the Todo lifecycle durably recorded, # never a host-normalized continuation. Contradictory or # dangling durable state fails closed before any further # writeback so the typed settlement sees the truthful outcome. - state_file = completion.get("state_file") - if not isinstance(state_file, str) or not state_file: - return { - "ok": False, - "appended": False, - "reason": ( - "validated completion lifecycle did not report its " - "durable Todo state file" - ), - } try: + # Canonical completion may intentionally omit the optional + # Markdown projection address. Resolve this Goal's route and + # let the existing reader select its native provider. + _, state_path = resolve_todo_state_path( + registry_path=registry_path, goal_id=args.goal_id, + project=None, state_file=None, + ) durable_todo, existing_todo_ids = read_persisted_todo_record( - Path(state_file), + state_path, todo_id=todo_id, registry_path=registry_path, goal_id=args.goal_id, diff --git a/loopx/cli_commands/turn_todo_writeback.py b/loopx/cli_commands/turn_todo_writeback.py index 15ecdf2f0e..89893d2aaf 100644 --- a/loopx/cli_commands/turn_todo_writeback.py +++ b/loopx/cli_commands/turn_todo_writeback.py @@ -16,6 +16,8 @@ def write_turn_repair_update( note: str, evidence: str, agent_id: str | None, + task_lease_idempotency_key: str | None = None, + task_lease_expected_version: int | None = None, ) -> None: """Record one repair-required Todo note under the effective runtime root. @@ -36,6 +38,8 @@ def write_turn_repair_update( project=None, dry_run=False, runtime_root_arg=runtime_root_arg, + task_lease_idempotency_key=task_lease_idempotency_key, + task_lease_expected_version=task_lease_expected_version, ) @@ -51,6 +55,8 @@ def write_turn_validated_completion( agent_id: str | None, completion_delivery_workspace: Mapping[str, Any] | None = None, completion_validation_workspace_path: Path | None = None, + task_lease_idempotency_key: str | None = None, + task_lease_expected_version: int | None = None, ) -> dict[str, Any]: """Complete one validated Todo under the effective runtime root.""" @@ -63,6 +69,8 @@ def write_turn_validated_completion( completion_identity_source="turn_settlement", completion_delivery_workspace=completion_delivery_workspace, completion_validation_workspace_path=completion_validation_workspace_path, + task_lease_idempotency_key=task_lease_idempotency_key, + task_lease_expected_version=task_lease_expected_version, evidence=evidence, note=note, agent_id=agent_id, diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index af653f5b0f..2d3c60f0bd 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -33,7 +33,7 @@ import {newGoalCreationBinding} from "./new_goal_initialization.ts"; import {readPromotionReceipt} from "./promotion_receipt.ts"; import {NEW_GOAL_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; import {readGoalTurnHistoryForTransfer, readGoalTurnJournalsForTransfer} from "../turn_driver/turn_journal_query.ts"; -import {goalTransferHistory, readGoalTransferHistoryJson} from "../goals/planned_transfer_history.ts"; +import {goalTransferHistory, readGoalTransferHistoryJson, readTransferValidationDeclarations} from "../goals/planned_transfer_history.ts"; import {readGoalQuotaHistoryForTransfer} from "../quota/accounting_artifact_transaction.ts"; import {observeLocalWorkspaceHostIdentity} from "../work_items/task_lease_workspace.ts"; @@ -337,7 +337,8 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro if (quotaHistory.closed !== true) reject("Source quota effects are not closed; recover the original debit or void before preparing transfer"); // Validate the new carrier's addresses and per-file bounds before // changing the original writer fence. - const runtimeHistory = goalTransferHistory(root, goal, turnHistory, quotaHistory); + const validations = await readTransferValidationDeclarations(root, goal, head.head); + const runtimeHistory = goalTransferHistory(root, goal, turnHistory, quotaHistory, validations); const pendingPath = join(directory, "preparation.json"); const pending = await optionalJson(pendingPath); const facts = {schema_version: PREPARATION_SCHEMA, request, @@ -346,6 +347,7 @@ export async function prepareAuthorityForPlannedTransfer(value: JsonObject): Pro source_cursor: head.cursor, source_projection_sha256: canonicalAuthoritySha256(head.head), source_turn_history: turnHistory, source_quota_history: quotaHistory, + ...(validations.length ? {source_validation_declarations: validations} : {}), ...(context ? {source_context: context, source_context_sha256: canonicalAuthoritySha256(context)} : {})}; let priorFence: JsonObject; if (pending) { @@ -430,7 +432,8 @@ export async function cancelSourcePreparation(value: JsonObject): Promise import("./goals/planned_transfer_cutover.ts"), ({retainLocalGoalTransferCutoverReview}) => retainLocalGoalTransferCutoverReview)], ["coordination.planned_transfer.read_local_cutover_review", lazyHandler(() => import("./goals/planned_transfer_cutover.ts"), ({readRegisteredLocalCutoverReview}) => readRegisteredLocalCutoverReview)], ["coordination.planned_transfer.finish_local_cutover", lazyHandler(() => import("./goals/planned_transfer_cutover.ts"), ({finishLocalGoalTransferCutover}) => finishLocalGoalTransferCutover)], + ["coordination.planned_transfer.validation_workspace", lazyHandler(() => import("./goals/planned_transfer_continuation.ts"), ({validateGoalTransferWorkingState}) => validateGoalTransferWorkingState)], + ["coordination.planned_transfer.execution_plan", lazyHandler(() => import("./goals/planned_transfer_continuation.ts"), ({readGoalTransferExecutionPlan}) => readGoalTransferExecutionPlan)], ["coordination.planned_transfer.host_context", lazyHandler(() => import("./goals/planned_transfer_continuation.ts"), ({readGoalTransferHostContext}) => readGoalTransferHostContext)], ["configuration.backup", lazyHandler(() => import("./configuration_backup.ts"), ({configurationBackupOperation}) => configurationBackupOperation)], ["runtime.checkpoint_artifacts", lazyHandler(() => import("./runtime/checkpoint_artifacts.ts"), ({checkpointArtifacts}) => checkpointArtifacts)], diff --git a/loopx/control_plane/goals/planned_transfer_continuation.ts b/loopx/control_plane/goals/planned_transfer_continuation.ts index 2cecec5116..690cc64b69 100644 --- a/loopx/control_plane/goals/planned_transfer_continuation.ts +++ b/loopx/control_plane/goals/planned_transfer_continuation.ts @@ -14,10 +14,13 @@ import {loadLegacyCoordinationWriterFence, isPlannedTransferAdmissionFence, PLAN import {openLocalAuthorityStoreHandle, requireLocalAuthorityRuntimeRoot} from "../coordination/local_authority_provider.ts"; import {indexCoordinationProjection} from "../coordination/coordination_projection.ts"; import {readGoalTransferSnapshot, checkGoalTransferSourceContext} from "./planned_transfer.ts"; -import {readGoalTransferHistoryJson} from "./planned_transfer_history.ts"; +import {readGoalTransferHistoryJson, readTransferValidationDeclarations} from "./planned_transfer_history.ts"; import {repositoryMaterialMapping, type TransferRepository} from "./planned_transfer_repository.ts"; +import {normalizeTodoCompletionValidationDeclaration} from "../todos/completion_validation_declaration.ts"; import {verifyGoalTransferExecutionWorkspace} from "./planned_transfer_workspace.ts"; +const LOCAL_TRANSFER_CAPABILITIES = ["filesystem_read", "filesystem_write", "shell"] as const; + function reject(message: string): never {throw new EffectRuntimeConflictError(message);} function same(a: unknown, b: unknown): boolean {return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b));} @@ -68,7 +71,7 @@ export function localTransferGoalConfiguration(original: JsonObject): JsonObject export function requireLocalTransferTaskBindings(original: JsonObject, projection: JsonObject): void { const coordination = requireJsonObject(original.coordination, "original coordination"); - const supported = new Set(["filesystem_read", "filesystem_write", "shell"]); + const supported = new Set(LOCAL_TRANSFER_CAPABILITIES); for (const todo of indexCoordinationProjection(projection, String(original.id)).todos.values()) { if (todo.done === true || todo.archive_state === "archive") continue; if ((todo.required_capabilities !== undefined && !Array.isArray(todo.required_capabilities)) || @@ -79,6 +82,83 @@ export function requireLocalTransferTaskBindings(original: JsonObject, projectio } } +/** This opt-in composes existing Task validation and native execution owners. + * It is local to this prepared move, never a Chat role or a copied Host grant. */ +export async function planTransferOriginalWork(original: JsonObject, projection: JsonObject, id: unknown, root: string): Promise { + if (id === undefined) return null; + const todoId = requireAuthorityStoreId(id, "original continuation Task"); + const task = indexCoordinationProjection(projection, String(original.id)).todos.get(todoId); + const agents = requireJsonObject(original.coordination, "original Agent registration").registered_agents as unknown[]; + if (!task || task.done === true || task.archive_state === "archive" || task.status !== "open" || + task.claimed_by !== agents[0] || !Array.isArray(task.required_write_scopes) || !task.required_write_scopes.length) { + reject("Automatic continuation requires the original open Task, registered Agent and declared write scopes"); + } + const retained = (await readTransferValidationDeclarations(root, String(original.id), projection)).find(row => row.todo_id === todoId); + const declaration = normalizeTodoCompletionValidationDeclaration(retained ? requireJsonObject(retained.declaration, "original independent validator") : {}); + if (!declaration.ok || !declaration.value.validation_command_argv) { + reject("Automatic continuation requires the original Task's independent argv validation declaration"); + } + return {todo_id: todoId, agent_id: agents[0], host: "codex-cli", sandbox: "workspace-write", validation_workspace: "reviewed_working_tree", + validation_sha256: canonicalAuthoritySha256(declaration.value)}; +} + +/** Read the admitted operation and the current Task; downstream owners still + * acquire the lease and decide the exact Turn. This projection grants nothing. */ +export async function readGoalTransferExecutionPlan(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id"])) reject("Read the original transfer execution identity"); + const root = requireLocalAuthorityRuntimeRoot(value.runtime_root), goal = requireAuthorityStoreId(value.goal_id, "Goal"); + const fence = await loadLegacyCoordinationWriterFence(root, goal); + if (fence.status !== "loaded" || fence.fence.schema_version !== PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA || + fence.fence.operation_id !== value.operation_id) reject("Original destination runtime is not admitted"); + const {requireLocalGoalTransferAdmission} = await import("./planned_transfer_cutover.ts"); + const admission = await requireLocalGoalTransferAdmission(root, goal, fence.fence); + const decision = requireJsonObject(admission.decision, "original cutover"); + const continuation = requireJsonObject(decision.continuation, "original continuation"); + if (!continuation.continue_work) return {plan: null}; + const work = requireJsonObject(continuation.continue_work, "reviewed original work"); + const {snapshot, materials} = await readReviewedTransferContext(admission, "readback"); + const execution = requireJsonObject(continuation.execution_workspace, "original execution workspace"); + await verifyGoalTransferExecutionWorkspace(execution, materials, false); + const opened = await openLocalAuthorityStoreHandle(root, goal, {}, {existingOnly: true}); + const head = await opened.store.loadAuthority(); + if (head.status !== "loaded") reject("Current original work is unavailable"); + const task = indexCoordinationProjection(head.head, goal).todos.get(String(work.todo_id)); + if (!task || task.claimed_by !== work.agent_id) reject("Original Task no longer belongs to its reviewed Agent"); + const retained = (await readTransferValidationDeclarations(root, goal, head.head)).find(row => row.todo_id === work.todo_id); + const declaration = normalizeTodoCompletionValidationDeclaration(retained ? requireJsonObject(retained.declaration, "original independent validator") : {}); + if (!declaration.ok || !declaration.value.validation_command_argv || + canonicalAuthoritySha256(declaration.value) !== work.validation_sha256) reject("Original independent acceptance changed; retain its current owner"); + return {plan: {registry_path: admission.target_registry_path, runtime_root: root, goal_id: goal, + agent_id: work.agent_id, todo_id: work.todo_id, project: execution.path, + turn_instance_id: `transfer-${canonicalAuthoritySha256([decision.operation_id, decision.source_preparation_sha256, work.todo_id])}`, + host: work.host, sandbox: work.sandbox, task_status: task.status, + write_scopes: task.required_write_scopes, available_capabilities: [...LOCAL_TRANSFER_CAPABILITIES], + validation_argv: declaration.value.validation_command_argv, + validation_timeout_seconds: declaration.value.validation_timeout_seconds, + activation_state: snapshot.activation_state}}; +} + +/** Only the explicitly reviewed original work may validate pending files. + * This chooses its workspace, never grants a validation effect or Task lease; + * the ordinary terminal transaction still admits and rechecks those effects. */ +export async function validateGoalTransferWorkingState(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "todo_id", "workspace_path", "declaration_sha256"])) reject("Original validation requires its exact workspace and declaration"); + const root = requireLocalAuthorityRuntimeRoot(value.runtime_root), goal = requireAuthorityStoreId(value.goal_id, "Goal"); + const fence = await loadLegacyCoordinationWriterFence(root, goal); + if (fence.status !== "loaded" || fence.fence.schema_version !== PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA) return {admitted: false}; + const {requireLocalGoalTransferAdmission} = await import("./planned_transfer_cutover.ts"); + const admission = await requireLocalGoalTransferAdmission(root, goal, fence.fence); + const continuation = requireJsonObject(requireJsonObject(admission.decision, "cutover").continuation, "continuation"); + if (!continuation.continue_work) return {admitted: false}; + const work = requireJsonObject(continuation.continue_work, "reviewed original work"); + if (work.todo_id !== value.todo_id || work.validation_workspace !== "reviewed_working_tree" || + work.validation_sha256 !== value.declaration_sha256) return {admitted: false}; + const {plan: rawPlan} = await readGoalTransferExecutionPlan({runtime_root: root, goal_id: goal, operation_id: fence.fence.operation_id}); + const plan = requireJsonObject(rawPlan, "current original execution plan"); + return {admitted: plan.activation_state === "active" && plan.task_status === "open" && + typeof value.workspace_path === "string" && resolve(value.workspace_path) === plan.project}; +} + /** Live document references follow the already reviewed material mapping. * Historical source context and receipts keep their original paths/bytes. */ export function localTransferMappedGoalDocuments(original: JsonObject, materials: JsonObject): JsonObject[] { diff --git a/loopx/control_plane/goals/planned_transfer_cutover.ts b/loopx/control_plane/goals/planned_transfer_cutover.ts index 9ed0eae030..9ac8e91c4d 100644 --- a/loopx/control_plane/goals/planned_transfer_cutover.ts +++ b/loopx/control_plane/goals/planned_transfer_cutover.ts @@ -22,11 +22,11 @@ import {loadLegacyCoordinationWriterFence, admitPlannedTransferTaskWriterUnderLo PLANNED_TRANSFER_WRITER_FENCE_SCHEMA, PLANNED_TRANSFER_TASK_FENCE_SCHEMA, PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA, isPlannedTransferAdmissionFence} from "../coordination/legacy_writer_fence.ts"; import {shadowManagementDirectory, withShadowMaintenanceLock} from "../coordination/shadow_management.ts"; -import {readGoalTransferHistoryJson} from "./planned_transfer_history.ts"; +import {readGoalTransferHistoryJson, readTransferValidationDeclarations} from "./planned_transfer_history.ts"; import {requirePhysicalTransferPath, inspectTransferFile} from "./planned_transfer_container.ts"; import {decodePlannedTransferMaterial, readGoalTransferSnapshot, withVerifiedGoalTransferContext} from "./planned_transfer.ts"; import {localTransferGoalConfiguration, localTransferGoalDocuments, localTransferMappedGoalDocuments, - requireLocalTransferTaskBindings, planReviewedTransferContinuation, readReviewedTransferContext} from "./planned_transfer_continuation.ts"; + requireLocalTransferTaskBindings, planReviewedTransferContinuation, readReviewedTransferContext, planTransferOriginalWork} from "./planned_transfer_continuation.ts"; import {planGoalTransferWorkspace, prepareGoalTransferExecutionWorkspace, readGoalTransferExecutionWorkspace} from "./planned_transfer_workspace.ts"; import {planGoalTransferRepository, goalTransferMaterialSource, type TransferRepository} from "./planned_transfer_repository.ts"; @@ -40,7 +40,8 @@ const RUNTIME_ADMISSION_SCHEMA = "loopx_local_goal_transfer_runtime_admission_v0 const REVIEW_SCHEMA = "loopx_local_goal_transfer_review_v0"; function confirmation(decision: JsonObject, digest: unknown): JsonObject { return {operation_id: decision.operation_id, preview_sha256: digest, - destination_name: basename(dirname(String(requireJsonObject(decision.target, "original destination").project)))}; + destination_name: basename(dirname(String(requireJsonObject(decision.target, "original destination").project))), + ...(requireJsonObject(decision.continuation, "continuation").continue_work ? {continue_work: requireJsonObject(decision.continuation, "continuation").continue_work} : {})}; } function reject(message: string): never {throw new EffectRuntimeConflictError(message);} function same(a: unknown, b: unknown): boolean {return canonicalAuthorityBytes(a).equals(canonicalAuthorityBytes(b));} @@ -68,7 +69,7 @@ async function locationIdentity(root: string): Promise { * No credentials, native sessions or optional capability settings are adopted. */ export async function planLocalGoalTransferPreparation(value: JsonObject): Promise { const rawMaterials = value.materials; - if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id", "source_context", "registry_witness", "destination_root", "materials"]) || + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "operation_id", "source_context", "registry_witness", "destination_root", "materials", ...(Object.hasOwn(value, "continue_todo_id") ? ["continue_todo_id"] : [])]) || !Array.isArray(rawMaterials) || process.platform === "win32") reject("Local preparation requires its explicit original Goal, destination and reviewed materials"); return visitRegisteredTransferSource(value, requireAuthorityStoreId(value.operation_id, "original operation"), async source => { if (source.preview.ready_for_authority_preparation !== true) reject("Original work is not settled; use its existing Task/Turn owners before preparing this move"); @@ -76,6 +77,7 @@ export async function planLocalGoalTransferPreparation(value: JsonObject): Promi const goal = requireJsonObject(source.context.goal, "original Goal"); localTransferGoalConfiguration(goal); requireLocalTransferTaskBindings(goal, source.projection); + const work = await planTransferOriginalWork(goal, source.projection, value.continue_todo_id, String(source.context.runtime_root)); const overlaps = (a: string, b: string) => { const suffix = relative(a, b); return suffix === "" || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)); @@ -108,6 +110,7 @@ export async function planLocalGoalTransferPreparation(value: JsonObject): Promi planGoalTransferWorkspace(join(destination, "workspace"), join(destination, "runtime"), reviewed.materials.map((row, index) => ({...decodePlannedTransferMaterial(row, index), path: String(materials[index]!.path)})), null); return {source_preview_sha256: source.preview.preview_sha256, provider: source.preview.provider, materials, repository, + ...(work ? {continue_todo_id: work.todo_id} : {}), project: join(destination, "workspace"), runtime_root: join(destination, "runtime"), recipient_directory: join(destination, "recipient"), artifact_directory: join(destination, "portable")}; }); @@ -118,7 +121,7 @@ export async function planLocalGoalTransferPreparation(value: JsonObject): Promi * a browser-supplied filesystem route. Every confirmation rechecks real owners. */ export async function readLocalGoalTransferCutoverReview(root: string, goal: string, operation: string): Promise { const review = await optional(join(operationDirectory(root, goal, operation, true), "cutover-review.json")); - if (review && (!hasExactAuthorityKeys(review, ["schema_version", "goal_id", "operation_id", "target_registry_path", "preview_sha256", "destination_name"]) || + if (review && (!hasExactAuthorityKeys(review, ["schema_version", "goal_id", "operation_id", "target_registry_path", "preview_sha256", "destination_name", ...(Object.hasOwn(review, "continue_todo_id") ? ["continue_todo_id"] : [])]) || review.schema_version !== REVIEW_SCHEMA || review.goal_id !== goal || review.operation_id !== operation || !BARE_SHA256_PATTERN.test(String(review.preview_sha256)) || typeof review.destination_name !== "string")) reject("Original local cutover review is unavailable; preserve its operation"); return review; @@ -159,7 +162,8 @@ export async function retainLocalGoalTransferCutoverReview(value: JsonObject): P if (current.preparedOperation !== decision.operation_id) reject("Original source is no longer prepared for this move"); const review = {schema_version: REVIEW_SCHEMA, goal_id: decision.goal_id, operation_id: decision.operation_id, target_registry_path: target.registry_path, preview_sha256: plan.preview_sha256, - destination_name: basename(dirname(String(target.project)))}; + destination_name: basename(dirname(String(target.project))), + ...(value.continue_todo_id ? {continue_todo_id: value.continue_todo_id} : {})}; await retain(join(operationDirectory(String(source.runtime_root), String(decision.goal_id), String(decision.operation_id), true), "cutover-review.json"), review); return {operation_id: review.operation_id, preview_sha256: review.preview_sha256, destination_name: review.destination_name}; }); @@ -275,6 +279,7 @@ export async function planLocalGoalTransferCutover(value: JsonObject): Promise { + if (!entries.length) return []; + const python = process.env.LOOPX_EFFECT_RUNTIME_PYTHON; + if (!python || !isAbsolute(python)) reject("Original validation store needs its installed codec"); + const output = await new Promise((accept, refuse) => { + const child = execFile(python, ["-m", "loopx.control_plane.goals.planned_transfer_io", "--validation-io", action, root, goal], + {env: {...process.env, PYTHONPATH: fileURLToPath(new URL("../../../", import.meta.url))}, + timeout: 30_000, maxBuffer: 16 * 1024 * 1024}, (error, stdout) => error ? refuse(error) : accept(stdout)); + child.stdin?.on("error", refuse); child.stdin?.end(JSON.stringify(entries)); + }); + const result: unknown = JSON.parse(output); + return decodeTransferValidations(result); +} +function decodeTransferValidations(value: unknown): JsonObject[] { + if (!Array.isArray(value) || value.length > 20_000) reject("Original private validation inventory is invalid"); + let previous = ""; + return value.map(raw => { + const row = requireJsonObject(raw, "original validation declaration"); + const id = requireAuthorityStoreId(row.todo_id, "original validation Task"); + const declaration = requireJsonObject(row.declaration, "private validation declaration"); + const normalized = normalizeTodoCompletionValidationDeclaration(declaration, {strict_fields: true, require_command: true, require_canonical_input: true}); + if (!hasExactAuthorityKeys(row, ["todo_id", "declaration_sha256", "declaration"]) || id <= previous || + !normalized.ok || canonicalAuthoritySha256(declaration) !== row.declaration_sha256) reject("Original private validation declaration differs from its authority digest"); + previous = id; return row; + }); +} +export async function readTransferValidationDeclarations(root: string, goal: string, projection: JsonObject): Promise { + const references = [...indexCoordinationProjection(projection, goal).todos.values()] + .filter(todo => todo.completion_validation_required === true) + .map(todo => ({todo_id: todo.todo_id, declaration_sha256: todo.completion_validation_sha256})) + .sort((a, b) => String(a.todo_id) < String(b.todo_id) ? -1 : 1); + if (references.some(row => !BARE_SHA256_PATTERN.test(String(row.declaration_sha256)))) reject("Original validation authority digest is missing"); + const declarations = await validationStoreIO("read", root, goal, references); + if (!same(declarations.map(({todo_id, declaration_sha256}) => ({todo_id, declaration_sha256})), references)) reject("Private validation store did not return the selected original declarations"); + return declarations; +} +export async function restoreTransferValidationDeclarations(root: string, goal: string, entries: unknown): Promise { + const declarations = decodeTransferValidations(entries ?? []); + if (!same(await validationStoreIO("restore", root, goal, declarations), declarations)) reject("Original validation store restore did not read back"); +} + /** These are addresses already selected by their owners, not a runtime sweep. * The closed set is local to this private transfer carrier; runtime semantics * remain with the original journal and accounting vocabularies. */ export function decodeGoalTransferHistory(raw: unknown): GoalTransferHistory { const value = requireJsonObject(raw, "original runtime history"); - if (!hasExactAuthorityKeys(value, ["schema_version", "goal_id", "source_runtime_root", "turn_history", "quota_history", "files"]) || + if (!hasExactAuthorityKeys(value, ["schema_version", "goal_id", "source_runtime_root", "turn_history", "quota_history", "files", ...(Object.hasOwn(value, "validation_declarations") ? ["validation_declarations"] : [])]) || value.schema_version !== HISTORY_SCHEMA || typeof value.source_runtime_root !== "string" || !isAbsolute(value.source_runtime_root) || resolve(value.source_runtime_root) !== value.source_runtime_root || value.source_runtime_root.includes("\0") || !Array.isArray(value.files) || value.files.length > 20_000) reject("Original runtime history is invalid or unsupported"); + if (value.validation_declarations !== undefined) decodeTransferValidations(value.validation_declarations); const goal = requireAuthorityStoreId(value.goal_id, "original runtime Goal"); if (/[\\/\0]/u.test(goal) || goal === "." || goal === "..") reject("Original runtime Goal must be one path segment"); const turns = requireJsonObject(value.turn_history, "original Turn history"), quota = requireJsonObject(value.quota_history, "original quota history"); @@ -68,9 +118,9 @@ export function decodeGoalTransferHistory(raw: unknown): GoalTransferHistory { return value as GoalTransferHistory; } -export function goalTransferHistory(root: string, goal: string, turns: JsonObject, quota: JsonObject): GoalTransferHistory { +export function goalTransferHistory(root: string, goal: string, turns: JsonObject, quota: JsonObject, validations: JsonObject[] = []): GoalTransferHistory { return decodeGoalTransferHistory({schema_version: HISTORY_SCHEMA, goal_id: goal, source_runtime_root: root, - turn_history: turns, quota_history: quota, + turn_history: turns, quota_history: quota, ...(validations.length ? {validation_declarations: validations} : {}), files: [...turns.files as JsonObject[], ...quota.files as JsonObject[]].sort((a, b) => String(a.relative_path) < String(b.relative_path) ? -1 : 1)}); } diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index f6d471edb7..a03e062721 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import json from pathlib import Path from typing import Any @@ -101,7 +102,8 @@ def inspect_registered_goal_transfer(*, registry_path: Path, goal_id: str) -> di try: preview = local_goal_transfer_cutover(registry_path=registry_path, goal_id=goal_id, operation_id=review["operation_id"], destination_registry=Path(review["target_registry_path"]), - continuation="reviewed_context", reviewed_preview_sha256=review["preview_sha256"]) + continuation="reviewed_context", reviewed_preview_sha256=review["preview_sha256"], + continue_todo_id=review.get("continue_todo_id")) except Exception: # Only this read-only destination observation failed. Preserve the # native source view/cancellation, including after a browser reload; @@ -122,7 +124,7 @@ def cancel_registered_goal_transfer(*, registry_path: Path, goal_id: str, operat def local_goal_transfer_cutover( *, registry_path: Path, goal_id: str, operation_id: str, destination_registry: Path, expected_preview_sha256: str | None = None, continuation: str | None = None, - reviewed_preview_sha256: str | None = None, + reviewed_preview_sha256: str | None = None, continue_todo_id: str | None = None, ) -> dict[str, Any]: """One source-online local cutover; native owners decide every transition. @@ -131,6 +133,8 @@ def local_goal_transfer_cutover( compensation; same-operation recovery preserves the stopped destination. """ request, snapshot, before = _local_cutover_request(registry_path, goal_id, operation_id, destination_registry, continuation) + if continue_todo_id is not None: + request["continue_todo_id"] = continue_todo_id destination_registry = destination_registry.expanduser().absolute() if reviewed_preview_sha256 is not None: request["reviewed_preview_sha256"] = reviewed_preview_sha256 @@ -168,7 +172,7 @@ def _local_cutover_request(registry_path: Path, goal_id: str, operation_id: str, def prepare_local_goal_transfer(*, registry_path: Path, goal_id: str, operation_id: str, - destination_root: Path, materials: list[Path]) -> dict[str, Any]: + destination_root: Path, materials: list[Path], continue_todo_id: str | None = None) -> dict[str, Any]: """Fixed IO composition of existing Goal, custody and registry owners. The Agent supplies an explicit reviewed file selection. TypeScript chooses @@ -180,6 +184,7 @@ def prepare_local_goal_transfer(*, registry_path: Path, goal_id: str, operation_ **context, "runtime_root": context["source_context"]["runtime_root"], "goal_id": goal_id, "operation_id": operation_id, "destination_root": str(destination_root.expanduser().absolute()), "materials": [str(path.expanduser().absolute()) for path in materials], + **({"continue_todo_id": continue_todo_id} if continue_todo_id is not None else {}), }, timeout=60.0, retry_safe=True) recipient = create_planned_transfer_recipient(destination=Path(plan["recipient_directory"])) exported = export_registered_goal_transfer(registry_path=registry_path, goal_id=goal_id, operation_id=operation_id, @@ -193,13 +198,15 @@ def prepare_local_goal_transfer(*, registry_path: Path, goal_id: str, operation_ request, _, _ = _local_cutover_request(registry_path, goal_id, operation_id, Path(plan["project"]) / ".loopx/registry.json", "reviewed_context") request["execution_repository"] = plan["repository"] + if "continue_todo_id" in plan: + request["continue_todo_id"] = plan["continue_todo_id"] effect_runtime_result("coordination.planned_transfer.retain_local_cutover_review", request, timeout=300.0, retry_safe=False) return inspect_registered_goal_transfer(registry_path=registry_path, goal_id=goal_id) def confirm_registered_goal_transfer(*, registry_path: Path, goal_id: str, operation_id: str, - expected_preview_sha256: str) -> dict[str, Any]: + expected_preview_sha256: str, codex_bin: str | None = None, codex_home: Path | None = None) -> dict[str, Any]: """Confirm/recover the retained original review, never a browser path.""" context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) review = effect_runtime_result("coordination.planned_transfer.read_local_cutover_review", { @@ -207,13 +214,102 @@ def confirm_registered_goal_transfer(*, registry_path: Path, goal_id: str, opera }, timeout=60.0, retry_safe=True) local_goal_transfer_cutover(registry_path=registry_path, goal_id=goal_id, operation_id=operation_id, destination_registry=Path(review["target_registry_path"]), continuation="reviewed_context", - expected_preview_sha256=expected_preview_sha256, reviewed_preview_sha256=review["preview_sha256"]) + expected_preview_sha256=expected_preview_sha256, reviewed_preview_sha256=review["preview_sha256"], + continue_todo_id=review.get("continue_todo_id")) + execution = None + if review.get("continue_todo_id"): + # The accepted cutover remains recorded even if the current Host cannot + # finish. The same native Turn journal owns recovery, never a new job. + try: + execution = _continue_transferred_work(registry_path=Path(review["target_registry_path"]), + goal_id=goal_id, operation_id=operation_id, codex_bin=codex_bin, codex_home=codex_home) + except Exception: + execution = {"ok": False, "reason_code": "original_work_continuation_unavailable"} # A lost later read cannot turn the already accepted cutover into failure. try: readback = inspect_registered_goal_transfer(registry_path=registry_path, goal_id=goal_id) except Exception: readback = None - return {"ok": True, "goal_id": goal_id, "recorded": True, "operation_id": operation_id, "readback": readback} + return {"ok": True, "goal_id": goal_id, "recorded": True, "operation_id": operation_id, "readback": readback, + **({"execution": execution} if execution is not None else {})} + + +def _continue_transferred_work(*, registry_path: Path, goal_id: str, operation_id: str, + codex_bin: str | None, codex_home: Path | None) -> dict[str, Any]: + """IO-only composition of the native plan, lease, Turn and validator owners. + + The mutex coordinates callers, not completion state. Exact replay belongs + exclusively to the existing Turn journal; no Chat Session or job ledger is + synthesized and no historic session/credential binding becomes a grant. + """ + import os + import subprocess + import sys + from ...file_lock import exclusive_cross_runtime_file_lock + from ..turn_driver.journal_store import find_loopx_turn_key_by_settlement_identity + + context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) + runtime = Path(context["source_context"]["runtime_root"]) + plan = effect_runtime_result("coordination.planned_transfer.execution_plan", { + "runtime_root": str(runtime), "goal_id": goal_id, "operation_id": operation_id, + }, retry_safe=True)["plan"] + if plan is None: + return {"ok": False, "reason_code": "original_work_not_prepared"} + project = Path(plan["project"]) + env = dict(os.environ) + if codex_home is not None: + env["CODEX_HOME"] = str(codex_home) + prefix = [sys.executable, "-I", "-m", "loopx.cli", "--format", "json", + "--registry", str(registry_path), "--runtime-root", str(runtime)] + + def invoke(*arguments: str) -> dict[str, Any]: + run = subprocess.run([*prefix, *arguments], cwd=project, env=env, + capture_output=True, text=True, encoding="utf-8", errors="strict", check=False) + return json.loads(run.stdout) + + identity = {key: plan[key] for key in ("goal_id", "agent_id", "todo_id", "turn_instance_id")} + key = plan["turn_instance_id"] + with exclusive_cross_runtime_file_lock(runtime / "goals" / goal_id / "turns" / f"{key}.dispatch", + operation="planned_transfer_original_work"): + original = find_loopx_turn_key_by_settlement_identity(runtime, **identity) + lease = None + if original is None: + args = ["task-lease", "acquire", "--goal-id", goal_id, "--todo-id", plan["todo_id"], + "--owner", plan["agent_id"], "--idempotency-key", key, "--write-worktree", str(project), + "--ttl-seconds", "600"] + for scope in plan["write_scopes"]: + args += ["--write-scope", scope] + acquired = invoke(*args) + if not acquired.get("ok"): + return {"ok": False, **identity, "reason_code": "original_task_lease_unavailable"} + lease = acquired["lease"] + args = ["turn", "run-once", "--goal-id", goal_id, "--agent-id", plan["agent_id"], + "--todo-id", plan["todo_id"], "--turn-instance-id", key, "--project", str(project), + "--host", plan["host"], "--codex-bin", codex_bin or env.get("CODEX_BIN", "codex"), + "--codex-sandbox", plan["sandbox"], "--session-scope", "agent", + "--validation-command-json", json.dumps(plan["validation_argv"]), + "--timeout-seconds", "90", "--no-global-sync", "--execute"] + if lease is not None: + args += ["--task-lease-idempotency-key", lease["idempotency_key"], + "--task-lease-expected-version", str(lease["version"])] + if plan["validation_timeout_seconds"] is not None: + args += ["--validation-timeout-seconds", str(plan["validation_timeout_seconds"])] + for capability in plan["available_capabilities"]: + args += ["--available-capability", capability] + result = invoke(*args) + # A pending settlement keeps its proof for original-Turn recovery. + # Only a successful settled Turn may release still-active work; normal + # canonical completion has already retired its lease atomically. + if lease is not None and result.get("ok") is True: + current = invoke("task-lease", "inspect", "--goal-id", goal_id, "--todo-id", plan["todo_id"]) + held = current.get("lease") + if isinstance(held, dict) and held.get("status") == "active" and held.get("idempotency_key") == key: + released = invoke("task-lease", "release", "--goal-id", goal_id, "--todo-id", plan["todo_id"], + "--owner", plan["agent_id"], "--idempotency-key", key, "--expected-version", str(held["version"])) + if not released.get("ok"): + raise ValueError("Original execution lease requires its owning recovery") + return {"ok": result.get("ok") is True, **identity, + "reason_code": None if result.get("ok") else str(result.get("error_code") or "original_work_turn_requires_recovery")} def read_goal_transfer_host_context(*, registry_path: Path, goal_id: str, @@ -429,12 +525,50 @@ def _read_transfer_destination( return registry_path, target, registry, witness +def _validation_store_io(action: str, runtime_root: Path, goal_id: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Fixed private file IO through the existing declaration store codec.""" + import stat + from ..todos.completion_validation_store import ( + read_completion_validation_declaration, prepare_completion_validation_declaration, + completion_validation_declaration_path, + ) + + restored = [] + for entry in entries: + path = completion_validation_declaration_path(runtime_root=runtime_root, goal_id=goal_id, todo_id=entry["todo_id"]) + blob = path.parent / "blobs" / f"{entry['declaration_sha256']}.json" + for candidate in (path, blob): + if candidate.resolve() != candidate: + raise ValueError("Original validation store path is redirected") + try: + observed = candidate.lstat() + except FileNotFoundError: + continue + if not stat.S_ISREG(observed.st_mode) or observed.st_nlink != 1: + raise ValueError("Original validation store requires a physical private file") + if action == "restore": + digest = prepare_completion_validation_declaration(runtime_root=runtime_root, + goal_id=goal_id, declaration=entry["declaration"]) + if digest != entry["declaration_sha256"]: + raise ValueError("Original validation declaration changed during restore") + elif action != "read": + raise ValueError("Unsupported private validation IO") + declaration = read_completion_validation_declaration(runtime_root=runtime_root, goal_id=goal_id, + todo_id=entry["todo_id"], expected_digest=entry["declaration_sha256"]) + if declaration is None: + raise ValueError("Original private validation declaration is unavailable") + restored.append({"todo_id": entry["todo_id"], "declaration_sha256": entry["declaration_sha256"], "declaration": declaration}) + return restored + + if __name__ == "__main__": - # Fixed native codec reader, with no write action or interpreter override. + # Fixed native codec IO; restoring private validator content grants no execution. # The invoking native owner holds the maintenance/state/registry locks. - import json import sys - if len(sys.argv) != 3: + if len(sys.argv) == 5 and sys.argv[1] == "--validation-io": + print(json.dumps(_validation_store_io(sys.argv[2], Path(sys.argv[3]), sys.argv[4], json.load(sys.stdin)), ensure_ascii=False)) + elif len(sys.argv) != 3: raise ValueError("Registered Goal context reader requires a registry and Goal id") - print(json.dumps(_read_goal_transfer_snapshot(registry_path=Path(sys.argv[1]), goal_id=sys.argv[2]), ensure_ascii=False)) + else: + print(json.dumps(_read_goal_transfer_snapshot(registry_path=Path(sys.argv[1]), goal_id=sys.argv[2]), ensure_ascii=False)) diff --git a/loopx/control_plane/todos/completion_validation.py b/loopx/control_plane/todos/completion_validation.py index 1284b96972..92d7bdc55b 100644 --- a/loopx/control_plane/todos/completion_validation.py +++ b/loopx/control_plane/todos/completion_validation.py @@ -117,14 +117,18 @@ def _resolve_completion_validation_workspace( delivery_workspace: Mapping[str, Any] | None, validation_workspace_path: Path | None, label: str, + todo_id: str | None = None, + declaration_sha256: str | None = None, ) -> tuple[Path | None, dict[str, Any] | None]: """Resolve the authority-bound workspace for one validation effect. The Goal repository remains the default when a Todo does not select a repository. When the host supplies a path-free, turn-bound delivery - workspace snapshot, validation must honor that exact clean independent - worktree even when it has the same canonical repository identity as the - Goal checkout. The snapshot may come from prior writeback or from the + workspace snapshot, validation must honor that exact independent worktree + even when it has the same canonical repository identity as the Goal checkout. + Generic delivery requires a clean tree. An explicitly reviewed transfer may + instead admit its original Task's pending working files through the native + transfer owner, preserving the original independent validation declaration. The snapshot may come from prior writeback or from the host's exact-Turn pre-completion check because completion itself precedes refresh. The candidate path is host execution context, never a public CLI cwd override. @@ -218,6 +222,25 @@ def _resolve_completion_validation_workspace( ), ) clean = _git_workspace_is_clean(candidate) + if clean is False and todo_id is not None and declaration_sha256 is not None: + # A prepared move may explicitly select its original pending working + # files. The TS transfer owner verifies that exact Task, declaration, + # admitted runtime and physical workspace; generic clean validation is + # unchanged. The terminal transaction still rechecks its current lease. + from ..effect_runtime import effect_runtime_result + from ..runtime.runtime_projection_route import resolve_goal_source_runtime_route + + try: + route = resolve_goal_source_runtime_route(registry_path=registry_path, goal_id=goal_id) + admitted = effect_runtime_result("coordination.planned_transfer.validation_workspace", { + "runtime_root": route["source_runtime_root"], "goal_id": goal_id, "todo_id": todo_id, + "workspace_path": str(candidate.resolve()), "declaration_sha256": declaration_sha256, + }) + except Exception: + return None, _workspace_failure(label, status="workspace_unverified", + summary="Original working-state validation admission could not be verified") + if admitted == {"admitted": True}: + return candidate, None if clean is not True: return None, _workspace_failure( label, @@ -257,6 +280,8 @@ def _run_declared_completion_validation( task_repository: str | None = None, delivery_workspace: Mapping[str, Any] | None = None, validation_workspace_path: Path | None = None, + todo_id: str | None = None, + declaration_sha256: str | None = None, ) -> dict[str, Any] | None: """Run a todo's declared caller-approved validation command. @@ -285,7 +310,7 @@ def _run_declared_completion_validation( task_repository=task_repository, delivery_workspace=delivery_workspace, validation_workspace_path=validation_workspace_path, - label=label, + label=label, todo_id=todo_id, declaration_sha256=declaration_sha256, ) if workspace_failure is not None: return workspace_failure @@ -363,6 +388,7 @@ def run_declared_completion_validation_effect( goal_id: str, delivery_workspace: Mapping[str, Any] | None = None, validation_workspace_path: Path | None = None, + todo_id: str | None = None, ) -> dict[str, Any]: """Execute exactly one TypeScript-authorized validation effect. @@ -414,6 +440,7 @@ def run_declared_completion_validation_effect( ), delivery_workspace=delivery_workspace, validation_workspace_path=validation_workspace_path, + todo_id=todo_id, declaration_sha256=declaration_digest, ) if receipt is None: raise RuntimeError("authorized validation effect produced no receipt") @@ -709,6 +736,7 @@ def execute_completion_validation_effects( if isinstance(effect, Mapping): updates["validation_receipt"] = run_declared_completion_validation_effect( effect=effect, + todo_id=str(result["todo_id"]) if result.get("todo_id") is not None else None, registry_path=registry_path, goal_id=goal_id, delivery_workspace=delivery_workspace, diff --git a/loopx/presentation/goal_transfer_api.py b/loopx/presentation/goal_transfer_api.py index cf84e713ea..d34396b7d8 100644 --- a/loopx/presentation/goal_transfer_api.py +++ b/loopx/presentation/goal_transfer_api.py @@ -36,7 +36,9 @@ def _goal_transfer(self) -> None: result = inspect_registered_goal_transfer(registry_path=self.server.registry_path, goal_id=body["goal_id"]) elif action == "confirm": result = confirm_registered_goal_transfer(registry_path=self.server.registry_path, goal_id=body["goal_id"], - operation_id=body["operation_id"], expected_preview_sha256=body["preview_sha256"]) + operation_id=body["operation_id"], expected_preview_sha256=body["preview_sha256"], + codex_bin=getattr(getattr(self.server, "runtime_controller", None), "codex_bin", None), + codex_home=getattr(getattr(self.server, "runtime_controller", None), "codex_home", None)) else: result = {"ok": True, **cancel_registered_goal_transfer(registry_path=self.server.registry_path, goal_id=body["goal_id"], operation_id=body["operation_id"])} diff --git a/tests/control_plane/test_planned_transfer_execution.py b/tests/control_plane/test_planned_transfer_execution.py new file mode 100644 index 0000000000..569d0fe779 --- /dev/null +++ b/tests/control_plane/test_planned_transfer_execution.py @@ -0,0 +1,232 @@ +"""One move confirmation continues the original Task through actual owners. + +Expected output is declared before transfer. These isolated File/SQLite fixtures +use a real Codex process with deterministic local inference, never a paid model. +""" +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import sys + +import pytest + +from loopx.control_plane.effect_runtime import restart_effect_runtime +from loopx.control_plane.todos.completion_validation_store import completion_validation_declaration_path, read_completion_validation_declaration +from loopx.control_plane.turn_driver.codex_sessions import select_codex_cli_session +from loopx.history import collect_history +from tests.control_plane.test_planned_transfer_cancellation import source as source_fixture +from tests.control_plane.test_planned_transfer_operation import _http +from tests.control_plane.test_planned_transfer_product import _goal_document + +source = source_fixture + + +def _prepare(source, tmp_path): + destination = tmp_path / "destination" + destination.mkdir() + return destination, source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", + "--operation-id", "move-a", "--destination-root", str(destination), + "--continue-todo-id", source["todo"], "--execute") + + +def _declare_original_checks(source): + # Validator is original Task authority, not a browser field or model claim. + preparation_lease = source["acquire"]("prepare-original-validation")["lease"] + record = source["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"]) + validator = [sys.executable, "-c", "from pathlib import Path; assert Path('report.txt').read_bytes()==b'21\\n'"] + source["cli"]("todo", "update", "--goal-id", "report", "--todo-id", source["todo"], + "--agent-id", "agent-a", "--update-operation-id", "original-independent-validator", + "--update-expected-provider-revision", record["authority_read"]["provider_revision"], + "--validation-command-json", json.dumps(validator), + "--task-lease-idempotency-key", preparation_lease["idempotency_key"], + "--task-lease-expected-version", str(preparation_lease["version"]), "--execute") + source["release"](preparation_lease) + original_task = source["cli"]("todo", "list", "--goal-id", "report", "--todo-id", source["todo"])["todo"] + return original_task, validator + + +def test_automatic_continuation_requires_original_independent_acceptance_before_fencing(source, tmp_path): + before = source["registry"].read_bytes(), source["fence"].read_bytes() + destination = tmp_path / "destination" + destination.mkdir() + result = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", + "--operation-id", "move-a", "--destination-root", str(destination), + "--continue-todo-id", source["todo"], "--execute", expected_code=1) + assert "independent" in result["error"] + assert before == (source["registry"].read_bytes(), source["fence"].read_bytes()) + assert list(destination.iterdir()) == [] + + +@pytest.mark.parametrize("native_outcome", ["accepted", "rejected"]) +@pytest.mark.skipif(not os.environ.get("LOOPX_NATIVE_CHECKPOINT_BIN"), reason="requires the isolated real Codex fixture") +def test_confirmation_runs_registered_original_agent_and_independently_returns_result_once(source, tmp_path, monkeypatch, native_outcome): + from tests.control_plane.codex_checkpoint_fixture import real_native_source + + _goal_document(source, absolute=True) + original_task, validator = _declare_original_checks(source) + actual = source["project"].parent / "agent" + (actual / "input.txt").write_text("5\n7\n9\n") + (actual / "report.txt").write_text("unfinished\n") + destination, prepared = _prepare(source, tmp_path) + assert prepared["prepared_cutover"]["continue_work"]["todo_id"] == source["todo"] + assert prepared["prepared_cutover"]["continue_work"]["agent_id"] == "agent-a" + requests = [] + + def response(body, number, message): + if number == 1: + return message + for item in body.get("input", []): + for part in item.get("content", []) if isinstance(item, dict) else []: + text = part.get("text", "") if isinstance(part, dict) else "" + if "Turn request:\n" in text and not requests: + requests.append(json.loads(text.split("Turn request:\n", 1)[1])) + assert requests and requests[0]["transfer_context"]["selected_todo"]["todo_id"] == source["todo"] + if number == 2: + script = "from pathlib import Path; assert Path('report.txt').read_text()=='unfinished\\n'; Path('report.txt').write_text(str(sum(map(int,Path('input.txt').read_text().split())))+'\\n')" + if native_outcome == "rejected": + script = script.replace("str(sum(map(int,Path('input.txt').read_text().split())))", "'20'") + return {"type": "function_call", "id": "fc_report", "call_id": "call_report", "name": "exec_command", + "arguments": json.dumps({"cmd": shlex.quote(sys.executable) + " -c " + shlex.quote(script), "max_output_tokens": 1000})} + result = {"schema_version": "loopx_turn_result_v0", "turn_key": requests[0]["turn_key"], + "result_kind": "validated_completion", "completed_phases": ["host_execute", "typed_result"], + "classification": "original_report_complete", "recommended_action": "Read the original result.", + "next_action": "Original report independently accepted as 21.", "delivery_batch_scale": "implementation", + "delivery_outcome": "outcome_progress", "vision_unchanged_reason": "Original independent report criterion remains unchanged.", + "path_delta_mode": "unchanged", "agent_vision_json": "", "summary": "Continued the original unfinished report."} + return {**message, "content": [{"type": "output_text", "text": json.dumps(result)}]} + + with real_native_source(tmp_path / "native", os.environ["LOOPX_NATIVE_CHECKPOINT_BIN"], actual, + close_source=True, response_builder=response, sandbox="workspace-write") as (env, unused, _, calls): + for key, value in env.items(): + monkeypatch.setenv(key, value) + server, thread, call = _http(source) + try: + request = {"action": "confirm", "goal_id": "report", "operation_id": "move-a", + "preview_sha256": prepared["prepared_cutover"]["preview_sha256"]} + status, result = call(request) + assert status == 200 and result["recorded"] is True + if native_outcome == "accepted": + assert result["execution"]["ok"] is True, json.dumps(result["execution"]) + work = next(row for row in result["readback"]["work"]["todos"] if row["todo_id"] == source["todo"]) + assert work["done"] is (native_outcome == "accepted") + if native_outcome == "accepted": + assert "21" in work["note"] + for field in ("text", "claimed_by", "task_repository", "required_write_scopes"): + assert work.get(field) == original_task.get(field) + assert work.get("required_capabilities", []) == original_task.get("required_capabilities", []) + assert work["completion_validation_required"] is True + assert work["completion_validation_sha256"] == original_task["completion_validation_sha256"] + assert requests[0]["transfer_context"]["agent_id"] == "agent-a" + binding = select_codex_cli_session(destination / "runtime", + lineage={"goal_id": "report", "agent_id": "agent-a"}, session_scope="agent") + assert binding["session_id"] not in ("original-context", unused) + count = len(calls) + assert count == 3 + target_work = destination / "agent-workspace" + assert (target_work / "report.txt").read_bytes() == (b"21\n" if native_outcome == "accepted" else b"20\n") + (target_work / "later.txt").write_text("Keep later destination work\n") + quota_before_replay = collect_history(registry_path=destination / "workspace/.loopx/registry.json", + runtime_root=destination / "runtime", goal_id="report", limit=50)["goals"][0]["quota"]["spent_slots"] + restart_effect_runtime(wait_for_exit=True) + replay = call(request)[1] + assert replay["execution"]["ok"] == result["execution"]["ok"] and len(calls) == count + assert replay["readback"]["work"] == result["readback"]["work"] + assert (target_work / "later.txt").read_text() == "Keep later destination work\n" + history = collect_history(registry_path=destination / "workspace/.loopx/registry.json", + runtime_root=destination / "runtime", goal_id="report", limit=50)["goals"][0] + assert history["quota"]["spent_slots"] == quota_before_replay + assert quota_before_replay == (2 if native_outcome == "accepted" else 1) + if native_outcome == "accepted": + assert quota_before_replay == 2 + forged = subprocess.run([sys.executable, "-I", "-m", "loopx.cli", "--format", "json", + "--registry", str(destination / "workspace/.loopx/registry.json"), "--runtime-root", str(destination / "runtime"), + "turn", "run-once", "--goal-id", "report", "--agent-id", "agent-a", "--todo-id", source["todo"], + "--turn-instance-id", result["execution"]["turn_instance_id"], "--project", str(target_work), + "--host", "codex-cli", "--task-lease-idempotency-key", "forged-new-proof", "--task-lease-expected-version", "1", + "--execute"], cwd=target_work, capture_output=True, text=True) + assert forged.returncode == 1 and "completion lease proof changed" in json.loads(forged.stdout)["error"] + assert len(calls) == count + assert (actual / "report.txt").read_text() == "unfinished\n" + assert not (source["runtime"] / "chat").exists() + target_registry = destination / "workspace/.loopx/registry.json" + data = json.loads(target_registry.read_text()) + assert data["goals"][0]["coordination"]["registered_agents"] == ["agent-a"] + finally: + server.shutdown() + thread.join(5) + server.server_close() + + +def test_default_move_restores_original_checks_without_launching_a_host(source, tmp_path, monkeypatch): + _goal_document(source, absolute=True) + original, validator = _declare_original_checks(source) + destination = tmp_path / "destination" + destination.mkdir() + prepared = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", "--operation-id", "move-a", + "--destination-root", str(destination), "--execute") + assert "continue_work" not in prepared["prepared_cutover"] + monkeypatch.setenv("CODEX_BIN", "/fixture/unavailable-codex") + server, thread, call = _http(source) + try: + status, result = call({"action": "confirm", "goal_id": "report", "operation_id": "move-a", + "preview_sha256": prepared["prepared_cutover"]["preview_sha256"]}) + assert status == 200 and result["recorded"] is True and "execution" not in result + assert next(row for row in result["readback"]["work"]["todos"] if row["todo_id"] == source["todo"])["done"] is False + declaration = read_completion_validation_declaration(runtime_root=destination / "runtime", goal_id="report", + todo_id=source["todo"], expected_digest=original["completion_validation_sha256"]) + assert declaration["validation_command_argv"] == validator + assert not (destination / "runtime/goals/report/turn-sessions").exists() + finally: + server.shutdown() + thread.join(5) + server.server_close() + + +@pytest.mark.parametrize("damage", ["missing", "changed", "linked"]) +def test_changed_target_validation_keeps_source_cancel_available(source, tmp_path, damage): + _goal_document(source, absolute=True) + original, _ = _declare_original_checks(source) + destination, prepared = _prepare(source, tmp_path) + blob = completion_validation_declaration_path(runtime_root=destination / "runtime", goal_id="report", + todo_id=source["todo"]).parent / "blobs" / f"{original['completion_validation_sha256']}.json" + if damage == "changed": + blob.write_text("{}") + else: + blob.unlink() + if damage == "linked": + source_blob = completion_validation_declaration_path(runtime_root=source["runtime"], goal_id="report", + todo_id=source["todo"]).parent / "blobs" / blob.name + blob.symlink_to(source_blob) + server, thread, call = _http(source) + try: + status, _ = call({"action": "confirm", "goal_id": "report", "operation_id": "move-a", + "preview_sha256": prepared["prepared_cutover"]["preview_sha256"]}) + assert status == 400 + assert call({"action": "inspect", "goal_id": "report"})[1]["source_state"] == "prepared" + assert call({"action": "cancel", "goal_id": "report", "operation_id": "move-a"})[1]["readback"]["original_writer_fence_restored"] is True + lease = source["acquire"]("continue-source-after-check-failure")["lease"] + source["release"](lease) + finally: + server.shutdown() + thread.join(5) + server.server_close() + + +def test_missing_original_validation_content_refuses_before_fence(source, tmp_path): + original, _ = _declare_original_checks(source) + blob = completion_validation_declaration_path(runtime_root=source["runtime"], goal_id="report", + todo_id=source["todo"]).parent / "blobs" / f"{original['completion_validation_sha256']}.json" + blob.unlink() + # The existing declaration owner also supports its legacy per-Task cache. + # Removing only the immutable blob is not loss of the selected content. + completion_validation_declaration_path(runtime_root=source["runtime"], goal_id="report", todo_id=source["todo"]).unlink() + before = source["fence"].read_bytes(), source["registry"].read_bytes() + destination = tmp_path / "destination" + destination.mkdir() + result = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", "--operation-id", "move-a", + "--destination-root", str(destination), "--continue-todo-id", source["todo"], "--execute", expected_code=1) + assert result["ok"] is False and before == (source["fence"].read_bytes(), source["registry"].read_bytes()) + assert list(destination.iterdir()) == [] From aee5aa064554fc8496497d7a7acc904f44307b06 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:08:25 +0800 Subject: [PATCH 24/55] Keep original move recovery reachable until its Task settles Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../coordination/planned_transfer_authority.ts | 9 ++++++++- tests/control_plane/test_planned_transfer_execution.py | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index 2d3c60f0bd..6e95938b80 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -259,13 +259,20 @@ export async function inspectRegisteredGoalTransfer(value: JsonObject): Promise< if (cutover) { const {readLocalGoalTransferContinuation} = await import("../goals/planned_transfer_cutover.ts"); const current = await readLocalGoalTransferContinuation(cutover); + const work = current.work as JsonObject | undefined; + // Runtime admission does not settle the explicitly selected Task. Keep + // its original confirmation reachable after a failed/lost continuation; + // a completed Task and the default admission-only profile need no retry. + const originalWorkPending = review?.continue_todo_id !== undefined && Array.isArray(work?.todos) && + (work.todos as JsonObject[]).some(todo => todo.todo_id === review.continue_todo_id && + todo.done !== true && todo.archive_state !== "archive" && todo.status === "open"); return {ok: true, schema_version: "loopx_planned_transfer_operation_view_v0", goal_id: goal.id, source_state: "cutover_recorded", operation_id: preparedOperation, source_provider: preview.provider, source_revision: preview.provider_revision, authority_preparation_ready: false, cancel_available: false, continuation_available: false, local_cutover: {source_final: true, target_task_admitted: current.target_task_admitted, target_runtime_admitted: current.target_runtime_admitted, ...(current.target_activation_state ? {target_activation_state: current.target_activation_state} : {})}, - ...(review && !current.target_runtime_admitted ? {_local_cutover_review: review} : {}), + ...(review && (!current.target_runtime_admitted || originalWorkPending) ? {_local_cutover_review: review} : {}), work: current.work ?? {objective: source.objective, requirements: context.state_text, agents: coordination.registered_agents ?? [], todos: todos.todo_ids.map(id => todos.todos.get(id)!)}, holds: [{code: "transfer_journey_incomplete" satisfies GoalTransferHoldCode, owner: "goal_transfer"}]}; diff --git a/tests/control_plane/test_planned_transfer_execution.py b/tests/control_plane/test_planned_transfer_execution.py index 569d0fe779..ffca839c07 100644 --- a/tests/control_plane/test_planned_transfer_execution.py +++ b/tests/control_plane/test_planned_transfer_execution.py @@ -114,6 +114,11 @@ def response(body, number, message): assert work["done"] is (native_outcome == "accepted") if native_outcome == "accepted": assert "21" in work["note"] + assert "prepared_cutover" not in result["readback"] + else: + # Admission alone must not hide the original UI recovery. + assert result["readback"]["prepared_cutover"] == prepared["prepared_cutover"] + assert call({"action": "inspect", "goal_id": "report"})[1]["prepared_cutover"] == prepared["prepared_cutover"] for field in ("text", "claimed_by", "task_repository", "required_write_scopes"): assert work.get(field) == original_task.get(field) assert work.get("required_capabilities", []) == original_task.get("required_capabilities", []) From c249422f25db690e9f4fdf1a0f3df0d99f967d78 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:34:52 +0800 Subject: [PATCH 25/55] Distinguish move recovery from its original confirmation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../personal-workspace/goal-transfer-settings.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx index 5ca5b9fd38..b21b40f136 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx @@ -65,7 +65,7 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { {cutoverRecorded ?

{zh ? "本次切换已接受,源环境保持停写。" : "This switch was accepted. Source writes remain closed."}

: null} {executionAccepted !== null ?

{executionAccepted ? (zh ? "续办请求已处理;下方为原任务的当前状态和独立验收结果。" : "The continuation request was processed. Its original task's current state and independent checks appear below.") - : (zh ? "切换已接受,原任务续办尚未成功。可恢复同一次切换;已记录的执行和结果不会重复。" : "The switch was accepted, but original work has not continued successfully. Resume this move to recover its recorded execution without duplication.")}

: null} + : (zh ? "切换已接受,本次续办请求需要恢复。请以下方原任务的当前状态为准;恢复本次切换不会重复已记录的工作或扣额。" : "The switch was accepted. This continuation request needs recovery; read the original task's current status below. Resume this move without repeating recorded work or debits.")}

: null} {view ? <>

{view.local_cutover ? (view.local_cutover.target_activation_state === "stopped" ? (zh ? "目标 Goal 已停止,仍可查看原任务结果。恢复该 Goal 后才可继续执行。" : "The destination Goal is stopped. Original work remains readable; resume that Goal before continuing execution.") @@ -82,7 +82,7 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { {view.prepared_cutover_unavailable ?

{zh ? "已准备目标的当前状态无法确认。可重新读取;尚未切换时也可取消准备,恢复源环境。" : "The prepared destination could not be verified. Read current status again, or cancel preparation if the switch has not happened."}

: null} -
+
{zh ? "原工作" : "Original work"} · {view.work.todos.length} {view.work.agents.length ?

{view.work.agents.join(" · ")}

: null}
    {view.work.todos.map(todo =>
  • {todo.text} · {zh @@ -93,11 +93,12 @@ export function GoalTransferSettings({goalId}: {goalId: string}) {
  • )}
{view.prepared_cutover ? <> -

{zh ? "已准备的本地目标:" : "Prepared local destination: "}{view.prepared_cutover.destination_name}

-

{view.prepared_cutover.continue_work +

{view.local_cutover ? (zh ? "本地目标:" : "Local destination: ") + : (zh ? "已准备的本地目标:" : "Prepared local destination: ")}{view.prepared_cutover.destination_name}

+ {!view.local_cutover ?

{view.prepared_cutover.continue_work ? (zh ? `确认后,${view.prepared_cutover.continue_work.agent_id} 使用目标环境当前的 Codex 登录,在目标工作区继续原任务并运行原验收。不会安装源环境凭证。` : `Confirm to let ${view.prepared_cutover.continue_work.agent_id} continue the original task in its destination workspace with the destination's current Codex login and original independent checks. Source credentials are not installed.`) - : (zh ? "确认后,由目标环境取得新的执行准入并继续上面的原工作。" : "Confirm to admit the destination for new execution and continue the original work above.")}

+ : (zh ? "确认后,由目标环境取得新的执行准入并继续上面的原工作。" : "Confirm to admit the destination for new execution and continue the original work above.")}

: null} From 45aebe4e034f1ebe9bb5bb149b20f0072d33798d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:51:36 +0800 Subject: [PATCH 26/55] feat(transfer): control the admitted destination from the original move entry Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 12 ++++ .../goal-transfer-settings.tsx | 31 ++++++-- .../planned_transfer_authority.ts | 1 + .../goals/planned_transfer_continuation.ts | 1 + .../goals/planned_transfer_cutover.ts | 6 +- .../goals/planned_transfer_io.py | 70 ++++++++++++++++++- loopx/presentation/goal_transfer_api.py | 12 +++- 7 files changed, 122 insertions(+), 11 deletions(-) diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index c50e4ad363..ab2732040d 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -51,6 +51,8 @@ const goalTransferViewSchema = z.object({ continue_work: z.object({todo_id: z.string(), agent_id: z.string(), host: z.literal("codex-cli"), sandbox: z.literal("workspace-write"), validation_workspace: z.literal("reviewed_working_tree"), validation_sha256: z.string()}).optional()}).optional(), prepared_cutover_unavailable: z.boolean().optional(), + destination_control: z.object({operation: z.enum(["stop", "resume"]), expected_state_fingerprint: z.string()}).optional(), + destination_control_unavailable: z.boolean().optional(), work: z.object({objective: z.string(), agents: z.array(z.string()), todos: z.array(z.object({todo_id: z.string(), text: z.string(), status: z.string(), note: z.string().nullish(), evidence: z.string().nullish()}))}), @@ -80,6 +82,16 @@ export async function confirmGoalTransfer(goalId: string, review: NonNullable) { + return z.object({ok: z.literal(true), after_state: z.enum(["active", "stopped"]), + changed: z.boolean(), readback: goalTransferViewSchema.nullable()}).parse( + await requestJson("/api/chat/goal-transfer", {method: "POST", body: JSON.stringify({ + action: control.operation, goal_id: goalId, operation_id: operationId, + expected_state_fingerprint: control.expected_state_fingerprint, + })})); +} + function chatApiUrl(path: string) { if (!configuredChatOrigin || /^https?:\/\//.test(path)) { return path; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx index b21b40f136..5ee8360012 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-transfer-settings.tsx @@ -1,9 +1,9 @@ import {useRef, useState} from "react"; -import {cancelGoalTransfer, confirmGoalTransfer, inspectGoalTransfer, type GoalTransferView} from "../../data/chat"; +import {cancelGoalTransfer, confirmGoalTransfer, controlGoalTransfer, inspectGoalTransfer, type GoalTransferView} from "../../data/chat"; import {useWorkspaceI18n} from "./i18n"; import "./goal-transfer-settings.css"; -type TransferAction = "read" | "cancel" | "confirm"; +type TransferAction = "read" | "cancel" | "confirm" | "stop" | "resume"; /** The Agent prepares the existing local owners; this entry confirms only the * retained exact review and reads actual destination work. Full journey held. */ @@ -31,6 +31,12 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { inFlight.current = true; setBusy(true); setPendingAction(action); setError(""); if (action === "read") setView(null); try { + if ((action === "stop" || action === "resume") && view?.operation_id && view.destination_control?.operation === action) { + const result = await controlGoalTransfer(goalId, view.operation_id, view.destination_control); + setView(result.readback); + if (!result.readback) setError(zh ? "目标状态已确认,请重新读取当前状态。" : "The destination state was confirmed. Read its current status again."); + return; + } if (action === "confirm" && view?.prepared_cutover) { const result = await confirmGoalTransfer(goalId, view.prepared_cutover); setCutoverRecorded(true); setView(result.readback); @@ -56,8 +62,12 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { }}> {zh ? "迁移 Agent" : "Move Agent"} {busy ?

{pendingAction === "confirm" - ? (zh ? "正在确认切换并继续原任务…" : "Confirming the move and continuing original work…") + ? (view?.prepared_cutover?.continue_work + ? (zh ? "正在确认切换并继续原任务…" : "Confirming the move and continuing original work…") + : (zh ? "正在确认本地切换…" : "Confirming the local move…")) : pendingAction === "cancel" ? (zh ? "正在取消迁移准备…" : "Cancelling move preparation…") + : pendingAction === "stop" ? (zh ? "正在停止目标 Goal…" : "Stopping the destination Goal…") + : pendingAction === "resume" ? (zh ? "正在恢复目标 Goal…" : "Resuming the destination Goal…") : (zh ? "读取原工作与迁移状态…" : "Reading original work and transfer state…")}

: null} {cancelled !== null ?

{cancelled ? (zh ? "准备已取消,源环境的原写入边界已恢复。" : "Preparation cancelled. The original source write boundary is restored.") @@ -77,6 +87,19 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { : view.source_state === "prepared" ? (zh ? "源环境已准备迁移,当前停止写入。" : "The source is prepared for transfer and rejects writes.") : (zh ? "源环境可供检查;尚未切换到目标环境。" : "The source is available for inspection. No destination cutover has occurred.")}

+ {view.destination_control ? <> + +

{view.destination_control.operation === "stop" + ? (zh ? "停止后不再启动新工作;当前工作是否结束,以实际状态为准。源环境仍保持停写。" : "Stopping prevents new work. Read actual status for work already in progress. Source writes remain closed.") + : (zh ? "恢复允许后续执行,不会重新运行已完成的工作。源环境仍保持停写。" : "Resuming permits later execution without rerunning completed work. Source writes remain closed.")}

+ : null} + {view.destination_control_unavailable ?

{zh + ? "目标控制暂不可用。请重新读取当前状态;原任务结果和源环境停写仍保留。" + : "Destination controls are unavailable. Read current status again; original results and the source write fence are retained."}

: null}

{view.work.objective}

    {view.holds.map(hold =>
  • {holds[hold.code]}
  • )}
{view.prepared_cutover_unavailable ?

{zh @@ -98,7 +121,7 @@ export function GoalTransferSettings({goalId}: {goalId: string}) { {!view.local_cutover ?

{view.prepared_cutover.continue_work ? (zh ? `确认后,${view.prepared_cutover.continue_work.agent_id} 使用目标环境当前的 Codex 登录,在目标工作区继续原任务并运行原验收。不会安装源环境凭证。` : `Confirm to let ${view.prepared_cutover.continue_work.agent_id} continue the original task in its destination workspace with the destination's current Codex login and original independent checks. Source credentials are not installed.`) - : (zh ? "确认后,由目标环境取得新的执行准入并继续上面的原工作。" : "Confirm to admit the destination for new execution and continue the original work above.")}

: null} + : (zh ? "确认后,目标环境取得新的执行准入并保留原任务。尚未配置自动续办,仍需在目标环境启动执行。" : "Confirm to admit the destination and retain its original tasks. Automatic continuation is not configured; start execution in the destination environment.")}

: null} diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index 6e95938b80..44da7717c0 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -273,6 +273,7 @@ export async function inspectRegisteredGoalTransfer(value: JsonObject): Promise< target_runtime_admitted: current.target_runtime_admitted, ...(current.target_activation_state ? {target_activation_state: current.target_activation_state} : {})}, ...(review && (!current.target_runtime_admitted || originalWorkPending) ? {_local_cutover_review: review} : {}), + ...(current._local_activation_target ? {_local_activation_target: current._local_activation_target} : {}), work: current.work ?? {objective: source.objective, requirements: context.state_text, agents: coordination.registered_agents ?? [], todos: todos.todo_ids.map(id => todos.todos.get(id)!)}, holds: [{code: "transfer_journey_incomplete" satisfies GoalTransferHoldCode, owner: "goal_transfer"}]}; diff --git a/loopx/control_plane/goals/planned_transfer_continuation.ts b/loopx/control_plane/goals/planned_transfer_continuation.ts index 690cc64b69..8b1d6f4882 100644 --- a/loopx/control_plane/goals/planned_transfer_continuation.ts +++ b/loopx/control_plane/goals/planned_transfer_continuation.ts @@ -287,5 +287,6 @@ export async function inspectAdmittedGoalTransferDestination(value: JsonObject): local_cutover: {source_final: true, target_task_admitted: current.target_task_admitted, target_runtime_admitted: current.target_runtime_admitted, ...(current.target_activation_state ? {target_activation_state: current.target_activation_state} : {})}, work: current.work, + ...(current._local_activation_target ? {_local_activation_target: current._local_activation_target} : {}), holds: [{code: "transfer_journey_incomplete", owner: "goal_transfer"}]}; } diff --git a/loopx/control_plane/goals/planned_transfer_cutover.ts b/loopx/control_plane/goals/planned_transfer_cutover.ts index 9ac8e91c4d..751164cd4b 100644 --- a/loopx/control_plane/goals/planned_transfer_cutover.ts +++ b/loopx/control_plane/goals/planned_transfer_cutover.ts @@ -266,7 +266,11 @@ export async function readLocalGoalTransferContinuation(decision: JsonObject): P reject("Original destination changed during readback; retry its current state"); } return {target_task_admitted: true, target_runtime_admitted: fence.fence.schema_version === PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA, - target_activation_state: snapshot.activation_state, work: {objective: snapshot.state_objective, requirements: context.state_text, + target_activation_state: snapshot.activation_state, + ...(fence.fence.schema_version === PLANNED_TRANSFER_RUNTIME_FENCE_SCHEMA ? {_local_activation_target: { + registry_path: target.registry_path, runtime_root: root, goal_id: goal, operation_id: decision.operation_id, + }} : {}), + work: {objective: snapshot.state_objective, requirements: context.state_text, agents: coordination.registered_agents, todos: index.todo_ids.map(id => index.todos.get(id)!)}}; } diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index a03e062721..8474ed9e7d 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -91,12 +91,42 @@ def preview_registered_goal_transfer(*, registry_path: Path, goal_id: str, opera return result -def inspect_registered_goal_transfer(*, registry_path: Path, goal_id: str) -> dict[str, Any]: - """Shared Settings/CLI transport; the native owner discovers preparation.""" +def _read_registered_goal_transfer(*, registry_path: Path, goal_id: str) -> dict[str, Any]: + """Private native view; resolved control routes never enter public packets.""" context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) - result: dict[str, Any] = effect_runtime_result("coordination.planned_transfer.inspect_operation", { + return effect_runtime_result("coordination.planned_transfer.inspect_operation", { "runtime_root": context["source_context"]["runtime_root"], "goal_id": goal_id, **context, }, timeout=60.0, retry_safe=True) + + +def _destination_activation(target: dict[str, Any], *, state: str, + expected_state_fingerprint: str | None = None, execute: bool = False) -> dict[str, Any]: + from .activation_service import set_goal_activation_state + + return set_goal_activation_state(registry_path=Path(target["registry_path"]), + goal_id=target["goal_id"], state=state, runtime_root_override=target["runtime_root"], + expected_state_fingerprint=expected_state_fingerprint, + actor_kind="owner" if execute else None, execute=execute) + + +def inspect_registered_goal_transfer(*, registry_path: Path, goal_id: str) -> dict[str, Any]: + """Shared Settings/CLI transport; the native owner discovers preparation.""" + result = _read_registered_goal_transfer(registry_path=registry_path, goal_id=goal_id) + target = result.pop("_local_activation_target", None) + if target is not None: + from .activation import GoalActivationState + + state = GoalActivationState(result["local_cutover"]["target_activation_state"]) + operation = "resume" if state is GoalActivationState.STOPPED else "stop" + try: + preview = _destination_activation(target, + state=GoalActivationState.ACTIVE.value if operation == "resume" else GoalActivationState.STOPPED.value) + if preview.get("ok") is not True: + raise ValueError("Current destination lifecycle is unavailable") + result["destination_control"] = {"operation": operation, + "expected_state_fingerprint": preview["observed_state_fingerprint"]} + except (OSError, ValueError): + result["destination_control_unavailable"] = True review = result.pop("_local_cutover_review", None) if review is not None: try: @@ -114,6 +144,40 @@ def inspect_registered_goal_transfer(*, registry_path: Path, goal_id: str) -> di return result +def control_transferred_goal(*, registry_path: Path, goal_id: str, operation_id: str, + operation: str, expected_state_fingerprint: str) -> dict[str, Any]: + """Owner-only UI transport to the original destination's lifecycle owner. + + This does not redirect callers or old executors. Both the native cutover + binding and the existing activation fingerprint are checked per request. + Resuming permits future admission; it never replays a Turn or spends quota. + """ + from .activation import GoalActivationState + + if operation not in ("stop", "resume"): + raise ValueError("Use the existing Goal stop or resume operation") + original = _read_registered_goal_transfer(registry_path=registry_path, goal_id=goal_id) + target = original.get("_local_activation_target") + if not isinstance(target, dict) or target["operation_id"] != operation_id or target["goal_id"] != goal_id: + raise ValueError("Original admitted destination is unavailable; preserve its source") + state = GoalActivationState.STOPPED if operation == "stop" else GoalActivationState.ACTIVE + preview = _destination_activation(target, state=state.value) + if preview.get("ok") is not True: + raise ValueError("Destination lifecycle preview is unavailable") + # A response-loss retry may observe the requested state, but it cannot + # perform another write on stale authority or claim a new execution. + result = preview if preview.get("changed") is False else _destination_activation(target, + state=state.value, expected_state_fingerprint=expected_state_fingerprint, execute=True) + if result.get("ok") is not True or result.get("readback", {}).get("verified") is not True: + raise ValueError("Destination changed; read its current state before retrying") + try: + readback = inspect_registered_goal_transfer(registry_path=registry_path, goal_id=goal_id) + except (OSError, ValueError): + readback = None + return {"ok": True, "goal_id": goal_id, "operation_id": operation_id, + "after_state": state.value, "changed": result["changed"], "readback": readback} + + def cancel_registered_goal_transfer(*, registry_path: Path, goal_id: str, operation_id: str) -> dict[str, Any]: """An address is not a grant; cancellation checks the retained native owner.""" context = read_goal_transfer_context(registry_path=registry_path, goal_id=goal_id) diff --git a/loopx/presentation/goal_transfer_api.py b/loopx/presentation/goal_transfer_api.py index d34396b7d8..b0ed032e5a 100644 --- a/loopx/presentation/goal_transfer_api.py +++ b/loopx/presentation/goal_transfer_api.py @@ -7,6 +7,7 @@ cancel_registered_goal_transfer, inspect_registered_goal_transfer, confirm_registered_goal_transfer, + control_transferred_goal, ) GOAL_TRANSFER_PATH = "/api/chat/goal-transfer" @@ -24,9 +25,10 @@ def _goal_transfer(self) -> None: try: body = self._read_json() action = body.get("action") - fields = {"action", "goal_id", "operation_id", "preview_sha256"} if action == "confirm" else ( - {"action", "goal_id", "operation_id"} if action == "cancel" else {"action", "goal_id"}) - if set(body) != fields or action not in ("inspect", "cancel", "confirm"): + fields = {"action", "goal_id", "operation_id", "expected_state_fingerprint"} if action in ("stop", "resume") else ( + {"action", "goal_id", "operation_id", "preview_sha256"} if action == "confirm" else ( + {"action", "goal_id", "operation_id"} if action == "cancel" else {"action", "goal_id"})) + if set(body) != fields or action not in ("inspect", "cancel", "confirm", "stop", "resume"): raise ValueError("Unsupported transfer request") if any(not isinstance(value, str) or not value.strip() for value in body.values()): raise ValueError("Transfer request requires original identifiers") @@ -34,6 +36,10 @@ def _goal_transfer(self) -> None: # registry codec. Browser data cannot redirect filesystem owners. if action == "inspect": result = inspect_registered_goal_transfer(registry_path=self.server.registry_path, goal_id=body["goal_id"]) + elif action in ("stop", "resume"): + result = control_transferred_goal(registry_path=self.server.registry_path, goal_id=body["goal_id"], + operation_id=body["operation_id"], operation=action, + expected_state_fingerprint=body["expected_state_fingerprint"]) elif action == "confirm": result = confirm_registered_goal_transfer(registry_path=self.server.registry_path, goal_id=body["goal_id"], operation_id=body["operation_id"], expected_preview_sha256=body["preview_sha256"], From 8674de6f67f5dd7d6f2f7a6c6f594fc6afd93340 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:51:54 +0800 Subject: [PATCH 27/55] test(transfer): verify destination controls and recovery on real authority stores Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../test_planned_transfer_lifecycle.py | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/control_plane/test_planned_transfer_lifecycle.py diff --git a/tests/control_plane/test_planned_transfer_lifecycle.py b/tests/control_plane/test_planned_transfer_lifecycle.py new file mode 100644 index 0000000000..2a8bbf1b3e --- /dev/null +++ b/tests/control_plane/test_planned_transfer_lifecycle.py @@ -0,0 +1,113 @@ +"""Original transfer entry controls the admitted destination, never old writers.""" +from __future__ import annotations + +import json + +import pytest + +from loopx.control_plane.effect_runtime import EffectRuntimeRemoteError, restart_effect_runtime +from loopx.control_plane.goals.first_party_host_admission import FirstPartyHostGoalAdmission +from tests.control_plane.test_planned_transfer_cancellation import source as source_fixture +from tests.control_plane.test_planned_transfer_operation import _http +from tests.control_plane.test_planned_transfer_product import _goal_document + +source = source_fixture + + +def test_original_entry_stops_and_resumes_only_the_admitted_destination(source, tmp_path): + _goal_document(source, absolute=True) + destination = tmp_path / "destination" + destination.mkdir() + prepared = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", + "--operation-id", "move-a", "--destination-root", str(destination), "--execute") + server, thread, call = _http(source) + try: + status, accepted = call({"action": "confirm", "goal_id": "report", "operation_id": "move-a", + "preview_sha256": prepared["prepared_cutover"]["preview_sha256"]}) + assert status == 200 and accepted["recorded"] is True + assert "execution" not in accepted + target_registry = destination / "workspace/.loopx/registry.json" + source_before = source["registry"].read_bytes(), source["fence"].read_bytes() + target_before = target_registry.read_bytes() + admission = FirstPartyHostGoalAdmission.for_plan(registry_path=target_registry, + goal_id="report", planned_goal_ref=None) + admission.require_current() + view = call({"action": "inspect", "goal_id": "report"})[1] + control = view["destination_control"] + assert control["operation"] == "stop" + assert "registry" not in json.dumps(control) + + stop = {"action": "stop", "goal_id": "report", "operation_id": "move-a", + "expected_state_fingerprint": control["expected_state_fingerprint"]} + for invalid in ({**stop, "operation_id": "other-move"}, + {**stop, "expected_state_fingerprint": "0" * 64}, + {**stop, "target_registry": str(source["registry"])}): + assert call(invalid)[0] == 400 + assert target_registry.read_bytes() == target_before + assert source_before == (source["registry"].read_bytes(), source["fence"].read_bytes()) + + status, stopped = call(stop) + assert status == 200 and stopped["readback"]["local_cutover"]["target_activation_state"] == "stopped" + assert stopped["readback"]["destination_control"]["operation"] == "resume" + with pytest.raises(EffectRuntimeRemoteError, match="Current Goal registration no longer matches"): + admission.require_current() + stopped_bytes = target_registry.read_bytes() + # A response-loss retry observes the same state, without rewriting its + # timestamp, source route or any execution receipt. + restart_effect_runtime(wait_for_exit=True) + assert call(stop)[0] == 200 + assert target_registry.read_bytes() == stopped_bytes + assert source_before == (source["registry"].read_bytes(), source["fence"].read_bytes()) + assert "prepared for transfer" in source["acquire"]("old-source-after-stop", expected_code=1)["error"] + + resume = {"action": "resume", "goal_id": "report", "operation_id": "move-a", + "expected_state_fingerprint": stopped["readback"]["destination_control"]["expected_state_fingerprint"]} + status, resumed = call(resume) + assert status == 200 and resumed["readback"]["local_cutover"]["target_activation_state"] == "active" + admission.require_current() + assert source_before == (source["registry"].read_bytes(), source["fence"].read_bytes()) + assert not (destination / "runtime/goals/report/turns").exists() + assert not (destination / "runtime/goals/report/turn-sessions").exists() + assert resumed["readback"]["work"] == accepted["readback"]["work"] + assert "prepared for transfer" in source["acquire"]("old-source-after-resume", expected_code=1)["error"] + resumed_bytes = target_registry.read_bytes() + assert call(stop)[0] == 400 + assert target_registry.read_bytes() == resumed_bytes + # The work view can remain readable while its lifecycle projection is + # unavailable. Do not present a control or fall back to the old source. + projection = destination / "runtime/registry.global.json" + retained = projection.with_suffix(".retained") + projection.rename(retained) + projection.mkdir() + try: + unavailable = call({"action": "inspect", "goal_id": "report"})[1] + assert unavailable["destination_control_unavailable"] is True + assert "destination_control" not in unavailable + assert unavailable["work"] == resumed["readback"]["work"] + assert call({**stop, "expected_state_fingerprint": resumed["readback"]["destination_control"]["expected_state_fingerprint"]})[0] == 400 + assert target_registry.read_bytes() == resumed_bytes + assert source_before == (source["registry"].read_bytes(), source["fence"].read_bytes()) + finally: + projection.rmdir() + retained.rename(projection) + assert call({"action": "inspect", "goal_id": "report"})[1]["destination_control"] == resumed["readback"]["destination_control"] + finally: + server.shutdown() + thread.join(5) + server.server_close() + + +def test_source_only_preparation_never_exposes_destination_control(source, tmp_path): + _goal_document(source, absolute=True) + original = source["cli"]("goal-transfer", "inspect", "--goal-id", "report") + assert "destination_control" not in original + server, thread, call = _http(source) + before = source["registry"].read_bytes(), source["fence"].read_bytes() + try: + assert call({"action": "resume", "goal_id": "report", "operation_id": "move-a", + "expected_state_fingerprint": "0" * 64})[0] == 400 + assert before == (source["registry"].read_bytes(), source["fence"].read_bytes()) + finally: + server.shutdown() + thread.join(5) + server.server_close() From 5ce41359c48526cc1c6ec439286216afcb2ca471 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:51:54 +0800 Subject: [PATCH 28/55] docs(transfer): describe scoped destination controls and remaining journey gaps Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../agent-transfer-destination-controls.png | Bin 0 -> 107143 bytes docs/reference/configuration-backup.md | 3 +++ docs/reference/research-checkpoint.md | 16 ++++++++++++++++ 3 files changed, 19 insertions(+) create mode 100644 docs/reference/assets/agent-transfer-destination-controls.png diff --git a/docs/reference/assets/agent-transfer-destination-controls.png b/docs/reference/assets/agent-transfer-destination-controls.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6892129b55a76d75a968b0a360cdf53ea513f3 GIT binary patch literal 107143 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 literal 0 HcmV?d00001 diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md index e96ec8d66c..2df701ab4e 100644 --- a/docs/reference/configuration-backup.md +++ b/docs/reference/configuration-backup.md @@ -440,6 +440,9 @@ open. Existing ordinary Goals and their provider defaults retain their behavior. 原 effect 去重和收据保持有效。源最终决定之前可取消,之后仅同操作向前恢复;重复确认 不覆盖目标后写。原设置和 `inspect` 从目标读取原任务状态和证据引用,目标失联则明确报错; 引用本身不代表已在此处读取产物字节或完成原 Goal 验收。 +取得本地 runtime 准入后,同一入口可停止或恢复实际目标 Goal;当前状态摘要和原切换 +绑定由既有 owner 校验。源端仍保持停写,浏览器不能指定另一 registry;恢复不额外 +派发或重放工作。该控制不证明进行中的 Host 已退出,也不补足持续多轮迁移验收。 源准备绑定原 OS 安装身份,目标任务准入还绑定原物理 runtime;复制收据或重建目录不获新写权限。 此路径在准入前要求两个原本地环境均可达;不是跨机授权令牌。再次迁移和 provider/ 策略替换仍待原谱系 owner 资格。自动准备、完整设置交互、必要配置/session 依赖、 diff --git a/docs/reference/research-checkpoint.md b/docs/reference/research-checkpoint.md index 8c6532ffd0..75e5c8d7ed 100644 --- a/docs/reference/research-checkpoint.md +++ b/docs/reference/research-checkpoint.md @@ -254,6 +254,22 @@ in this entry. New Host/runtime execution remains stopped until the existing Goal lifecycle owner resumes it; viewing a result does not renew admission. Ordinary Todo management keeps its existing owner rules. +The same **Move Agent** entry now offers **Stop destination Goal** or +**Resume destination Goal** after the local runtime is admitted. These actions +use the existing Goal lifecycle owner, current destination fingerprint and the +original cutover binding. They never redirect old source workers or accept a +browser-supplied registry path. A stale preview or unavailable destination +requires a fresh read; a response-loss retry can observe the already requested +state without rewriting it. Resume permits later execution; it does not launch +another Turn or replay completed work. Stopping prevents new admission and does +not itself prove an in-flight process has exited. + +中文:本地目标取得执行准入后,原“迁移 Agent”入口可“停止目标 Goal”或“恢复目标 +Goal”。操作复用既有 Goal 生命周期、当前目标状态摘要和原切换绑定,不重定向旧源 +执行器,也不接收浏览器提供的 registry 路径。旧预览或目标不可用时重新读取;响应 +丢失后的重试可读回已达到的状态,不重复改写。恢复只允许后续执行,不启动新轮次、 +不重放已完成的工作;停止阻止新准入,并不证明进行中的进程已经退出。 + For a governed CLI Turn with an explicit Todo and `--turn-instance-id`, retrying that same identity now reads the original journal before rebuilding a live plan. A changed plan hash cannot allocate a second canonical journal for the same From 72589c51bd906c8301f36d387c7fe0eadfe80445 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:32:15 +0800 Subject: [PATCH 29/55] fix(transfer): follow admitted destination in ordinary Goal workspace Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/chat_goal_lifecycle_actions.py | 13 ++- loopx/chat_status_api.py | 35 ++++-- .../planned_transfer_authority.ts | 19 +++ .../control_plane/effect_runtime_handlers.ts | 1 + .../goals/planned_transfer_io.py | 22 ++++ .../test_planned_transfer_workspace.py | 110 ++++++++++++++++++ 6 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 tests/control_plane/test_planned_transfer_workspace.py diff --git a/loopx/chat_goal_lifecycle_actions.py b/loopx/chat_goal_lifecycle_actions.py index d4df549ebc..4b0c96a43e 100644 --- a/loopx/chat_goal_lifecycle_actions.py +++ b/loopx/chat_goal_lifecycle_actions.py @@ -2,6 +2,7 @@ from __future__ import annotations +from pathlib import Path from typing import Any from .control_plane.goals.activation import GoalActivationState, goal_activation_state @@ -15,6 +16,12 @@ class ChatGoalLifecycleActionMixin: """Keep Goal activation policy separate from general action orchestration.""" + def _goal_lifecycle_registry(self, goal_id: str) -> Path: + from .control_plane.goals.planned_transfer_io import read_goal_transfer_workspace_target + + target = read_goal_transfer_workspace_target(registry_path=self.registry_path, goal_id=goal_id) + return Path(target["registry_path"]) if target is not None else self.registry_path + def _goal_lifecycle_preview( self, parameters: dict[str, Any], @@ -49,7 +56,7 @@ def _goal_lifecycle_preview( else GoalActivationState.ACTIVE ) preview = set_goal_activation_state( - registry_path=self.registry_path, + registry_path=self._goal_lifecycle_registry(str(parameters["goal_id"])), goal_id=str(parameters["goal_id"]), state=target_state, reason=parameters.get("reason"), @@ -227,7 +234,7 @@ def _apply_goal_lifecycle( ) expected_fingerprint = str(proposal.get("expected_state_fingerprint") or "") current = set_goal_activation_state( - registry_path=self.registry_path, + registry_path=self._goal_lifecycle_registry(goal_id), goal_id=goal_id, state=target_state, reason=parameters.get("reason"), @@ -260,7 +267,7 @@ def _apply_goal_lifecycle( ) return {"proposal": stale, "turn": None} result = set_goal_activation_state( - registry_path=self.registry_path, + registry_path=self._goal_lifecycle_registry(goal_id), goal_id=goal_id, state=target_state, reason=parameters.get("reason"), diff --git a/loopx/chat_status_api.py b/loopx/chat_status_api.py index 9402f41fe7..f983504e95 100644 --- a/loopx/chat_status_api.py +++ b/loopx/chat_status_api.py @@ -25,6 +25,7 @@ EffectRuntimeStartupError, effect_runtime_request_scope, ) +from .control_plane.goals.activation import goal_activation_state from .control_plane.runtime.public_safety import ( public_safe_compact_text, validate_public_safe_value, @@ -52,12 +53,13 @@ from .control_plane.todos.list_readback import list_goal_todos -def _goal_task_map(server: Any, goal_id: str) -> dict[str, Any] | None: - runtime_root = server.runtime_root_override +def _goal_task_map(server: Any, goal_id: str, *, registry_path: Path | None = None, + runtime_root: str | Path | None = None) -> dict[str, Any] | None: + runtime_root = runtime_root if registry_path is not None else server.runtime_root_override source_read_at = now_utc_iso() try: listed = list_goal_todos( - registry_path=server.registry_path, + registry_path=registry_path if registry_path is not None else server.registry_path, goal_id=goal_id, runtime_root_arg=str(runtime_root) if runtime_root else None, ) @@ -227,14 +229,31 @@ def _status(self, *, delivery_review: bool = False) -> None: ) self._send_json(directory) return + status_registry = self.server.registry_path + status_runtime = self.server.runtime_root_override + transfer_target = None + if goal_id: + from .control_plane.goals.planned_transfer_io import read_goal_transfer_workspace_target + + transfer_target = read_goal_transfer_workspace_target(registry_path=status_registry, goal_id=goal_id) + if transfer_target is not None: + status_registry = Path(transfer_target["registry_path"]) + status_runtime = transfer_target["runtime_root"] + if activation_state_filter is None: + # The old entry's discovery metadata still names the + # source. Select the existing stopped archive read when + # the admitted target is stopped, retaining its work. + target_goal = next(goal for goal in registry_goals(load_registry(status_registry)) + if str(goal.get("id")) == goal_id) + activation_state_filter = goal_activation_state(target_goal) projection = collect_status( - registry_path=self.server.registry_path, + registry_path=status_registry, # The Chat service already starts on an absent registry as an # empty workspace (serve_chat); its status reports the same # fact as a warning, so a fresh install is not a failed source. allow_missing_registry=True, - runtime_root_override=self.server.runtime_root_override, - scan_roots=self.server.scan_roots, + runtime_root_override=status_runtime, + scan_roots=[] if transfer_target else self.server.scan_roots, limit=self.server.limit, goal_id=goal_id, include_public_boundary_scan=False, @@ -280,7 +299,8 @@ def _status(self, *, delivery_review: bool = False) -> None: if row.get("goal_id") == goal_id), {}) goal = next((row for row in projection.get("run_history", {}).get("goals", []) if row.get("id") == goal_id), {}) - goal_map = _goal_task_map(self.server, goal_id) + goal_map = _goal_task_map(self.server, goal_id, + registry_path=status_registry, runtime_root=status_runtime) observed_at = now_utc_iso() components = [item.get("task_graph_projection"), goal_map, goal.get("acceptance_observation")] upstream = [value for value in [projection.get("projection_envelope"), @@ -312,6 +332,7 @@ def _status(self, *, delivery_review: bool = False) -> None: self.server.registry_path, *self.server.scan_roots, self.server.runtime_root, + *([status_registry, status_registry.parent.parent, Path(status_runtime)] if transfer_target else []), ] projection = json.loads( redact_local_paths( diff --git a/loopx/control_plane/coordination/planned_transfer_authority.ts b/loopx/control_plane/coordination/planned_transfer_authority.ts index 44da7717c0..f463422d4e 100644 --- a/loopx/control_plane/coordination/planned_transfer_authority.ts +++ b/loopx/control_plane/coordination/planned_transfer_authority.ts @@ -231,6 +231,25 @@ export async function visitRegisteredTransferSource(value: JsonObject, reques })); } +/** First-party workspace reads may follow only the existing local cutover + * proof. This read neither grants writer admission nor redirects executors. + * Ordinary fences need no transfer snapshot or authority-history read. */ +export async function readGoalTransferWorkspaceTarget(value: JsonObject): Promise { + if (!hasExactAuthorityKeys(value, ["runtime_root", "goal_id", "registry_path"])) reject("Workspace transfer read requires its registered source"); + const root = requireLocalAuthorityRuntimeRoot(value.runtime_root); + const goal = requireAuthorityStoreId(value.goal_id, "Goal id"); + const fence = await loadLegacyCoordinationWriterFence(root, goal); + if (fence.status === "missing") return {target: null}; + if (fence.status !== "loaded") reject("Current workspace writer boundary is unavailable"); + if (fence.fence.schema_version !== PLANNED_TRANSFER_WRITER_FENCE_SCHEMA || fence.fence.role !== "source") return {target: null}; + const snapshot = await readGoalTransferSnapshot(String(value.registry_path), goal); + const context = requireJsonObject(snapshot.source_context, "workspace source context"); + if (context.runtime_root !== root) reject("Workspace source runtime changed"); + const view = await inspectRegisteredGoalTransfer({runtime_root: root, goal_id: goal, + source_context: context, registry_witness: snapshot.registry_witness}); + return {target: view._local_activation_target ?? null}; +} + /** The caller-facing continuation view. This local vocabulary describes this * Goal's actual preparation and its remaining continuation gap; it is not a * replacement lifecycle, provider decision or execution grant. Full native diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index ef39b9576a..bdf1aca929 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -422,6 +422,7 @@ export function createEffectRuntimeHandlers( ["coordination.authority_archive.manage", lazyHandler(() => import("./coordination/local_authority_archive.ts"), ({manageLocalAuthorityArchive}) => manageLocalAuthorityArchive)], ["coordination.planned_transfer.preview_source", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({previewRegisteredAuthorityForPlannedTransfer}) => previewRegisteredAuthorityForPlannedTransfer)], ["coordination.planned_transfer.inspect_operation", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({inspectRegisteredGoalTransfer}) => inspectRegisteredGoalTransfer)], + ["coordination.planned_transfer.workspace_target", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({readGoalTransferWorkspaceTarget}) => readGoalTransferWorkspaceTarget)], ["coordination.planned_transfer.prepare_authority", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({prepareAuthorityForPlannedTransfer}) => prepareAuthorityForPlannedTransfer)], ["coordination.planned_transfer.cancel_source_preparation", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({cancelSourcePreparation}) => cancelSourcePreparation)], ["coordination.planned_transfer.registry_write_check", lazyHandler(() => import("./coordination/planned_transfer_authority.ts"), ({checkPlannedTransferRegistryWrite}) => checkPlannedTransferRegistryWrite)], diff --git a/loopx/control_plane/goals/planned_transfer_io.py b/loopx/control_plane/goals/planned_transfer_io.py index 8474ed9e7d..ca1fc1d415 100644 --- a/loopx/control_plane/goals/planned_transfer_io.py +++ b/loopx/control_plane/goals/planned_transfer_io.py @@ -109,6 +109,28 @@ def _destination_activation(target: dict[str, Any], *, state: str, actor_kind="owner" if execute else None, execute=execute) +def read_goal_transfer_workspace_target(*, registry_path: Path, goal_id: str) -> dict[str, Any] | None: + """Private first-party workspace route, never an executor/CLI redirection. + + Existing orphan handling still owns an unavailable original registry. A + present transfer whose target cannot be verified must never fall back. + The typed transfer owner alone decides whether a destination was admitted. + """ + from ..runtime.runtime_projection_route import resolve_goal_source_runtime_route + + try: + route = resolve_goal_source_runtime_route(registry_path=registry_path, goal_id=goal_id) + except (OSError, ValueError): + # Source lookup has not established any transfer. Preserve the existing + # lifecycle owner's explicit orphan-stop refusal/recovery rules. Native + # cutover/target proof below is deliberately outside this fallback. + return None + return effect_runtime_result("coordination.planned_transfer.workspace_target", { + "registry_path": route["source_registry"], "runtime_root": route["source_runtime_root"], + "goal_id": goal_id, + }, timeout=60.0, retry_safe=True)["target"] + + def inspect_registered_goal_transfer(*, registry_path: Path, goal_id: str) -> dict[str, Any]: """Shared Settings/CLI transport; the native owner discovers preparation.""" result = _read_registered_goal_transfer(registry_path=registry_path, goal_id=goal_id) diff --git a/tests/control_plane/test_planned_transfer_workspace.py b/tests/control_plane/test_planned_transfer_workspace.py new file mode 100644 index 0000000000..3e953f498c --- /dev/null +++ b/tests/control_plane/test_planned_transfer_workspace.py @@ -0,0 +1,110 @@ +"""Ordinary Goal reads/actions follow the admitted local target, not old workers.""" +from __future__ import annotations + +import http.client +import json + +import pytest + +from loopx.chat_action_store import ChatActionStore +from loopx.chat_actions import ChatActionService +from loopx.control_plane.effect_runtime import EffectRuntimeRemoteError +from loopx.control_plane.goals.planned_transfer_io import control_transferred_goal, inspect_registered_goal_transfer +from tests.control_plane.test_planned_transfer_cancellation import source as source_fixture +from tests.control_plane.test_planned_transfer_operation import _http +from tests.control_plane.test_planned_transfer_product import _goal_document + +source = source_fixture + + +def _status(server): + connection = http.client.HTTPConnection(*server.server_address, timeout=90) + try: + connection.request("GET", "/status.json?goal_id=report") + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + + +def _preview(service, operation, key): + return service.preview({"action_kind": "goal.lifecycle", "summary": operation, + "normalized_parameters": {"goal_id": "report", "operation": operation}, + "context": {"kind": "goal_directory", "goal_id": "report"}, "idempotency_key": key}) + + +def _status_server(source): + server, thread, call = _http(source) + server.selected_goal_id = None + server.scan_roots = [source["project"]] + server.limit = 50 + server.goal_subagent_configuration_enabled = False + return server, thread, call + + +def test_ordinary_goal_workspace_follows_current_target_and_rejects_source_fallback(source, tmp_path): + _goal_document(source, absolute=True) + service = ChatActionService(store=ChatActionStore(tmp_path / "actions"), registry_path=source["registry"]) + old_preview = _preview(service, "stop", "before-move") + destination = tmp_path / "destination" + destination.mkdir() + prepared = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", + "--operation-id", "move-a", "--destination-root", str(destination), "--execute") + server, thread, call = _status_server(source) + try: + assert call({"action": "confirm", "goal_id": "report", "operation_id": "move-a", + "preview_sha256": prepared["prepared_cutover"]["preview_sha256"]})[0] == 200 + source_before = source["registry"].read_bytes(), source["fence"].read_bytes() + control = inspect_registered_goal_transfer(registry_path=source["registry"], goal_id="report")["destination_control"] + control_transferred_goal(registry_path=source["registry"], goal_id="report", operation_id="move-a", + operation="stop", expected_state_fingerprint=control["expected_state_fingerprint"]) + status, view = _status(server) + assert status == 200 + assert view["run_history"]["goals"][0]["activation_state"] == "stopped" + assert view["run_history"]["goals"][0]["goal_objective"] == "Produce report.txt from input.txt" + pending = view["attention_queue"]["items"][0]["agent_todos"]["items"] + assert any(todo["todo_id"] == source["todo"] and todo["status"] == "open" for todo in pending) + assert str(destination) not in json.dumps(view) + # Approval of the old source cannot be laundered into target authority. + assert service.apply(old_preview["proposal_id"])["proposal"]["status"] == "stale" + resume = _preview(service, "resume", "resume-current-target") + assert service.apply(resume["proposal_id"])["proposal"]["status"] == "applied" + target_registry = destination / "workspace/.loopx/registry.json" + assert json.loads(target_registry.read_text())["goals"][0]["activation"]["state"] == "active" + assert _status(server)[1]["run_history"]["goals"][0]["activation_state"] == "active" + assert source_before == (source["registry"].read_bytes(), source["fence"].read_bytes()) + retained = target_registry.with_suffix(".retained") + target_registry.rename(retained) + try: + status, unavailable = _status(server) + assert status >= 400 and "run_history" not in unavailable + with pytest.raises((EffectRuntimeRemoteError, ValueError, OSError)): + _preview(service, "stop", "unavailable-target") + assert source_before == (source["registry"].read_bytes(), source["fence"].read_bytes()) + finally: + retained.rename(target_registry) + assert _status(server)[0] == 200 + finally: + server.shutdown() + thread.join(5) + server.server_close() + + +def test_ordinary_goal_without_transfer_keeps_its_existing_lifecycle_owner(source, tmp_path): + _goal_document(source, absolute=True) + service = ChatActionService(store=ChatActionStore(tmp_path / "actions"), registry_path=source["registry"]) + original_fence = source["fence"].read_bytes() + server, thread, _ = _status_server(source) + try: + assert _status(server)[1]["run_history"]["goals"][0]["activation_state"] == "active" + stop = _preview(service, "stop", "ordinary-stop") + assert service.apply(stop["proposal_id"])["proposal"]["status"] == "applied" + assert json.loads(source["registry"].read_text())["goals"][0]["activation"]["state"] == "stopped" + resume = _preview(service, "resume", "ordinary-resume") + assert service.apply(resume["proposal_id"])["proposal"]["status"] == "applied" + assert _status(server)[1]["run_history"]["goals"][0]["activation_state"] == "active" + assert source["fence"].read_bytes() == original_fence + finally: + server.shutdown() + thread.join(5) + server.server_close() From 2e582f557acfef9d3b13d406be2f5661deb640d7 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:32:35 +0800 Subject: [PATCH 30/55] docs(transfer): show current Goal workspace and remaining adoption gaps Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../agent-transfer-workspace-before.png | Bin 0 -> 93702 bytes .../agent-transfer-workspace-stopped.png | Bin 0 -> 85623 bytes .../agent-transfer-workspace-unavailable.png | Bin 0 -> 58670 bytes docs/reference/research-checkpoint.md | 21 ++++++++++++++++++ 4 files changed, 21 insertions(+) create mode 100644 docs/reference/assets/agent-transfer-workspace-before.png create mode 100644 docs/reference/assets/agent-transfer-workspace-stopped.png create mode 100644 docs/reference/assets/agent-transfer-workspace-unavailable.png diff --git a/docs/reference/assets/agent-transfer-workspace-before.png b/docs/reference/assets/agent-transfer-workspace-before.png new file mode 100644 index 0000000000000000000000000000000000000000..6c47621e064c213384be754793003ad517331664 GIT binary patch literal 93702 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'a$9{Sp&rMCL-1Sdj2eqg_oAn_d{pY6=&1$TE~r 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

OXbA$`9X@@ZWPrM%Y`a0NJt@m_q~h5+++@TpgW8 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 N)X_SB7pho2`9IucT@L^N literal 0 HcmV?d00001 diff --git a/docs/reference/assets/agent-transfer-workspace-unavailable.png b/docs/reference/assets/agent-transfer-workspace-unavailable.png new file mode 100644 index 0000000000000000000000000000000000000000..d2f6d6d7d229b4ae25d33410f72c4e0aee54ca04 GIT binary patch literal 58670 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

* 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 Doi}$r literal 0 HcmV?d00001 diff --git a/docs/reference/research-checkpoint.md b/docs/reference/research-checkpoint.md index 75e5c8d7ed..ecda6b148b 100644 --- a/docs/reference/research-checkpoint.md +++ b/docs/reference/research-checkpoint.md @@ -270,6 +270,27 @@ Goal”。操作复用既有 Goal 生命周期、当前目标状态摘要和原 丢失后的重试可读回已达到的状态,不重复改写。恢复只允许后续执行,不启动新轮次、 不重放已完成的工作;停止阻止新准入,并不证明进行中的进程已经退出。 +After an admitted local move, selecting the original Goal also reads the current +destination in its ordinary overview and delivery review. A stopped destination +uses the existing stopped-Goal archive projection, retaining its objective and +unfinished Tasks. The overview's existing **Stop** / **Resume** actions use the +same verified destination and Goal lifecycle owner. A proposal prepared before +the move becomes stale; an unavailable target refuses instead of reading or +controlling the old source. Goals without a local move keep their existing read +and lifecycle owners, including the established orphan-stop recovery rules. + +This workspace adoption covers selected status, delivery review and activation. +Task mutations, Goal Chat/session binding, deletion and sustained original-task +execution still need their own integration. It does not redirect source CLI +commands or executors. The full installed-profile and 120-second acceptance +remain unqualified. + +中文:已准入的本地迁移也会让原 Goal 主视图和交付评审读取真实目标。目标停止时, +复用既有历史视图保留目标说明与未完成任务;原“停止/恢复”操作作用于经核验的 +目标。迁移前的旧预览失效,目标不可用时明确拒绝,不回退到旧源。未迁移 Goal 的 +原有行为保持。当前仍需接通任务修改、Goal 对话和会话、删除与原任务持续执行; +旧 CLI 与执行器不自动重定向,完整安装迁移及 120 秒验收仍未通过。 + For a governed CLI Turn with an explicit Todo and `--turn-instance-id`, retrying that same identity now reads the original journal before rebuilding a live plan. A changed plan hash cannot allocate a second canonical journal for the same From 4c5e22070f5111388a963ed9d41d6b1c3aa69ac6 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:22:21 +0800 Subject: [PATCH 31/55] fix(transfer): keep Task interactions on the admitted destination Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/chat_actions.py | 11 +- loopx/chat_server.py | 8 +- loopx/chat_todo_actions.py | 31 ++++-- loopx/chat_todo_detail.py | 12 ++- .../test_planned_transfer_workspace.py | 101 ++++++++++++++++++ 5 files changed, 147 insertions(+), 16 deletions(-) diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index b5bba582c5..9ca534d71c 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -1276,7 +1276,7 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]: permission = "protected" elif action_kind == "todo.create": canonical_preview = build_todo_review_preview( - registry_path=self.registry_path, + registry_path=self._todo_registry(normalized["goal_id"], start_execution=normalized.get("start_execution", False)), goal_id=normalized["goal_id"], text=normalized["text"], priority=normalized.get("priority"), @@ -1323,6 +1323,7 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]: elif action_kind in {"todo.update", "monitor.update"}: if normalized.get("operation") != "run_now": canonical_update_basis = self._canonical_update_basis(normalized["goal_id"], + registry_path=self._todo_registry(normalized["goal_id"]) if action_kind == "todo.update" else None, completion_todo_id=normalized["todo_id"] if normalized.get("operation") in {"complete", "stop"} else None) if action_kind == "todo.update" or normalized.get("operation") != "run_now": run = self._run_todo_update if action_kind == "todo.update" else self._run_monitor_update @@ -1560,6 +1561,8 @@ def apply(self, proposal_id: str, *, steward_context: dict[str, Any] | None = No }, ) if action_kind == "todo.create": + registry_path = self._todo_registry(str(parameters["goal_id"]), + start_execution=parameters.get("start_execution", False)) checkpoint = proposal.get("checkpoint") steps = checkpoint.get("steps") if isinstance(checkpoint, dict) else {} steps = steps if isinstance(steps, dict) else {} @@ -1574,7 +1577,7 @@ def apply(self, proposal_id: str, *, steward_context: dict[str, Any] | None = No ) else: current = build_todo_review_preview( - registry_path=self.registry_path, + registry_path=registry_path, goal_id=str(parameters["goal_id"]), text=str(parameters["text"]), priority=parameters.get("priority"), @@ -1588,7 +1591,7 @@ def apply(self, proposal_id: str, *, steward_context: dict[str, Any] | None = No ) return {"proposal": stale, "turn": None} applied = apply_todo_review_preview( - registry_path=self.registry_path, + registry_path=registry_path, goal_id=str(parameters["goal_id"]), text=str(parameters["text"]), priority=parameters.get("priority"), @@ -1607,7 +1610,7 @@ def apply(self, proposal_id: str, *, steward_context: dict[str, Any] | None = No agent_id = parameters.get("agent_id") if agent_id and not isinstance(steps.get("todo_assigned"), dict): update_goal_todo( - registry_path=self.registry_path, + registry_path=registry_path, goal_id=str(parameters["goal_id"]), todo_id=todo_id, claimed_by=str(agent_id), diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 6d3eb0033a..063330e4ea 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1081,16 +1081,20 @@ def _todo(self, *, apply: bool) -> None: if not goal_id: raise ValueError("goal_id is required") self._registry_and_goal(goal_id) + from .control_plane.goals.planned_transfer_io import read_goal_transfer_workspace_target + + target = read_goal_transfer_workspace_target(registry_path=self.server.registry_path, goal_id=goal_id) + registry_path = Path(target["registry_path"]) if target is not None else self.server.registry_path if apply: payload = apply_todo_review_preview( - registry_path=self.server.registry_path, + registry_path=registry_path, goal_id=goal_id, text=text, preview_id=str(body.get("preview_id") or ""), ) else: payload = build_todo_review_preview( - registry_path=self.server.registry_path, + registry_path=registry_path, goal_id=goal_id, text=text, ) diff --git a/loopx/chat_todo_actions.py b/loopx/chat_todo_actions.py index 97ada532cb..ec01c05588 100644 --- a/loopx/chat_todo_actions.py +++ b/loopx/chat_todo_actions.py @@ -2,6 +2,8 @@ from __future__ import annotations +import hashlib +from pathlib import Path from typing import Any from .control_plane.todos.mutation_api import complete_goal_todo, update_goal_todo @@ -24,11 +26,20 @@ def _decision_readback(result: dict[str, Any]) -> dict[str, Any]: class ChatTodoActionMixin: """Keep Todo preview/apply parity separate from general orchestration.""" + def _todo_registry(self, goal_id: str, *, start_execution: bool = False) -> Path: + from .control_plane.goals.planned_transfer_io import read_goal_transfer_workspace_target + + target = read_goal_transfer_workspace_target(registry_path=self.registry_path, goal_id=goal_id) + if target is not None and start_execution: + raise ValueError("Task execution from this entry is unavailable after transfer; create without starting execution.") + return Path(target["registry_path"]) if target is not None else self.registry_path + def _run_todo_update( self, parameters: dict[str, Any], *, dry_run: bool, basis: dict[str, Any] | None = None, operation_id: str | None = None, ) -> dict[str, Any]: goal_id = str(parameters["goal_id"]) + registry_path = self._todo_registry(goal_id) operation = str(parameters.get("operation") or "edit") if parameters.get("review_basis") is not None and parameters["review_basis"] != basis: raise ValueError("The Task source changed. Reload its complete requirements before saving.") @@ -40,7 +51,7 @@ def _run_todo_update( if operation == "complete" and (basis is None or basis.get("schema_version") == "loopx_chat_canonical_terminal_basis_v0"): return complete_goal_todo( - registry_path=self.registry_path, + registry_path=registry_path, goal_id=goal_id, todo_id=str(parameters["todo_id"]), note=parameters.get("note"), @@ -60,7 +71,7 @@ def _run_todo_update( elif operation == "defer": status = "deferred" return update_goal_todo( - registry_path=self.registry_path, + registry_path=registry_path, goal_id=goal_id, todo_id=str(parameters["todo_id"]), text=parameters.get("text"), @@ -137,14 +148,22 @@ def _reviewed_update_options( def _canonical_update_basis( self, goal_id: str, *, completion_todo_id: str | None = None, - decision: bool = False, + decision: bool = False, registry_path: Path | None = None, ) -> dict[str, Any] | None: - registry_sha256 = self._registry_fingerprint() + # Only the Task entrypoint passes its admitted current registry. Gate + # and Monitor owners retain their own original routing and semantics. + registry_path = registry_path or self.registry_path + + def fingerprint() -> str: + return (self._registry_fingerprint() if registry_path == self.registry_path else + hashlib.sha256(registry_path.read_bytes()).hexdigest()) + + registry_sha256 = fingerprint() authority = read_canonical_todos_if_promoted( - runtime_root=effective_runtime_root(self.registry_path, None), + runtime_root=effective_runtime_root(registry_path, None), goal_id=goal_id, include_leases=True, ) - if self._registry_fingerprint() != registry_sha256: + if fingerprint() != registry_sha256: raise ValueError("Todo authority registration changed while reading; retry") if authority is None: return None diff --git a/loopx/chat_todo_detail.py b/loopx/chat_todo_detail.py index 12b1363da2..7d793be972 100644 --- a/loopx/chat_todo_detail.py +++ b/loopx/chat_todo_detail.py @@ -1,10 +1,12 @@ """Loopback-only exact Task request reads over the existing Todo owner.""" import hashlib +from pathlib import Path from urllib.parse import parse_qs, urlparse from .status_server import is_loopback_host from .control_plane.todos.list_readback import list_goal_todos +from .control_plane.goals.planned_transfer_io import read_goal_transfer_workspace_target class TodoDetailRequestMixin: @@ -22,12 +24,14 @@ def _todo_detail(self) -> None: try: if not self.server.registry_path.is_file(): raise OSError("Task registry is unavailable") - registry_digest = hashlib.sha256(self.server.registry_path.read_bytes()).hexdigest() self._registry_and_goal(goal_id) + target = read_goal_transfer_workspace_target(registry_path=self.server.registry_path, goal_id=goal_id) + registry_path = Path(target["registry_path"]) if target is not None else self.server.registry_path + registry_digest = hashlib.sha256(registry_path.read_bytes()).hexdigest() payload = list_goal_todos( - registry_path=self.server.registry_path, goal_id=goal_id, + registry_path=registry_path, goal_id=goal_id, role="agent", todo_id=todo_id, - runtime_root_arg=self.server.runtime_root_override, + runtime_root_arg=target["runtime_root"] if target is not None else self.server.runtime_root_override, ) if payload.get("ambiguous"): self._send_error("The Task source is ambiguous. Refresh its Goal.", status=409) @@ -36,7 +40,7 @@ def _todo_detail(self) -> None: if item is None: self._send_error("The Task is no longer available in this Goal.", status=404) return - if hashlib.sha256(self.server.registry_path.read_bytes()).hexdigest() != registry_digest: + if hashlib.sha256(registry_path.read_bytes()).hexdigest() != registry_digest: raise OSError("Task registration changed during the read") authority = payload.get("authority_read") or {} # Echo the same canonical CAS used by the existing Chat edit owner. diff --git a/tests/control_plane/test_planned_transfer_workspace.py b/tests/control_plane/test_planned_transfer_workspace.py index 3e953f498c..22925e12ae 100644 --- a/tests/control_plane/test_planned_transfer_workspace.py +++ b/tests/control_plane/test_planned_transfer_workspace.py @@ -108,3 +108,104 @@ def test_ordinary_goal_without_transfer_keeps_its_existing_lifecycle_owner(sourc server.shutdown() thread.join(5) server.server_close() + + +def _task_detail(server, todo_id): + connection = http.client.HTTPConnection(*server.server_address, timeout=90) + try: + connection.request("GET", f"/api/chat/todo/detail?goal_id=report&todo_id={todo_id}") + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + + +def _task_preview(service, kind, key, **parameters): + if kind == "todo.update": + parameters.setdefault("agent_id", "agent-a") + return service.preview({"action_kind": kind, "summary": "Review original work", + "normalized_parameters": {"goal_id": "report", **parameters}, + "context": {"kind": "goal", "goal_id": "report"}, "idempotency_key": key}) + + +def test_original_task_entry_reads_edits_and_creates_at_current_target(source, tmp_path, monkeypatch): + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + + _goal_document(source, absolute=True) + service = ChatActionService(store=ChatActionStore(tmp_path / "actions"), registry_path=source["registry"]) + old_edit = _task_preview(service, "todo.update", "old-edit", todo_id=source["todo"], + operation="edit", text="An unconfirmed source edit") + old_create = _task_preview(service, "todo.create", "old-create", text="An unconfirmed source task") + destination = tmp_path / "destination" + destination.mkdir() + prepared = source["cli"]("goal-transfer", "prepare-local", "--goal-id", "report", + "--operation-id", "move-a", "--destination-root", str(destination), "--execute") + server, thread, call = _status_server(source) + try: + assert call({"action": "confirm", "goal_id": "report", "operation_id": "move-a", + "preview_sha256": prepared["prepared_cutover"]["preview_sha256"]})[0] == 200 + source_before = read_canonical_todos_if_promoted(runtime_root=source["runtime"], goal_id="report") + source_files = source["registry"].read_bytes(), source["fence"].read_bytes() + assert service.apply(old_edit["proposal_id"])["proposal"]["status"] == "stale" + assert service.apply(old_create["proposal_id"])["proposal"]["status"] == "stale" + status, detail = _task_detail(server, source["todo"]) + assert status == 200 and detail["text"] == "[P1] Write the input sum to report.txt" + text = detail["text"] + ". Preserve all original input and independently check 21." + edit = _task_preview(service, "todo.update", "target-edit", todo_id=source["todo"], + operation="edit", text=text, review_basis=detail["review_basis"]) + save_checkpoint = service.store.save_checkpoint + def lose_reply(*args, **kwargs): + raise OSError("Synthetic response loss after the canonical edit") + monkeypatch.setattr(service.store, "save_checkpoint", lose_reply) + with pytest.raises(OSError, match="response loss"): + service.apply(edit["proposal_id"]) + committed = read_canonical_todos_if_promoted(runtime_root=destination / "runtime", goal_id="report") + monkeypatch.setattr(service.store, "save_checkpoint", save_checkpoint) + applied = service.apply(edit["proposal_id"])["proposal"] + assert applied["status"] == "applied" + assert service.apply(edit["proposal_id"])["proposal"] == applied + assert committed["provider_revision"] == read_canonical_todos_if_promoted( + runtime_root=destination / "runtime", goal_id="report")["provider_revision"] + status, saved = _task_detail(server, source["todo"]) + assert status == 200 and saved["text"] == text + assert saved["review_basis"] != detail["review_basis"] + with pytest.raises(ValueError, match="changed"): + _task_preview(service, "todo.update", "stale-read", todo_id=source["todo"], + operation="edit", text="Obsolete intent", review_basis=detail["review_basis"]) + create = _task_preview(service, "todo.create", "target-create", text="Review the completed original report") + created = service.apply(create["proposal_id"])["proposal"] + assert created["status"] == "applied" + assert service.apply(create["proposal_id"])["proposal"] == created + new_id = created["receipt"]["resource_ids"]["todo_id"] + assert _task_detail(server, new_id)[1]["text"] == "[P1] Review the completed original report" + pause = _task_preview(service, "todo.update", "target-pause", todo_id=source["todo"], + operation="block", note="Wait for the independent report review") + assert service.apply(pause["proposal_id"])["proposal"]["status"] == "applied" + assert _task_detail(server, source["todo"])[1]["status"] == "blocked" + resume = _task_preview(service, "todo.update", "target-resume", todo_id=source["todo"], + operation="edit", status="open", note="Resume the original report") + assert service.apply(resume["proposal_id"])["proposal"]["status"] == "applied" + assert _task_detail(server, source["todo"])[1]["status"] == "open" + # An execution request must refuse before creating a task in either + # store while the session/runtime continuation owner is unavailable. + with pytest.raises(ValueError, match="execution.*transfer"): + _task_preview(service, "todo.create", "target-start", text="Do not partially create this task", + agent_id="agent-a", start_execution=True) + target_registry = destination / "workspace/.loopx/registry.json" + retained = target_registry.with_suffix(".retained") + target_registry.rename(retained) + try: + assert _task_detail(server, source["todo"])[0] >= 400 + with pytest.raises((EffectRuntimeRemoteError, ValueError, OSError)): + _task_preview(service, "todo.create", "unavailable-task", text="Never fall back to the source") + finally: + retained.rename(target_registry) + assert _task_detail(server, source["todo"])[1]["text"] == text + target = read_canonical_todos_if_promoted(runtime_root=destination / "runtime", goal_id="report") + assert {row["todo_id"] for row in target["todos"]} == {source["todo"], new_id} + assert source_before == read_canonical_todos_if_promoted(runtime_root=source["runtime"], goal_id="report") + assert source_files == (source["registry"].read_bytes(), source["fence"].read_bytes()) + finally: + server.shutdown() + thread.join(5) + server.server_close() From 331137afd30c5f15188ad9375e4f49c48f879c4b Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:32:41 +0800 Subject: [PATCH 32/55] fix(workspace): refresh the affected Goal after creating a Task Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../src/features/personal-workspace/personal-workspace-page.tsx | 2 +- tests/control_plane/test_planned_transfer_workspace.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 6578cc1ee1..a0c52d8ec6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -1973,7 +1973,7 @@ export function PersonalWorkspacePage({ if (applied.actionKind === "goal.lifecycle" && applied.lifecycleOperation === "delete" && applied.goalId) { callbacks.onGoalDeleted?.(applied.goalId); } - if (applied.actionKind === "goal.lifecycle" || applied.actionKind === "gate.resolve" || applied.actionKind === "todo.update") { + if (applied.actionKind === "goal.lifecycle" || applied.actionKind === "gate.resolve" || applied.actionKind === "todo.update" || applied.actionKind === "todo.create") { void reconcileStatus(applied.goalId ? [applied.goalId] : undefined); } return applied; diff --git a/tests/control_plane/test_planned_transfer_workspace.py b/tests/control_plane/test_planned_transfer_workspace.py index 22925e12ae..db4781a4f8 100644 --- a/tests/control_plane/test_planned_transfer_workspace.py +++ b/tests/control_plane/test_planned_transfer_workspace.py @@ -178,6 +178,8 @@ def lose_reply(*args, **kwargs): assert service.apply(create["proposal_id"])["proposal"] == created new_id = created["receipt"]["resource_ids"]["todo_id"] assert _task_detail(server, new_id)[1]["text"] == "[P1] Review the completed original report" + assert any(row["todo_id"] == new_id for row in + _status(server)[1]["attention_queue"]["items"][0]["agent_todos"]["items"]) pause = _task_preview(service, "todo.update", "target-pause", todo_id=source["todo"], operation="block", note="Wait for the independent report review") assert service.apply(pause["proposal_id"])["proposal"]["status"] == "applied" From 650acb5ebf270761b3d682bacc44a407636b5c81 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:32:41 +0800 Subject: [PATCH 33/55] docs(transfer): qualify original Task interaction and remaining gaps Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../assets/agent-transfer-task-edit.png | Bin 0 -> 105388 bytes docs/reference/research-checkpoint.md | 26 ++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 docs/reference/assets/agent-transfer-task-edit.png diff --git a/docs/reference/assets/agent-transfer-task-edit.png b/docs/reference/assets/agent-transfer-task-edit.png new file mode 100644 index 0000000000000000000000000000000000000000..cdbfef428a5ad9cbf27fad5d1b45bb26f0140d75 GIT binary patch literal 105388 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