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 @@ -31,6 +31,15 @@

## Current implementation checkpoint

Handoff-mode changes now share one TS ownership-fact classifier before and
after promotion. Legacy event-only claims reject rather than disappear at a
Markdown boundary; event append locks protect the observation through writeback.
Canonical changes reuse durable command receipt recovery. This is an L2/L3
compatibility correction with Python decision deletion, not cohort migration,
SQLite D2 completion or a default flip. The remaining 5–8 packages still depend
on executor/consumer closure, qualification, integrated migration and onboarding.
[Operation, repair and recovery](../../reference/handoff-mode.md).

The terminal caller family now binds review and validation to the canonical
source and recovers historical receipts independently of private argv. Agent
completion and Monitor stop share current-head display acknowledgement with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ This closes a concurrency/current-proof slice of L2/L3, not whole-Goal migration
default onboarding, contributor-owned SQLite D2 or T4 Python retirement. The
[operator contract](../../reference/canonical-lease-renew.md#commit-retry-and-readback)
distinguishes historical results from present execution.
Handoff-mode transition now shares typed ownership facts and an explicit
valid/invalid previous-mode state across legacy and canonical paths. Python's
blocker classification, artificial previous mode and whole-text rewrite are
removed; its retained boundary is source projection/locking and capture IO.
The legacy scan includes event-only claims, and canonical mode receipts reuse
command recovery with strict historical decisions. Full-source snapshot and
real-provider validation guard this T1/T2 replacement. This closes a rule and
caller discrepancy, not a whole default-cutover package; the conditional 5–8
package estimate remains. [Changed behavior and recovery](../../reference/handoff-mode.md).

Terminal review and validation now converge in the existing TS terminal owner.
Agent completion and Monitor stop reuse Chat's canonical receipt-first recovery
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ receipt,再执行新准入。这修复同 operation 并发竞争,不扩展 p
迁移、默认启用、contributor 的 SQLite D2 或 T4 Python 退役完成。见
[操作和恢复合同](../../reference/canonical-lease-renew.md#commit-retry-and-readback)。

模式切换的旧路径与 canonical 路径现在共用 TS 所有权事实及显式的有效/无效旧模式。
删除 Python 的阻塞分类、伪造旧模式和整篇文本重写,保留来源投影、锁与 capture IO。
旧扫描补齐事件独有 claim,canonical 回执复用 command recovery 并严格校验历史决策。
完整快照和真实 provider 验证覆盖这一 T1/T2 替换;它关闭一处规则/调用差异,
不代表关闭整项默认切换交付包,条件性的 5–8 包估算不变。
[行为变化与恢复](../../reference/handoff-mode.md)。

终结审核与验证已收敛到既有 TS terminal owner:Agent 完成、Monitor 停止复用 Chat
先恢复 canonical 回执再确认显示的路径;v2 把验证 continuation 绑定来源 revision,
准入/回放之后才请求私有声明。删除 Python 的终结操作审核分流和提前解析声明编排。
Expand Down
47 changes: 42 additions & 5 deletions docs/reference/handoff-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ loopx handoff-mode set --goal-id example-goal --mode soft_claim --format json
```

Before promotion, these commands use the existing frontmatter writer and its
state/lease locks. After promotion, they use the selected canonical provider;
state/event/lease locks. After promotion, they use the selected canonical provider;
`show` returns `source=canonical_provider` and its `provider_revision`, even if
Markdown is stale or missing. `--runtime-root` applies to both show and set.
Provider errors fail closed. A leftover local lease file cannot override an
Expand All @@ -28,9 +28,28 @@ cannot prove quiescence. Concurrent mutations invalidate the CAS snapshot and
return a conflict without switching the mode. Todos, lease records and their
read-model digests are preserved by the mode change.

The unpromoted scan retains its older materialized-state scope: it does not
claim to include event-only Todos. Its quiescence decision and the canonical
transaction now share one typed policy. No default mode changes.
The unpromoted scan now includes the same complete event-overlay Todo view as
Todo listing, without its display limit. This changes the previous behavior:
an event-only claim now rejects a mode switch. Every configured/fallback event
candidate must be readable; corrupt input returns `handoff_mode_source_unavailable`
instead of silently falling back to apparently empty Markdown. The append store
locks (including absent candidate paths) remain held through the durable mode
write, followed by the existing per-goal lease mutex. Direct unsupported file
edits are outside this contract.

Both paths use the same TS claim/lease classifier and mode-transition rule.
An identical valid mode remains a no-op even with active work. A malformed
legacy mode can be repaired only when quiescent; the result retains its actual
`previous_mode` and `previous_mode_valid=false`. Duplicate mode fields reject
with `handoff_mode_duplicate_field`, and missing frontmatter rejects a changed
mode with `state_frontmatter_missing`. Canonical malformed state still rejects;
legacy repair does not grant permission to repair a canonical head.

Only frontmatter and compact ownership facts enter the legacy TS plan. Python
keeps the source locks, event projection and existing capture/writeback adapter;
the body never crosses the mode-plan transport. The scalar replacement preserves
unrelated metadata, CRLF/LF, Unicode separators and the final newline. No default
mode, provider or capability changes.

## Preserve claims during authority promotion

Expand Down Expand Up @@ -97,6 +116,12 @@ A retry's clock may advance; it still recovers the original result. Even an
accepted unchanged canonical set seals a receipt and advances provider revision,
while returning `changed=false`. If another mode was selected afterward, replay
returns the original decision without restoring it. Use `show` for current mode.
A thrown commit response follows the same durable receipt recovery as other
canonical commands. If the write may have committed but the receipt cannot be
read, the result is `ambiguous` with `coordination_receipt_recovery_required`:
retry the same operation ID. A malformed historical decision is rejected as
`invalid_coordination_command_receipt`, not coerced into an unchanged success.

Preview writes neither a mode nor an operation receipt. `--operation-id` requires
canonical authority; the legacy writer does not promise durable operation replay.

Expand All @@ -120,7 +145,19 @@ provider 失败明确报错,不回退旧文件。现有 Todo-section 投影不
切换要求完整快照内不存在未完成的已认领活动 Todo、不存在有效 lease。过期时间
恰好等于观察时间视为已过期;非法有效期或未知 lease schema 不能作为空闲证据。
并发修改使 CAS 冲突,不能在旧检查结果上继续切换。原 Todo、lease 和摘要不变。
未晋升路径仍仅扫描物化状态,不宣称覆盖 event-only Todo;两条路径共用 TS 切换规则。
未晋升路径现在也读取完整事件覆盖视图,包含显示分页之外的 Todo。因此旧行为发生改变:
仅在事件中存在的 claim 也会阻止切换。所有事件候选源必须可读,损坏源返回
`handoff_mode_source_unavailable`,不能回退 Markdown 后宣称空闲。事件追加锁从读取
保持到模式写回完成,再配合已有 lease 锁;直接手改文件仍不在该合同内。

两条路径共用 TS 所有权分类和切换规则。相同合法模式仍是 no-op;非法旧模式以显式
无效状态进入修复,只允许在无在途工作时修复,不再伪造另一个合法旧模式。
重复字段拒绝为 `handoff_mode_duplicate_field`,缺少 frontmatter 时拒绝变更。
只把 frontmatter 和必要事实传给 TS,Python 保留锁、事件投影和 capture 适配;
正文不进入计划传输,并保留 CRLF、Unicode 分隔符和末尾换行。默认模式和 provider 不变。

canonical 提交响应丢失时复用已有回执恢复;若回执暂时不可读,返回 ambiguous 并要求
以同一个 operation ID 重试。损坏的历史决策明确拒绝,不能当作“成功但没变化”。

对于无法清空活跃 claim 的 Goal,整 Goal authority 晋升提供一个更窄的显式迁移入口:
`--handoff-mode-migration preserve` 只切换存储权威并保留 `legacy`/`soft_claim`;
Expand Down
51 changes: 51 additions & 0 deletions loopx/control_plane/coordination/handoff_mode_facts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/** Quiescence is a decision over complete ownership facts, never a display page. */
import type {JsonObject} from "../effect_program.ts";
import {requireJsonObject} from "../runtime_decode.ts";
import {parseIsoTimestamp} from "../runtime_timestamp.ts";
import {leaseIsActive, TASK_LEASE_SCHEMA_VERSION} from "../work_items/task_lease_acquire.ts";
import {HANDOFF_MODES, type HandoffMode} from "./handoff_mode_policy.ts";

export type PersistedHandoffMode =
| {kind: "valid"; value: HandoffMode}
| {kind: "invalid"; value: string};

export function persistedHandoffMode(value: unknown): PersistedHandoffMode {
const text = value == null ? "" : String(value).trim();
if (!text) return {kind: "valid", value: "legacy"};
const mode = HANDOFF_MODES.find(mode => mode === text);
return mode ? {kind: "valid", value: mode} : {kind: "invalid", value: text};
}

export function previousModeFields(mode: PersistedHandoffMode): JsonObject {
return {previous_mode: mode.value, previous_mode_valid: mode.kind === "valid",
...(mode.kind === "invalid" ? {previous_mode_error_code: "invalid_handoff_mode"} : {})};
}

export interface HandoffQuiescence {
claimed_todos: JsonObject[];
active_leases: JsonObject[];
}

export function handoffQuiescence(todos: readonly JsonObject[], leases: readonly JsonObject[],
observedAt: string): HandoffQuiescence {
const now = parseIsoTimestamp(observedAt);
if (!now) throw new Error("observed_at must be a valid ISO timestamp");
const claimed: JsonObject[] = [];
for (const value of todos) {
const todo = requireJsonObject(value, "handoff Todo");
if (todo.archive_state === "archive" || todo.done === true) continue;
if (todo.claimed_by == null || todo.claimed_by === "") continue;
if (typeof todo.claimed_by !== "string") throw new Error("Todo claimed_by must be a string or null");
const owner = todo.claimed_by.trim();
if (owner) claimed.push({todo_id: todo.todo_id ?? null, claimed_by: owner, status: todo.status ?? null});
}
const active: JsonObject[] = [];
for (const value of leases) {
const lease = requireJsonObject(value, "handoff lease");
if (lease.schema_version !== TASK_LEASE_SCHEMA_VERSION) throw new Error("lease schema mismatch");
if (leaseIsActive(lease, now)) active.push({todo_id: lease.todo_id ?? null,
owner: lease.owner ?? null, expires_at: lease.expires_at ?? null,
...(typeof lease.lease_path === "string" ? {lease_path: lease.lease_path} : {})});
}
return {claimed_todos: claimed, active_leases: active};
}
74 changes: 74 additions & 0 deletions loopx/control_plane/coordination/handoff_mode_legacy_plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/** Typed compatibility plan. Python owns source locks/capture; TS owns the change. */
import type {JsonObject} from "../effect_program.ts";
import {requireJsonObject, requireStringLiteral} from "../runtime_decode.ts";
import {HANDOFF_MODES, decideHandoffMode} from "./handoff_mode_policy.ts";
import {handoffQuiescence, persistedHandoffMode, previousModeFields} from "./handoff_mode_facts.ts";

export const LEGACY_HANDOFF_PLAN_SCHEMA = "loopx_legacy_handoff_mode_plan_request_v0";
const RESULT_SCHEMA = "loopx_legacy_handoff_mode_plan_result_v0";

/** Edit only the generated scalar field. All other bytes, including CRLF and
* Unicode line separators inside quoted metadata, remain untouched. A duplicate
* key has no single writable owner, so do not silently patch only one copy. */
export function patchHandoffMode(text: string, requested: string): string {
const opening = /^---[ \t]*\r?\n/.exec(text)?.[0];
if (!opening) throw new Error("state_frontmatter_missing");
const fields: {start: number; end: number; ending: string}[] = [];
let close: number | undefined;
// JavaScript multiline ^ also recognizes U+2028/U+2029. The generated
// metadata protocol uses physical LF only, including inside JSON strings.
for (let start = opening.length; start <= text.length;) {
const newline = text.indexOf("\n", start);
const end = newline < 0 ? text.length : newline + 1;
const raw = text.slice(start, end);
const line = raw.replace(/\r?\n$/, "");
if (/^---[ \t]*$/.test(line)) { close = start; break; }
if (/^[ \t]*handoff_mode[ \t]*:/.test(line)) {
fields.push({start, end, ending: /\r?\n$/.exec(raw)?.[0] ?? ""});
}
if (newline < 0) break;
start = end;
}
if (close === undefined) throw new Error("state_frontmatter_missing");
if (fields.length > 1) throw new Error("handoff_mode_duplicate_field");
const field = fields[0];
if (field) return text.slice(0, field.start) + `handoff_mode: ${requested}${field.ending}` + text.slice(field.end);
const newline = opening.endsWith("\r\n") ? "\r\n" : "\n";
return text.slice(0, close) + `handoff_mode: ${requested}${newline}` + text.slice(close);
}

export function planLegacyHandoffMode(value: unknown): JsonObject {
const input = requireJsonObject(value, "legacy handoff mode plan");
if (input.schema_version !== LEGACY_HANDOFF_PLAN_SCHEMA) throw new Error("legacy handoff plan schema mismatch");
const requested = requireStringLiteral(input.requested_mode, HANDOFF_MODES, "requested_mode");
if (typeof input.frontmatter_text !== "string") throw new Error("frontmatter_text must be a string");
const previous = persistedHandoffMode(input.previous_value);
const fields = {...previousModeFields(previous), handoff_mode: requested};
// An identical valid mode grants no new behavior. Preserve the compatibility
// no-op even when leases exist or the legacy state has no frontmatter.
if (previous.kind === "valid" && previous.value === requested) {
return {schema_version: RESULT_SCHEMA, outcome: "no_change", code: "handoff_mode_unchanged",
changed: false, ...fields};
}
if (input.todos === null && input.leases === null) return {schema_version: RESULT_SCHEMA,
outcome: "snapshot_required", ...fields, changed: false};
if (!Array.isArray(input.todos) || !Array.isArray(input.leases)) throw new Error("complete Todo and lease facts required");
let facts;
try {
facts = handoffQuiescence(input.todos.map(row => requireJsonObject(row, "Todo")),
input.leases.map(row => requireJsonObject(row, "lease")), String(input.observed_at));
} catch (error) {
return {schema_version: RESULT_SCHEMA, outcome: "rejected", code: "invalid_handoff_mode_authority",
...fields, changed: false, reason: String(error)};
}
const plan = decideHandoffMode(previous, requested, facts.claimed_todos.length, facts.active_leases.length);
if (plan.outcome === "rejected") return {schema_version: RESULT_SCHEMA, ...plan, ...fields,
changed: false, ...facts};
try {
return {schema_version: RESULT_SCHEMA, ...plan, ...fields, changed: true,
next_frontmatter_text: patchHandoffMode(input.frontmatter_text, requested)};
} catch (error) {
if (!(error instanceof Error) || !["state_frontmatter_missing", "handoff_mode_duplicate_field"].includes(error.message)) throw error;
return {schema_version: RESULT_SCHEMA, outcome: "rejected", code: error.message, ...fields, changed: false};
}
}
17 changes: 12 additions & 5 deletions loopx/control_plane/coordination/handoff_mode_policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,24 @@ export const HANDOFF_MODES = ["legacy", "soft_claim", "hard_lease"] as const;
export type HandoffMode = typeof HANDOFF_MODES[number];
export const HANDOFF_MODE_PLAN_SCHEMA = "loopx_handoff_mode_plan_request_v0";

import type {PersistedHandoffMode} from "./handoff_mode_facts.ts";

export function decideHandoffMode(previous: PersistedHandoffMode, requested: HandoffMode,
claims: number, leases: number): JsonObject {
const unchanged = previous.kind === "valid" && previous.value === requested;
const rejected = !unchanged && (claims > 0 || leases > 0);
return {outcome: unchanged ? "no_change" : rejected ? "rejected" : "apply",
code: unchanged ? "handoff_mode_unchanged" : rejected ? "handoff_mode_not_quiescent" : "handoff_mode_transition",
idempotent: unchanged, previous_mode: previous.value, handoff_mode: requested};
}

export function planHandoffMode(value: unknown): JsonObject {
const input = requireJsonObject(value, "handoff mode plan");
if (input.schema_version !== HANDOFF_MODE_PLAN_SCHEMA) throw new Error("handoff mode plan schema mismatch");
const previous = requireStringLiteral(input.previous_mode, HANDOFF_MODES, "previous_mode");
const requested = requireStringLiteral(input.requested_mode, HANDOFF_MODES, "requested_mode");
const claims = requireStringArray(input.active_claimed_todo_ids, "active_claimed_todo_ids");
const leases = requireStringArray(input.active_lease_todo_ids, "active_lease_todo_ids");
const unchanged = previous === requested;
const rejected = !unchanged && (claims.length > 0 || leases.length > 0);
return {schema_version: "loopx_handoff_mode_plan_result_v0",
outcome: unchanged ? "no_change" : rejected ? "rejected" : "apply",
code: unchanged ? "handoff_mode_unchanged" : rejected ? "handoff_mode_not_quiescent" : "handoff_mode_transition",
idempotent: unchanged, previous_mode: previous, handoff_mode: requested};
...decideHandoffMode({kind: "valid", value: previous}, requested, claims.length, leases.length)};
}
Loading
Loading