Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2904,6 +2904,16 @@ re-enter active lanes. The three-arm rehearsal checks this closure against real
providers; derived readiness is not evidence. General historical import and the
remaining D3 qualification/explicit cutover approval are still separate work.

Runtime-shadow parity and source-partition continuity exclude only
`resume_condition.evaluated_at` from their semantic digests. That field is a
query-clock observation, so another read of unchanged durable source must not
manufacture drift or break a later writer's continuity proof. The evaluated
decision and all other resume facts remain compared; a change to readiness,
reason, generation, target, or any other Todo/lease field still fails parity or
continuity until captured. Prepared outbox bytes and supplied projections remain
fully verified, and the complete record, including the observation timestamp,
remains available to readers and in the candidate snapshot.

Quota scope/claim selection and resume planning now share one typed read boundary.
It consumes existing legacy/canonical summaries without a provider-specific rule
fork. User gate scope is distinct from Agent execution ownership, including in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2298,6 +2298,14 @@ commit receipt。各 provider 的 CAS/replay 边界、legacy 源顺序兼容、
活动 lane。三臂演练用真实 provider 检查此闭合;派生 readiness 不充当证据。通用历史
导入、剩余 D3 资格化与显式 cutover 批准仍是后续工作。

Runtime-shadow parity 与 source-partition continuity 的语义 digest 都只排除
`resume_condition.evaluated_at`。该字段是查询时钟 observation;再次读取未变化的
持久来源不应凭空制造 drift,也不应破坏后续 writer 的连续性证明。已求值的决策和
其余 resume 事实仍全部参与比较:readiness、reason、generation、target 或任何其他
Todo/lease 字段变化时,仍须先被 capture,否则 parity 或 continuity 必须失败。
Prepared outbox 字节和传入 projection 仍做完整校验;完整记录(包括 observation 时间)
也仍保留给 reader 和候选快照。

Quota scope/claim 选择与 resume planning 现共用一个 TS 只读边界,消费既有
legacy/canonical summary,不分叉 provider 专用规则。User gate 作用域与 Agent
执行归属分开解释,active-next-action 也遵守此区分;有意语义变化与删除的 Python
Expand Down
59 changes: 56 additions & 3 deletions loopx/control_plane/coordination/local_authority_shadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,11 +668,58 @@ function decodeReadRequest(value: unknown): ReadRequest {
};
}

/**
* Remove query-clock observations from one Todo before authority comparison.
*
* `resume_condition.evaluated_at` records when a reader evaluated an otherwise
* durable resume condition. Re-reading unchanged source therefore changes
* that timestamp without changing the Todo decision. The evaluated outcome
* and every other resume fact remain in the authority identity, so an actual
* readiness transition still produces drift until the writer captures it.
*/
function todoAuthorityIdentityView(value: unknown): unknown {
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
const todo = structuredClone(value as JsonObject);
const condition = todo.resume_condition;
if (condition !== null && typeof condition === "object" && !Array.isArray(condition)) {
const stableCondition = { ...(condition as JsonObject) };
delete stableCondition.evaluated_at;
todo.resume_condition = stableCondition;
}
return todo;
}

function authorityIdentityTodos(value: unknown): unknown {
return Array.isArray(value) ? value.map(todoAuthorityIdentityView) : value;
}

function partitionAuthorityIdentityView(partition: ShadowPartition, projection: JsonObject): JsonObject {
if (partition !== "todos") return projection;
return { ...projection, todos: authorityIdentityTodos(projection.todos) };
}

/**
* Stable identity for one source partition.
*
* Prepared outbox bytes and the supplied projection are still compared in
* full. This semantic digest excludes only the query-clock observation that
* cannot prove a source mutation, so writer continuity and final parity use
* the same identity boundary.
*/
export function localAuthorityShadowPartitionDigest(
partition: ShadowPartition,
projection: JsonObject,
): string {
return `sha256:${createHash("sha256").update(
canonicalAuthorityBytes(partitionAuthorityIdentityView(partition, projection)),
).digest("hex")}`;
}

/** Digest of the fields parity compares; must match Python `head_digest`. */
export function localAuthorityShadowHeadDigest(head: JsonObject): string {
const view = {
handoff_mode: head.handoff_mode ?? null,
todos: head.todos ?? null,
todos: authorityIdentityTodos(head.todos ?? null),
leases: head.leases ?? null,
};
return `sha256:${createHash("sha256").update(canonicalAuthorityBytes(view)).digest("hex")}`;
Expand Down Expand Up @@ -952,7 +999,10 @@ function validateEntryIdentity(request: CommitEntryRequest, binding: ShadowLinea
sourceReference(entry, request.partition_digest), entry.capture_lineage_id, entry.source_root_digest),
"entry_identity_mismatch");
if (request.partition_projection !== null) {
requireLineage(request.partition_digest === `sha256:${canonicalAuthoritySha256(request.partition_projection)}`,
requireLineage(request.partition_digest === localAuthorityShadowPartitionDigest(
entry.partition,
request.partition_projection,
),
"partition_digest_mismatch");
}
requireLineage(entry.source.kind !== "state_event_log", "event_log_writer_not_bound");
Expand All @@ -966,7 +1016,10 @@ function partitionProjection(head: JsonObject, partition: ShadowPartition): Json
}

function validateSourceContinuity(request: CommitEntryRequest, previous: JsonObject): void {
const digest = `sha256:${canonicalAuthoritySha256(partitionProjection(previous, request.entry.partition))}`;
const digest = localAuthorityShadowPartitionDigest(
request.entry.partition,
partitionProjection(previous, request.entry.partition),
);
requireLineage(request.entry.source.previous_partition_digest === digest, "source_partition_continuity_unproved");
if (!NO_OP_RESOLUTIONS.has(request.entry.resolution)) {
requireLineage(request.partition_digest !== digest, "partition_unchanged");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,16 +138,49 @@ def lease_partition_projection(
return {"leases": leases}


def _stable_todos(value: object) -> object:
"""Remove only query-clock observations from Todo authority identity."""

if not isinstance(value, list):
return value
stable_todos: list[object] = []
for item in value:
if not isinstance(item, Mapping):
stable_todos.append(item)
continue
todo = dict(item)
condition = todo.get("resume_condition")
if isinstance(condition, Mapping):
stable_condition = dict(condition)
stable_condition.pop("evaluated_at", None)
todo["resume_condition"] = stable_condition
stable_todos.append(todo)
return stable_todos


def partition_comparison_view(projection: Mapping[str, Any]) -> dict[str, Any]:
"""Stable partition identity shared by capture and TS continuity checks.

The complete prepared projection remains byte-verified separately. Only
the read-time resume evaluation clock is absent from this semantic digest.
"""

view = dict(projection)
if "todos" in view:
view["todos"] = _stable_todos(view.get("todos"))
return view


def partition_digest(projection: Mapping[str, Any]) -> str:
return sha256_digest(dict(projection))
return sha256_digest(partition_comparison_view(projection))


def head_comparison_view(head: Mapping[str, Any]) -> dict[str, Any]:
"""The part of a candidate head that parity compares against the source."""

return {
"handoff_mode": head.get("handoff_mode"),
"todos": head.get("todos"),
"todos": _stable_todos(head.get("todos")),
"leases": head.get("leases"),
}

Expand All @@ -170,6 +203,7 @@ def head_digest(head: Mapping[str, Any]) -> str:
"head_comparison_view",
"head_digest",
"lease_partition_projection",
"partition_comparison_view",
"partition_digest",
"sha256_digest",
"text_digest",
Expand Down
1 change: 1 addition & 0 deletions skills/loopx-self-repair/references/repair-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ teaches a reusable control-plane lesson.
| `dashboard_open_token_picker_gap` | A bounded dashboard setting asks users to type protocol tokens such as `task_domain`; the placeholder looks like a current value, users cannot discover legal choices, or Goals without tagged work cannot enable a capability whose runtime treats the token filter as optional. | Current Goal Todo index, configured token allowlist, option-to-Todo match counts, canonical empty-filter semantics, preview payload, empty state, and packaged browser behavior. | An intentionally open optional backend vocabulary was exposed as a required product authority boundary; reading only a compact Goal-card Todo slice can also hide valid choices. | Keep the open typed token contract in the owning control-plane boundary, but present it as an optional per-Goal multi-select derived from the authoritative Todo index plus already configured values, using compact Goal Todo rows only as a compatibility fallback. Empty means no token filter while every independent admission boundary remains enforced; a non-empty selection remains a strict allowlist. Show match counts, preserve configured zero-match values, and cover unrestricted, restricted, invalid-token, preview, readback, empty-state, and packaged parity behavior. |
| `app_host_heartbeat_identity_gap` | An App host can create recurring automations, but LoopX classifies it as a generic visible CLI loop; the agent may complete one phase and then stop because no host-owned successor wake is activated. Another form builds the right full scheduler packet but drops it at a compact Turn-envelope boundary that still reads a sibling App's legacy field. | Exact App versus CLI host surface, ambient thread id, runtime profile, activation packet, full and compact scheduler projections, scheduler ownership, settled-turn liveness, and terminal no-follow-up evidence. | Host capability existed, but LoopX modeled only the sibling CLI surface or left a downstream transport coupled to that sibling's packet name, so the App automation contract or successor wake did not survive the real execution path. | Add a distinct App host/runtime identity while reusing the provider-neutral `app_automation` cadence and ACK rules end to end. Bind the host's ambient thread id, preserve the packet through Turn compaction without a sibling-host alias, create/update the host automation after Todo writeback, keep settled non-terminal turns active for a fresh successor turn, and stop only on validated terminal no-follow-up. Preserve the CLI host and its native visible Goal path. Never reuse another App's local-store fallback. |
| `artifact_without_goal_delta` | Repeated individually valid changes leave the requested user outcome or peer handoff unqualified; completion reports count fields, receipts or PRs. | Current user/task acceptance, latest main and related work, actual caller/readback, `problem_context` delivery judgment and remaining dependency. | Work was selected and settled around implementation artifacts rather than an independently useful outcome slice. | Reconcile the accepted goal, consolidate the missing integration/negative/readback work, or justify a prerequisite with its real successor and owner. Update the existing task/vision through its owner when direction changed. Do not repair by minimum LOC/PR quotas, a second task ledger, fabricated follow-ups or stronger prose alone. |
| `runtime_shadow_query_clock_drift` | A runtime-shadow bootstrap reports matched, then an immediate read-only inspect reports drift even though no Todo or lease writer ran; a subsequent captured write can also stop with `source_partition_continuity_unproved`. | Expected and observed parity and partition digests, field-level normalized projection diff, resume-condition decision fields, prepared outbox bytes, and source mutation receipts. | `resume_condition.evaluated_at` is recomputed from the reader clock and was included in durable authority identity, so observation time changed on every read and invalidated both parity and writer continuity. | Exclude only the query-clock observation from parity and partition semantic digests in the TS owner and its Python codec mirror. Still verify full prepared bytes/projections and compare readiness, reason, generation, target, and every other Todo/lease fact. Cover clock-only equality plus a real decision-change mismatch, then reproduce bootstrap-to-inspect and captured-write continuity through the real CLI before promotion. |

### Runtime diagnostic drift in parity fixtures

Expand Down
40 changes: 40 additions & 0 deletions tests/control_plane/test_local_authority_shadow_drain.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,46 @@
GOAL_ID = "goal-e2e"


def test_shadow_parity_ignores_only_resume_evaluation_observation_clock() -> None:
head = {
"handoff_mode": "hard_lease",
"todos": [{
"todo_id": "todo-a",
"resume_ready": False,
"resume_condition": {
"evaluated_at": "2026-09-20T00:00:00Z",
"satisfied": False,
"availability_reason": "resume_condition_pending",
},
}],
"leases": [],
}
later_observation = json.loads(json.dumps(head))
later_observation["todos"][0]["resume_condition"]["evaluated_at"] = (
"2026-09-21T00:00:00Z"
)

assert head_digest(head) == head_digest(later_observation)
assert partition_digest({
"handoff_mode": head["handoff_mode"],
"todos": head["todos"],
}) == partition_digest({
"handoff_mode": later_observation["handoff_mode"],
"todos": later_observation["todos"],
})

changed_decision = json.loads(json.dumps(later_observation))
changed_decision["todos"][0]["resume_condition"]["satisfied"] = True
assert head_digest(head) != head_digest(changed_decision)
assert partition_digest({
"handoff_mode": head["handoff_mode"],
"todos": head["todos"],
}) != partition_digest({
"handoff_mode": changed_decision["handoff_mode"],
"todos": changed_decision["todos"],
})


def _fixture(tmp_path: Path) -> tuple[Path, Path, Path]:
real = workspace(tmp_path / "repo")
return real.registry, real.state, real.runtime
Expand Down
52 changes: 52 additions & 0 deletions tests/control_plane_ts/local_authority_shadow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type {
import { FileAuthorityStore } from "../../loopx/control_plane/coordination/file_authority_store.ts";
import {
LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA,
localAuthorityShadowHeadDigest,
localAuthorityShadowPartitionDigest,
recordLocalAuthorityShadow,
} from "../../loopx/control_plane/coordination/local_authority_shadow.ts";

Expand All @@ -38,6 +40,56 @@ function request(directory: string, operationId = "local-operation-a") {
};
}

test("runtime shadow parity ignores only the resume evaluation observation clock", () => {
const head = {
handoff_mode: "hard_lease",
todos: [{
todo_id: "todo-a",
resume_ready: false,
resume_condition: {
evaluated_at: "2026-09-20T00:00:00Z",
satisfied: false,
availability_reason: "resume_condition_pending",
},
}],
leases: [],
};
const laterObservation = structuredClone(head);
laterObservation.todos[0]!.resume_condition.evaluated_at = "2026-09-21T00:00:00Z";

assert.equal(
localAuthorityShadowHeadDigest(head),
localAuthorityShadowHeadDigest(laterObservation),
);
assert.equal(
localAuthorityShadowPartitionDigest("todos", {
handoff_mode: head.handoff_mode,
todos: head.todos,
}),
localAuthorityShadowPartitionDigest("todos", {
handoff_mode: laterObservation.handoff_mode,
todos: laterObservation.todos,
}),
);

const changedDecision = structuredClone(laterObservation);
changedDecision.todos[0]!.resume_condition.satisfied = true;
assert.notEqual(
localAuthorityShadowHeadDigest(head),
localAuthorityShadowHeadDigest(changedDecision),
);
assert.notEqual(
localAuthorityShadowPartitionDigest("todos", {
handoff_mode: head.handoff_mode,
todos: head.todos,
}),
localAuthorityShadowPartitionDigest("todos", {
handoff_mode: changedDecision.handoff_mode,
todos: changedDecision.todos,
}),
);
});

test("one-way file shadow captures a post-commit observation without claiming parity", async (t) => {
const root = await mkdtemp(join(tmpdir(), "loopx-local-authority-shadow-"));
t.after(() => rm(root, { recursive: true, force: true }));
Expand Down
59 changes: 58 additions & 1 deletion tests/control_plane_ts/local_authority_shadow_outbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { join } from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import type { JsonObject } from "../../loopx/control_plane/effect_program.ts";
import { commitLocalAuthorityShadowEntry, readLocalAuthorityShadow } from "../../loopx/control_plane/coordination/local_authority_shadow.ts";
import {
commitLocalAuthorityShadowEntry,
localAuthorityShadowPartitionDigest,
readLocalAuthorityShadow,
} from "../../loopx/control_plane/coordination/local_authority_shadow.ts";
import { outboxEntryIdentity, beginLeaseOutboxEntry } from "../../loopx/control_plane/coordination/local_authority_shadow_outbox.ts";
import * as schemas from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts";
import { fixture, pendingEntry, settleFiles, todo, sha } from "./shadow_file_fixture.ts";
Expand Down Expand Up @@ -207,6 +211,34 @@ test("a missing primary mutation cannot hide behind continuous sequence numbers
assert.equal((await f.store.loadAuthority() as { cursor: string }).cursor, "1");
});

test("Todo continuity ignores only a changed resume evaluation observation clock", async (t) => {
const f = await fixture(t);
const original = todo();
original.resume_condition = {
evaluated_at: "2026-09-20T00:00:00Z",
satisfied: false,
availability_reason: "resume_condition_pending",
};
const first = await pendingEntry(f, 1, { handoff_mode: "hard_lease", todos: [original] });
const delivered = await commitLocalAuthorityShadowEntry(first);
assert.equal(delivered.outcome, "delivered");
await settleFiles(f, first, delivered);

const reread = structuredClone(original);
(reread.resume_condition as JsonObject).evaluated_at = "2026-09-21T00:00:00Z";
const next = structuredClone(reread);
next.text = "Durable Todo mutation after another read";
const second = await pendingEntry(
f,
2,
{ handoff_mode: "hard_lease", todos: [next] },
{
previousPartitionProjection: { handoff_mode: "hard_lease", todos: [reread] },
},
);
assert.equal((await commitLocalAuthorityShadowEntry(second)).outcome, "delivered");
});

test("prose bytes may change only while the canonical previous partition remains proved", async (t) => {
const f = await fixture(t);
await writeFile(f.statePath, `${await readFile(f.statePath, "utf8")}\n## Notes\nProse only.\n`);
Expand All @@ -223,3 +255,28 @@ test("Python and TypeScript entry identity include the same root and lineage", a
assert.notEqual(outboxEntryIdentity("goal-a", "leases", 7, source, "lineage-a", root),
outboxEntryIdentity("goal-a", "leases", 7, source, "lineage-b", root));
});

test("Python and TypeScript share the stable Todo partition digest", async () => {
const projection = {
handoff_mode: "hard_lease",
todos: [{
...todo(),
resume_condition: {
evaluated_at: "2026-09-21T00:00:00Z",
satisfied: false,
availability_reason: "resume_condition_pending",
},
}],
};
const script = [
"import json, sys",
"from loopx.control_plane.coordination.local_authority_shadow_projection import partition_digest",
"print(partition_digest(json.loads(sys.argv[1])))",
].join("\n");
const result = await execFileAsync(
process.env.LOOPX_TEST_PYTHON ?? "python3",
["-c", script, JSON.stringify(projection)],
{ cwd: join(import.meta.dirname, "..", "..") },
);
assert.equal(result.stdout.trim(), localAuthorityShadowPartitionDigest("todos", projection));
});
Loading
Loading