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.")}
{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.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.")}
{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."}
{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.")}
{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.")}
void perform("confirm")}>
{view.local_cutover ? (zh ? "恢复本次切换" : "Resume this move") : (zh ? "确认切换" : "Confirm local move")}
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 ?
{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."}
{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}
void perform("confirm")}>
{view.local_cutover ? (zh ? "恢复本次切换" : "Resume this move") : (zh ? "确认切换" : "Confirm local move")}
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.")}
{zh
+ ? "目标控制暂不可用。请重新读取当前状态;原任务结果和源环境停写仍保留。"
+ : "Destination controls are unavailable. Read current status again; original results and the source write fence are retained."}
{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}
void perform("confirm")}>
{view.local_cutover ? (zh ? "恢复本次切换" : "Resume this move") : (zh ? "确认切换" : "Confirm local move")}
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
zcmeFZcRbep8#XTUvM+m&3uQ)RW?!~cMk0F?*?Wh}%7{?*tgJ#s*<@u$QIhN;$qFIk
zIWPBpKY#pQuiszK>v^7kp8olE_pQtIdB4wboX2sTpLpG?8ssF*Bse%YowDZTZTd^G`#~twVC0C(JMJo6a=^nHfoT{T@oG
z4y}p}S@@l?6fkdE@U=u+>yY!RWNf`c*Mo(Fo%Q)=em~#eJ-*Vh#%h$|a@W4!tT(5&
z=4MI1jALdoZ7^-`%c^GX8Qn*Hn*1r_!fNqcGS6goA20eoaFDVx-1|*duo~L#7k+_J
zPHobfW9{XIBgR{OS2C}(|GxZ4?V_K~C$dNNTn-F#j>^xb-b^tJZa)$}c*`9malGT~
zp+uBmsHgt*QQz?yvh*vHtPOa}I54`y;_2`2
zZ|-9qVNU<2o5BBe_gDC3PU>2;wpHNZ_xoEL9my2>OYn4Tsm{5yK0B|p!K{l}nK7}k
zq-ag?`i6$jpFb0$5hx52P0igCiTvlcamr|A&C@?bpa@9_P}u(){*#f97W2>VkT!+7
zNa}z8a>W1p%l|*8p)E&;nVz0LK0Z!e=*5l;(352QHd&eDV1IvqZx0g_
zlbxM?qfEC*uS8w5A@H|@W*6fN?X+`OUOEgR1NOJRjaM4kYTksu@bu=({tLUUK|9VG
z8gIjn4t~wFopi=hW(XQ3H}}1A{c<($5}itZ;MU@g>6RwWe{MKsPh*Ot*
zeI5ztvAE`+ckbX^zI@sB_}^%wu8vbtMo1unH(16v?`*JKu)^8SNlGH8N1e=)1PRCm
z?~;k~>p!|v#7m>n`{sOCMM?@%f{2DD&93kM8$C<|uPuo{8S(ewyoBwxqg~U*mT)-I
zks_4_?`8;D^%R-an-!i(gAR%HAL1pHX0rXYa@k_J>-fWg?C=mZ(gJg
z`0~m%rTlu-f48BNi|AO_i)tSq_nqsA7L#)vW;G7qz#AGGLeW;4U=M+ABz2BInBmK7mHz>Oxu
zXc9X8CpyGe_LfJvgM)+1`X8J(CHW`A5%F)AZz4O?S)|-@o0@_j2;CeOSmx2Z&!Yt)
zXCD)s^i?Fi?qg+okcL4Uvu~s*nZ|JykxAF#(6btD59D9+CFwD@%b|){9LkZR
z+SuGoy)h@nN#JT185L#O5_Gon#*G{N22aMSy4hd5e)0S9{w%8)Sx+Dd9wFI?mU{2I
z`GWA{h|OdUyYULcSPC}g&);^px36p1tWVZeddzjq%eHsxzjiZciW%o%n0Fq`6!l^+
zSB@h5p@(d&uTNwYoo|o8DUmN}TP(edch7j?YithOZ
zZHkj4zrMVV?Tq*1!G}mCjiuyZ&qJy7QsUXTLjs`#l^OJ}7)I6C+noGWwJFls7o?Uj
zZ}Q7H(hfh`F`8};97yNq{-+^{cuEW`|8YU@2gXA|sO@7{3?q{;eymNG>F#t7svw(U
z7;AW4MfL5^FSvxYvc+#f@f)l0Gi>zvA?DD3{kctd$o^IzBdb@Aq^ppSP#K|C$n1LC
z$?@U-OWoVMo(nz1YG2=6G=qzRXSA*P@O=E}@1Ic-Z@Az}qw3*Y8JdShnqxWz3N02b
zLG>+Aad%h7UEpz9|6YM_n4+q~8WyWNj`3a^rnBsWgu7axkO!&$((&ELaswjr7x1wR
ze7fy3GaTffsn2TL4P_htbqx}CE*T`M6gq!CTI`PNhbLDi4LKA-ly1D2)Vl?#|3oLN
zc#vd8xZm2nwY62_9{W(f{9hJ=ygK-~QNBzJBDz%6mq)U2HOyn?%m*m4li6gs*dtQ8
zHPE;}ZojbWqt3ib$u76Iw^?Ht^v5b6Sk9fwS@7q0GH&@I<9o^8{jXLJqcxW9Hyr
zF1ns(7tbbDY@{wN8m^EGz|=d<9~}yYJGKaPlZ7ymp_N7fUaZA1x8}
zm{nq;H0LFl`#zKdCoA0csrUsHMn3Pf;gH*5?jdJv7cv1CDfLd&%bA+L8A
zclo#qW!lSgXJtG?z>xa%_~VlU1Q5jWygX*g0YBb9hJOCF!COPPgBP>hF<0N%*x%+p
z-JB{yj~mxnq!L>ZS#$ZJu>HV;At?6;_BH{O^kTOrYOK*5x4$;lL0?^&hq^24zb3Se
zFaGR$v*?}1``!;EoyQPW3bVLB}t(xuGGyjiO<}9?d7@S01egrB8
zb^r1F(Z*b792K`sl?t|Tq+k#L9plqX>YO2lW%~{J^?9xpC`8#oSaLurQ&P?}csr6c
zym|BGrQ@}V>*YO3EJjU!u0`%PNDSQ9J+G?|-M%*VmAgP{>qF%B5<{zs5ldJvgn|L_
zBk#Y)+94?^3DvlQJ#2UNW=|p$cQ|ts5$Tt-HIMARNUeFDEyq$r%4wJL9g``bd&jxj?IbInARw86
zFZ|s0ZNE`mXlN*OB$B-S**8%T;TjE=r$>a3k3Ay#-b{F$TWvUVOG|$i`)FL5p-D5w
zdraWs;T451UhZ!4Ul%s1ExTkeu>X7M+v`bX=)q&Izi5l6>JD1&qN7RF{+!i!eG|6f
z&lVCA0&NfzB_S$$vn?#t=fw@av`wYY6+MN~(cLTKmET5Pr|MH9Q?Q3b%tnGnQ(ru9
zQach9q=);EEE1{D3&-`I*sKyMb9Rx2Izi;jlexI_z^YG|f
zUy3*(o=49bd_a8rOFvJ9tIUVjm&SWC&nxP4hcmszPz8;Kb36A>6A<%35Q`%2(x1;3
zv)6VVgz|CSoae{aCVyk&iQQjco@>kI)6g@$I&WAp21lzV6tK5Z)MyQ*uH^C)I*j53
zsyX;i6fgX
z^Q5J62#3B9@wezs#`OHdt^(avWf~fqO+3Af4JibAq{2`zF!3ITVk=zyFz=;*zAX|a
zBu4xK#qh9`;}*%D4<8J8&m@X4P1k$cM&J^3z4lt{rDD2so=HW_-QArj&Wl2c%Yqyn984dR84QVuiABZ44vI5~0(W
z)2TiMs2_jHL?Zn7aQ>&LlvFhgB@h`@3IQ9lmf=T_!|g2EO=A8OWOy%nnp!?9EJIEE{v1+YHrFI#Hdb|Sa+DpuO%jzk5BpWAkh=|
zqhe3%+$Pf{?CC&eY3E2jll%_T#L6cE-@U)QowdMzydDx(TJFOAyia_0HoJ
z^DcJ|Rg&I_MUS`wyaWj0zc$5Bt1NBTi&VzO#(kV3PM$8#dhey=hK2^o@0$y6Mcr*~
z-_Dis{ys(0;PtIz277W=2XDMEoaxrQyrr3r=~T?hjDb@QQ2%B-`fAG6H*A4W`G*Vm
zAvOJEYq5{};%L~djlIo<6_)##NcI!T){Q5>d|40~)aOTd&R)xx>&ut_XQtC~f|1<@
z32;V!H=c-s6@XL*tBhCI@$Wkp78VeNG2wAFsi~v&o(rwg%)g<~h^0ul+{iP8-xWx?
z=kF=3@Gq-YEU2LvK9pV)X^nwe-gl=~T+pz>&cT76l{Ik#AP@{z|DreM-0LyO;>$2s
zaQ4+YdjNcak;Y(&U>tSfLJgF=clq*Z30JbkJ^lRXMdw#mDpo3cBQGQ)h>0Uq_&P0R
zEok5JwOvFVzhrg2eS0zX(rk9chX<#e1CP)$_DRHDC$Br|EI`WITy!a0Tplff=~Gfd
zBJYAV8b+ep%BiU-fP!AXCM6m&&zru2c{5y8W$_%|1?rb}eOzG(!1XXDL#JfzfLZyT
zSdz~n*KZhq_P?pv+AfeWiS_nkCkl;UlXWieLH|?>Tnx5a6#D&|Oy$jpZ{dUn^H`Tv
zGEeGYZKjP1%jeJCxjFaly#p9*Khf96A9K@rn^dkPiA4&XfUV~+
zQBN8=I5;RPE2B5jkS|$K<9?a&^}&%3c)(Gn8pq^FxcpZMv9*N{z+K9j{TBth8Er7X
zJ-(82BTw=B;r?%S!!h!K|IT4B48x~aNOd#
zP4t0sLZ+ex2ZK^K4BVPabQ-iQ^Edb|meZd%xxVyG%Sp{wAX_9k<}5aEwUn@dW1Pq=
zu>$xYMFoN?Rdg#!x>rU}P;md(P8Lb(>u8ktpBvrpZvB&+oYLiFs2DYG4Gc7?ok=~5I
z2g_=7G!hy0p4+JXlXk|1Dw=WDdarKZl(>ES_S6ge0ZG@%-fl=g$AJgr$3+PVmyL}x
zC2y{bl>@B$`uH+~#0$%|Fl2;o4AS=kj_owR_)FBfrs$x=erS2iA&$_v}S>|$`Re0(9^M;I(HkO#l
zx?oK?-w`Wt8AglYPb+PAKUG_nUdfS^E!HsbN3aq=egB^W;8}#PT*ZDS
za;pw*f|`cd{9o>k8ZRXFw2{L#kDCn%^UdoDFOG&M+AW(_zCR>M^X(5IZd
zyqZwW%a>o817H5jMzOXNuYa2##on9FlaKVl=0K$+@<+mQ~7)72g^Q=;#D|V1z()
zz%$%q6y@UN#EJZm$wTbBZ3`ikmxl9-mI1HnLr4n6U@%IfDBVJ(2uKf7`*ugNoPlRN
z@Km~;!F2z9A*6g!xH7`zwcA%eQIzEL)Rfu37me6gNbKnBj8+IZIX(hJKh_P?2ZU@W5EPJE^!>W;
zh0Pm#4S};-A1hBIGrf6J-pC2&^hG(q!6BicsB^BauH4mM0hYis;G;a_b8<`v^CkZY
zFJguk84R3qj5oc3mDqVK~j|oau0hDA_}RJ{c=F_)_OW6uu4ztm~ua
zX5--S#P;ZoEsU_`jnaT4VcLV9?~(?%;Te!ektrOwY^MMTc|UG6M`d|=8QX&KfhHnV
z=HpgTQTbAPi{|S&AWuwRIwVOt!0-kf?6oYh$8~iQ^w`dp?#|L*7Zw%<(B`O6nuy&fWbe5J2_
zpY6Fm0E`v0?^jKZ{VeS|DXxszr#5}xd`?)UJmKY!N0ycX$z=d7!(y*<+!N)~++*r%kV
zq<_+6^Ikn2=GCiLPFHqUKhu1i!4T5`n8Nh7x3>c?F$Gg)*n2Tc5
z=QjI~w9*_0Gb0R*o5N3zC!X6z&mZPwX7<)3B#;ha>)&>=
z;j&-cqv5?u#x7@!M7QFz?m>I(%mUJN8>m@q67uR+Z2^xKiM%v4I{F;AIA{|U=1mkj
zYN}j!)KqQgsM7_F+~Bw%s4xg|K)m{oN#3^yWBMpxBVV<_#cw@qINDz-Kr{#K^bZb_
znWE&Z+Hp=B2zp-BlMhjC))4u?2IAR@v;Bz@_X}vf(8Vp9{LX(w(>p7o%k1kTIWZxE
z`T6-5{*cfMWkLQ`zULVr3x7;QJsKejEZ|ZROks&Zs4x1Rn!cZ}|qWy-?xPh0VXe
zo(TgKm7dN1U^boK01NgRfI5~9+IA8V5kZVX*M03Y+A%yV4CwbCji$>M_~(ux78x%)
zsDdcOb7|`i3_M+~VY>}HyiFz};j=+LjWj5j=`nby=UwmLQ&;7srG0?nRs*qE3(S6>
zp;zO%IQhNU5npKORQ>5!fe#;kaGs?jB^BV{gLs%(rQD~tK3Rqn5D-{XeYE^Om_-GM
zJlW6x$&)9f48qMmKkkRf??F+r?@xboMd`HYqy!`$6BOOMM|ljz*oe>Ds8_R0`56bW
zz*Qq7xLbzQek)^eyEe$+s;Vk|2tUIqk=kTT6+0m0zsHwGY>lTk7q(+AV(qT
zisVfoHET(u#Zp6oU5A5MTV6(($2!cHXxuy4{UvMD6$ePcywOK1Y|J9rRmFJNy(ns%
zee^;`+F8Hl5#_6q*AVe!jaD8zN5_BHO`h}0Ko|k4Dy00zb-iHMUQtFXk>n7rY{`YD
zJy@0D8gSsiWS6N+-kw03%OQM#0|!(N3`;a?v?1SrU-Cpn9pWk*^cx(>tENL9n=1kmk%0+X0syJ2}%^quWLe~cJVLZhArHCDl_DZj^8V*tiy%np1bFbyojV7t9>%dO?A~#Bu!ICeIzJhHeVo&)
zSB6Lo@+d-35MU!Z)p4u3GP1gQ9r_X{?sz&oJG+Qw>zFVM;$;bdL}Cltpla=-Mxgwf
zn3$l?T0kv>rUmLzj*cG;cW}>~cicFAwg>a&iGra5w*FgyGwY8itlvM7G-%=GpCJ_tHhoNkb-c7{M5lyN|o=;N%M92Wu6
z={)C(MOzqj6k$x!n$;#mG-ax;({fy@yoARgE-tQ3FaA}|!4R4Gv7R1vb@Xwc2oqe5
zvQ~eHutl@+Q)AI*)qKQcWEgMbVpZZ_+ZRs(B+s!DWULO4?!VHo=Ismg{~}8?8TJ1@
z@M!=vQuCewJ^=x7F)uK^&**Hjj<;J<)QIrdTS&<%8F(HrGR5a6pF7{5{mEOB0sEce
zFDG_<))*_>g%pHr*I|
zWs=^?#ony#ky`7ZgL-5az1)MfX2i5FYUGRJZ4cW4pFWw)XzZs8wxW^6_Cg3P+DDqH
zTo3qZsyJ2q&Yu^$ecP2F9xtT11rHa`_89f-5Tt^(LK7)T3$({n^dNQ?O!SN5o!I%
z`v_tZlKJ2$PjYAJn9o6QZbJ`0>!hp`j>q-Wo*ozrc4SaJ!NigdbCob8ruSo~22IvC
zsFHv0_6OHkl-LSg%#!xBfr&4C{~H5lX(-3&)$PxqO}Nm=1#A=oJcdzR
z3wRQGp!XC24*CA-P38VMURio&{pZgwt)anX${=^RhJbkU>({SnE|Gx@!H4IrFh3$A
zB~^(~3_Apt=PE5$+XfQo^K)CGT|WOmz|jC4EVcrD%5(MUdutVcU8g=
zRty=WCS48;e1>8!wzj)xvFu1Z0i9
z;q58~;^WY)Pa_VJL;Z(s9k&%&MnbEWYK65D9HbItVwIFZ{@1x1r8dxW8J#gGf
zE!beN?&4&33B~KQ9kv_1v|hTdGNhB(zw~m#OA_a#g5g!~gO9YS#*eQsAz53u3upSd
zk3Tehe*9d@&*wtcN0}eZEv=+~_SG5~I8G4Vdxt7cZG<12)Gz7$xSb;&?^zv~e@1z3
z{^uq+f|C6219B{dK+V9z9*ayGfcD-&o?_csjCZ9EW?_f^bYfQxV{#`KG&z9aXCQ-t
z4s+}MBP#6$>L-{+E$D#Ik1-D(r>0UdKLNBth2h~E&X>;tL7uOQ?-SQCuyHU4R00a~
zk(pgNmD^R0=R|TZ9kzHcQScOiS^-H1WBROAN33cHh-J*;PB?jlYzSpODItR=WQXl)
z3G~Xs#d3hk(adG4W|MKZMvDn>fgb|AP*YxBK0FNrOc>ys+1Xj(eTs1)o%6-)NqD%b
z)}YCAlY4cdgl@i24=-e{oHV?)}5!}fBUs;hTl%<81oTXJ5z5O#YCN)e0j+0PNe+zo3{Iy@xz>&egP=
zEccvldCw`2Y_qkN-)C~sC{qaJh*T>lJnQg8J>383kHTTUgwKTYI{M}yp~Q2U5N?ky
zll2DqUoRd>25w8*%{y?EeLiO|##Zg%n*&
zJK9@mP11amVN@R$c
zxp@)wb6IhkIhOkR`q^qup43Q7=`MXT;u`u}s;gx{ljdG0bgls&7$n>*IuG*VSb~Il
zqjUj7ZO7j*&H(#EkFL^6J6C_zQw_*>mY5Z}ksM?u>|@U6
zNcw2sMnT^BtY=LUazcXjm+RjirUxD#qJ8n-ftK(q7+Z7
z&^K*fi9X5m>KPJT-H#2weE-)EQ%vJ>4oMt|J+*w;M6_3qaaE}{5~us{4y
zEkl1cc5}P6-TaXGD(~R4)Pt9B5pX?=U2&RM|=CT3!}%fAn;UI
zyN?#DS@&bDK9cyOd6Z4FP$$4&g<%qA{o5a&Bx&yHOf1^EF&EWa
zNzSh?_)~>Pp^s-sgGI}f!`5$-^V
z-|38{@cl7KHz@|V2!xC~!FSXx>{yV)?2_s0h+E3vM;