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 @@ -1544,10 +1544,10 @@ projects normalized snapshots, invokes those decisions, and reconstructs the
provider-neutral `TransitionPlan`. The local lease-file transaction and the
coordination executor therefore consume the same lease decisions; locking,
source revalidation, file persistence, provider CAS, and receipt construction
remain in their respective execution layers. Todo, terminal-fence, and
handoff-mode decisions stay in the Python core until their own reviewed
TypeScript cutovers; local holder/fence-close lock mechanics remain execution
effects rather than provider contracts.
remain in their respective execution layers. The initial extraction retained
Todo, terminal-fence and handoff-mode decisions in Python; subsequent cutovers
move them to their typed owners. Handoff quiescence now lives in
`handoff_mode_policy.ts`. Local holder/fence-close locks remain execution effects.

Keep three layers distinct as the provider work proceeds:

Expand Down Expand Up @@ -2894,6 +2894,7 @@ an equal-byte retry syncs file and directory before reporting `current`. Narrati
canonical records stay intact. This converges the retained Python presentation/legacy
input adapter; it adds no RPC or business state machine and does not change TS authority
transactions, provider defaults, SQLite D2 or D3 promotion requirements.
Canonical handoff-mode show/set no longer depend on Markdown frontmatter or local lease files. One TS transaction binds quiescence, mode and durable operation replay to the same revision, including sealed no-op intents. This adds a provider-neutral command boundary, not a provider default or whole-Goal cutover; frontmatter remains outside the Todo-section renderer. See [operation and recovery](../../reference/handoff-mode.md).

T2 now commits a lease-free native Monitor observation and its independent
successors in one canonical CAS/receipt; the route planner alone still grants
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1231,9 +1231,9 @@ transfer、release 则由 `task_lease_lifecycle_decision.ts` 的纯 seam 持有
`authority_core` 只负责投影 normalized snapshot、调用这些 decision,再重建
provider-neutral `TransitionPlan`。因此,本地 lease-file transaction 与 coordination
executor 消费同一份 lease decision;加锁、source 重验、文件持久化、provider CAS 与
receipt 构造仍分别属于各自 execution layer。Todo、terminal-fence 与 handoff-mode
决策继续留在 Python core,直到各自经过 review 的 TypeScript cutover;本地 holder /
fence-close 锁机制属于 execution effect,而不是 provider contract。
receipt 构造仍分别属于各自 execution layer。初次抽取保留的 Todo、terminal-fence
与 handoff-mode Python 决策由后续切片移入 typed owner;handoff 空闲判断现归属
`handoff_mode_policy.ts`。本地 holder/fence-close 锁仍属于 execution effect。

后续 provider 工作必须始终分开三层:

Expand Down Expand Up @@ -2288,6 +2288,7 @@ fenced 示例被当成真实任务、归档 end marker 后叙述进入历史、
文件/目录同步,之后才报告 `current`。区域外正文和 canonical record 不被改写。
这是永久 Python 展示/legacy 输入适配层的收敛:TS authority transaction、provider
默认值、SQLite D2 与 D3 promotion 合同不变,不增加 RPC 或另一份业务状态机。
Canonical handoff-mode show/set 不再依赖 Markdown frontmatter 或本地 lease;一笔 TS 事务把空闲检查、mode 与耐久操作回执绑定到同一 revision,包括未改值请求的回执。该命令边界不切换默认 provider、不晋升整 Goal;frontmatter 仍不属于 Todo-section renderer。操作与恢复见 [handoff-mode](../../reference/handoff-mode.md)。

能力缺口 consumer 在 legacy/canonical 输入上共用 TS requirement/resolution owner,
包括 quota 的 Monitor 能力分流。删除 Python missing-set 与 owner/repair 决策 builder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,7 @@ an equal-byte retry syncs file and directory before reporting `current`. Narrati
canonical records stay intact. This converges the retained Python presentation/legacy
input adapter; it adds no RPC or business state machine and does not change TS authority
transactions, provider defaults, SQLite D2 or D3 promotion requirements.
Handoff mode now shares a typed quiescence policy between the legacy adapter and one provider-neutral CAS/receipt transaction. Promoted show/set consume canonical mode and complete Todo/lease facts; the old Python transition decision is removed. Legacy state/lease locks remain until their last writer retires. See [handoff-mode operation and replay](../../reference/handoff-mode.md).

Task-graph topology now shares `work_items/planning_relations.ts` with inventory
and horizon. One pure TS request owns relationship discovery, deterministic
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ fenced 示例被当成真实任务、归档 end marker 后叙述进入历史、
文件/目录同步,之后才报告 `current`。区域外正文和 canonical record 不被改写。
这是永久 Python 展示/legacy 输入适配层的收敛:TS authority transaction、provider
默认值、SQLite D2 与 D3 promotion 合同不变,不增加 RPC 或另一份业务状态机。
Handoff mode 的 legacy adapter 与原生 CAS/receipt 事务现共用 TS 空闲判断;晋升后的 show/set 使用 canonical mode 和完整 Todo/lease 快照,删除 Python 切换决策。旧 state/lease 锁仍服务未晋升 writer,不能提前删除。操作与回放合同见 [handoff-mode](../../reference/handoff-mode.md)。

Task graph topology 与 inventory/horizon 共用 `work_items/planning_relations.ts`。
一轮纯 TS 请求拥有关系发现、稳定有界遍历、边去重与缺失/截断完整度;删除
Expand Down
81 changes: 81 additions & 0 deletions docs/reference/handoff-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Goal handoff mode

`handoff-mode` chooses the ownership rule used by existing Todo/lease operations:
`legacy` retains the claim/lease compatibility model, `soft_claim` uses the Todo
claim, and `hard_lease` requires the existing lease fences. It is not an Agent
capability grant, provider selector, or Goal promotion command.

## Read and change

```bash
loopx handoff-mode show --goal-id example-goal --format json
loopx handoff-mode set --goal-id example-goal --mode soft_claim --dry-run --format json
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;
`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
empty canonical lease collection.

A mode change requires no unfinished claimed active Todo and no time-active
lease. The canonical transaction checks the complete Todo/lease snapshot,
including records outside display limits. An expiry equal to the observation
time is expired; an invalid active lease timestamp or unknown lease schema
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.

## Recover a canonical request

Choose an operation ID before a canonical set if a lost response must be retried:

```bash
loopx handoff-mode set --goal-id example-goal --mode soft_claim --operation-id mode-change-1 --format json
# Repeat this exact intent to recover its original receipt.
loopx handoff-mode set --goal-id example-goal --mode soft_claim --operation-id mode-change-1 --format json
loopx handoff-mode show --goal-id example-goal --format json
```

The ID binds the goal and requested mode. Reuse with a different mode is rejected.
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.
Preview writes neither a mode nor an operation receipt. `--operation-id` requires
canonical authority; the legacy writer does not promise durable operation replay.

Select a previous mode with a **new** operation ID to change it back, subject to
the same quiescence check. Do not disable the writer fence or restore old Markdown
to roll back a canonical change. The existing Todo-section renderer does not
project frontmatter: canonical mode is read through `handoff-mode show`, not a
possibly old frontmatter value. This command does not qualify a provider profile,
complete D1–D3, deploy PostgreSQL, or authorize active-Goal migration.

## 中文

`handoff-mode` 选择 Todo 的 claim/lease 所有权规则,不授予 capability、不选择
provider,也不执行 Goal 晋升。上面的命令分别用于读取、预览和切换。

晋升前保留 frontmatter 与本地锁兼容路径;晋升后从 canonical provider 读取,
Markdown 缺失/陈旧和遗留本地 lease 不再影响判断。`show` 返回来源及 revision;
provider 失败明确报错,不回退旧文件。现有 Todo-section 投影不包含 frontmatter,
因此当前 mode 应通过 `show` 查询。

切换要求完整快照内不存在未完成的已认领活动 Todo、不存在有效 lease。过期时间
恰好等于观察时间视为已过期;非法有效期或未知 lease schema 不能作为空闲证据。
并发修改使 CAS 冲突,不能在旧检查结果上继续切换。原 Todo、lease 和摘要不变。
未晋升路径仍仅扫描物化状态,不宣称覆盖 event-only Todo;两条路径共用 TS 切换规则。

需支持丢响应恢复时,在首次 canonical set 前指定 `--operation-id`,重试沿用同一
目标 mode 和 ID。不同 mode 复用 ID 会被拒绝;即使最初 mode 未变,也记录耐久回执。
若后来已切到其他 mode,旧请求重放只返回原回执,不把 mode 改回去;用 `show` 读当前值。
预览不写入;旧 writer 不支持该幂等 ID。需要切回时,用新 ID 请求原 mode,仍须满足
空闲门禁,不能通过关闭 fence 或恢复旧 Markdown 回滚。本功能不解除 provider
默认值、长程资格化、PostgreSQL 部署或 D1–D3 的剩余条件。
16 changes: 11 additions & 5 deletions loopx/cli_commands/handoff_mode.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from ..control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable

from ..control_plane.coordination.legacy_writer_fence import LegacyCoordinationWriterFenced
from ..control_plane.coordination.shadow_management import ShadowManagementError

Expand Down Expand Up @@ -69,7 +71,7 @@ def register_handoff_mode_command(
parser = subparsers.add_parser(
"handoff-mode",
help=(
"Show or set the per-goal handoff_mode front-matter field that "
"Show or set the authoritative per-goal handoff_mode that "
"selects which ownership authority governs todo handoffs."
),
)
Expand All @@ -94,6 +96,8 @@ def register_handoff_mode_command(
"for ownership changes and makes the completion fence mandatory."
),
)
parser.add_argument("--operation-id", help="Stable canonical set intent id for recovery after a lost response.")
parser.add_argument("--dry-run", action="store_true", help="Validate set without committing the mode or a receipt.")
parser.add_argument("--project", help="Project root. Defaults to the registry goal repo.")
parser.add_argument("--state-file", help="Active goal state path. Defaults to the registry goal state_file.")

Expand All @@ -114,11 +118,12 @@ def handle_handoff_mode_command(
}
try:
if args.handoff_mode_command == "show":
if args.mode:
raise ValueError("handoff-mode show does not accept --mode")
if args.mode or args.operation_id or args.dry_run:
raise ValueError("handoff-mode show does not accept set options")
payload = show_goal_handoff_mode(
registry_path=registry_path,
goal_id=args.goal_id,
runtime_root_arg=runtime_root_arg,
**path_args,
)
else:
Expand All @@ -129,17 +134,18 @@ def handle_handoff_mode_command(
goal_id=args.goal_id,
mode=args.mode,
runtime_root_arg=runtime_root_arg,
operation_id=args.operation_id, dry_run=args.dry_run,
**path_args,
)
except (HandoffModeError, LegacyCoordinationWriterFenced, ShadowManagementError) as exc:
except (HandoffModeError, LegacyCoordinationWriterFenced, ShadowManagementError, LocalCoordinationAuthorityUnavailable) as exc:
payload = {
**exc.payload,
"ok": False,
"schema_version": "goal_handoff_mode_v0",
"action": getattr(args, "handoff_mode_command", None),
"goal_id": args.goal_id,
"error": str(exc),
"error_code": exc.code,
**exc.payload,
}
except LockAcquireTimeoutError as exc:
payload = {
Expand Down
28 changes: 14 additions & 14 deletions loopx/control_plane/coordination/authority_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,20 +784,20 @@ def _decide_handoff_transition(
snapshot: CoordinationSnapshot,
command: HandoffModeTransitionCommand,
) -> TransitionPlan:
if snapshot.handoff_mode is command.requested_mode:
return _result(
DecisionOutcome.NO_CHANGE,
"handoff_mode_unchanged",
next_snapshot=snapshot,
idempotent=True,
)
if snapshot.active_claimed_todo_ids or snapshot.active_lease_todo_ids:
return _result(DecisionOutcome.REJECTED, "handoff_mode_not_quiescent")
return _result(
DecisionOutcome.APPLY,
"handoff_mode_transition",
next_snapshot=replace(snapshot, handoff_mode=command.requested_mode),
)
result = effect_runtime_result("coordination.handoff_mode.plan", {
"schema_version": "loopx_handoff_mode_plan_request_v0",
"previous_mode": snapshot.handoff_mode.value,
"requested_mode": command.requested_mode.value,
"active_claimed_todo_ids": list(snapshot.active_claimed_todo_ids),
"active_lease_todo_ids": list(snapshot.active_lease_todo_ids),
})
if not isinstance(result, dict) or result.get("schema_version") != "loopx_handoff_mode_plan_result_v0":
raise RuntimeError("TypeScript handoff mode plan shape mismatch")
outcome = DecisionOutcome(result["outcome"])
return _result(outcome, str(result["code"]),
next_snapshot=(None if outcome is DecisionOutcome.REJECTED else
replace(snapshot, handoff_mode=command.requested_mode)),
idempotent=result["idempotent"])


def decide(
Expand Down
22 changes: 22 additions & 0 deletions loopx/control_plane/coordination/handoff_mode_policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** One quiescence rule for the legacy adapter and canonical mode transaction. */
import type {JsonObject} from "../effect_program.ts";
import {requireJsonObject, requireStringArray, requireStringLiteral} from "../runtime_decode.ts";

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";

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",

Check warning on line 19 in loopx/control_plane/coordination/handoff_mode_policy.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=huangruiteng_loopx&issues=AaCX8kIqQzi-Va3rxlYu&open=AaCX8kIqQzi-Va3rxlYu&pullRequest=4304
code: unchanged ? "handoff_mode_unchanged" : rejected ? "handoff_mode_not_quiescent" : "handoff_mode_transition",

Check warning on line 20 in loopx/control_plane/coordination/handoff_mode_policy.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=huangruiteng_loopx&issues=AaCX8kIqQzi-Va3rxlYv&open=AaCX8kIqQzi-Va3rxlYv&pullRequest=4304
idempotent: unchanged, previous_mode: previous, handoff_mode: requested};
}
29 changes: 29 additions & 0 deletions loopx/control_plane/coordination/handoff_mode_runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/** Local transport binds the existing provider and writer fence to the mode transaction. */
import type {JsonObject} from "../effect_program.ts";
import {requireJsonObject} from "../runtime_decode.ts";
import {requireAuthorityStoreId} from "./authority_store_codec.ts";
import {openLocalAuthorityStore, localAuthorityOpenFailure} from "./local_authority_provider.ts";
import {runtimeRoot, sourceAuthorityFor, withCanonicalWriter} from "./local_authority_runtime.ts";
import {ShadowManagementError} from "./shadow_management.ts";
import {executeHandoffModeSet, HANDOFF_MODE_SET_SCHEMA} from "./handoff_mode_transaction.ts";

export async function setLocalHandoffMode(value: unknown): Promise<JsonObject> {
const evidence = {source_authority: "file_v0", decision_read_from_provider: true, legacy_fallback_used: false};
try {
const input = requireJsonObject(value, "handoff mode set request");
if (input.schema_version !== HANDOFF_MODE_SET_SCHEMA) throw new Error("handoff mode request schema mismatch");
const root = runtimeRoot(input.runtime_root);
const goalId = requireAuthorityStoreId(input.goal_id, "goal id");
return await withCanonicalWriter(root, goalId, input.dry_run === true, async () => {
const store = await openLocalAuthorityStore(root, goalId);
evidence.source_authority = sourceAuthorityFor(store);
return {...await executeHandoffModeSet(store, {goal_id: goalId,
operation_id: input.operation_id as string, requested_mode: input.requested_mode as string,
observed_at: input.observed_at as string, dry_run: input.dry_run as boolean}), ...evidence};
});
} catch (error) {
return {schema_version: "loopx_coordination_handoff_mode_set_result_v0", status: "failed", changed: false,
reason_code: error instanceof ShadowManagementError ? error.reason_code : "handoff_mode_unavailable",
reason: error instanceof Error ? error.message : String(error), ...evidence, ...localAuthorityOpenFailure(error)};
}
}
Loading