Skip to content

Commit f8f82f3

Browse files
authored
refactor(todo): unify claim decision in TypeScript (#3972)
Signed-off-by: huangruiteng <huangrt01@163.com>
1 parent 8a2c89c commit f8f82f3

8 files changed

Lines changed: 393 additions & 55 deletions

‎docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2387,6 +2387,13 @@ export and cleanup. Each slice must prove an end-to-end transaction, not merely
23872387
another schema identifier consolidation. Native contract acceptance alone is
23882388
not permission to bypass any promotion hold.
23892389

2390+
The first replacement-first `claim` slice routes both the default Markdown
2391+
writer and the promoted provider transaction through one TypeScript decision.
2392+
Python's default path retains only locked commit and existing projection-
2393+
compatibility duties. This closes duplicate claim policy; it neither promotes
2394+
Markdown to authority nor replaces the remaining unified
2395+
create/update/complete/archive transactions and projection outbox.
2396+
23902397
Use file-v0 for bounded conformance and import rehearsal only. Start the
23912398
Section 7.2 embedded-store slice alongside the provider-first Todo caller; both
23922399
converge before long-goal local qualification and promotion. PostgreSQL

‎docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1893,6 +1893,11 @@ kernel 的近期顺序是:(1)真实 provider-first Todo lifecycle caller
18931893
transaction,不能只做另一轮 schema identifier 统一。接受 native contract 不等于
18941894
可以绕过任何 promotion hold。
18951895

1896+
`claim` 的第一个 replacement-first 切片把默认 Markdown writer 与 promotion 后的
1897+
provider transaction 接到同一个 TS decision;Python 默认路径只保留持锁提交和既有
1898+
投影兼容职责。它关闭 claim policy 的双实现,但不把 Markdown 提升为 authority,
1899+
也不替代剩余 create/update/complete/archive 的统一 transaction 与 projection outbox。
1900+
18961901
file-v0 只用于有界 conformance 与 import 演练。第 7.2 节嵌入式存储切片与 provider-first
18971902
Todo caller 同时推进,在长程本地资格化与晋升前汇合。PostgreSQL service/deployment
18981903
继续并行,不成为本地晋升的依赖;NoKV 独立通过自己的 lineage/recovery 资格门禁。

‎docs/architecture/rfcs/typescript-control-plane-migration-v0.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ native creation, archival, receipt replay, and store reopen are tested without
3131
Markdown metadata. Python only adapts the typed read result to the compatibility
3232
summary. This is a contract checkpoint, not a completed CLI lifecycle cutover.
3333

34-
After explicit promotion, `todo claim` now crosses once into a TS-owned
34+
The default Markdown and explicitly promoted `todo claim` paths now share one
35+
TypeScript claim decision for actor, registration, role, status, archive,
36+
exclusion, and existing-owner checks; the Python legacy writer only commits
37+
that decision while holding its lock. After explicit promotion, claim crosses
38+
once into the same TS-owned
3539
transaction for both native and v0 records. New claims require active, open
3640
Todos and the current actor/lease checks. Exact operation retries recover the
3741
original claim receipt before current-state eligibility; observation time and

‎docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ coordination 路径使用同一份语言中立的 `coordination_state_contract_v
2828
仅将 typed read result 适配为兼容 summary。这是 contract 检查点,不是已经完成的
2929
CLI lifecycle cutover。
3030

31-
显式 promotion 后,`todo claim` 现在只跨一次 runtime 边界,由 TS 事务同时处理原生
31+
默认 Markdown 与显式 promotion 两条 `todo claim` 路径现在都由同一个 TS claim
32+
decision 处理 actor、registration、role、status、archive、exclusion 与现有 owner
33+
检查;Python legacy writer 只在持锁后提交该 decision。显式 promotion 后,claim
34+
只跨一次 runtime 边界,由同一 TS 事务同时处理原生
3235
和 v0 记录。新 claim 要求 active、open Todo,并检查当前 actor/lease;同一 operation
3336
的重试先恢复原 claim receipt,再考虑当前资格。观测时间和当前注册信息不属于请求
3437
身份。回放 receipt 不续租,也不表示当前仍持有任务。非 preview 的成功 `no_change`

‎loopx/control_plane/coordination/todo_claim.ts‎

Lines changed: 146 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export const COORDINATION_TODO_CLAIM_RESULT_SCHEMA =
1616
"loopx_coordination_todo_claim_result_v0";
1717
export const COORDINATION_TODO_CLAIM_RECEIPT_SCHEMA =
1818
"loopx_coordination_todo_claim_receipt_v0";
19+
export const COORDINATION_TODO_CLAIM_DECISION_SCHEMA =
20+
"loopx_coordination_todo_claim_decision_v0";
1921

2022
export interface CoordinationTodoClaimInput {
2123
readonly goal_id: string;
@@ -33,6 +35,28 @@ export type CoordinationTodoClaimResult = JsonObject & {
3335
readonly schema_version: typeof COORDINATION_TODO_CLAIM_RESULT_SCHEMA;
3436
};
3537

38+
export interface CoordinationTodoClaimAcceptedDecision extends JsonObject {
39+
readonly schema_version: typeof COORDINATION_TODO_CLAIM_DECISION_SCHEMA;
40+
readonly status: "accepted";
41+
readonly owner: string;
42+
readonly actor: string | null;
43+
readonly mode: "single_agent_compatibility" | "registered_peer_actor";
44+
readonly mutation_authority: JsonObject;
45+
}
46+
47+
export interface CoordinationTodoClaimRejectedDecision extends JsonObject {
48+
readonly schema_version: typeof COORDINATION_TODO_CLAIM_DECISION_SCHEMA;
49+
readonly status: "rejected";
50+
readonly reason_code: string;
51+
readonly reason: string;
52+
}
53+
54+
export type CoordinationTodoClaimDecision =
55+
| CoordinationTodoClaimAcceptedDecision
56+
| CoordinationTodoClaimRejectedDecision;
57+
58+
type ClaimRejection = CoordinationTodoClaimRejectedDecision | null;
59+
3660
function normalizeAgent(value: unknown, label: string): string {
3761
if (typeof value !== "string") {
3862
throw new AuthorityStoreProtocolError(`${label} must be a public-safe agent id`);
@@ -59,85 +83,155 @@ function normalizeRegisteredAgents(value: readonly string[]): string[] {
5983
return normalized;
6084
}
6185

86+
function normalizeExcludedAgents(value: unknown): string[] {
87+
if (value === undefined || value === null) return [];
88+
if (!Array.isArray(value)) {
89+
throw new AuthorityStoreProtocolError("todo.excluded_agents must be an array");
90+
}
91+
const normalized = value.map((agent, index) =>
92+
normalizeAgent(agent, `todo.excluded_agents[${index}]`)
93+
);
94+
if (new Set(normalized).size !== normalized.length) {
95+
throw new AuthorityStoreProtocolError(
96+
"todo.excluded_agents must contain unique public-safe agent ids",
97+
);
98+
}
99+
return normalized;
100+
}
101+
62102
function failure(code: string, reason: string, detail: JsonObject = {}): CoordinationTodoClaimResult {
63103
return {
104+
...detail,
64105
schema_version: COORDINATION_TODO_CLAIM_RESULT_SCHEMA,
65106
status: "failed",
66107
reason_code: code,
67108
reason,
109+
};
110+
}
111+
112+
function decisionFailure(
113+
code: string,
114+
reason: string,
115+
detail: JsonObject = {},
116+
): CoordinationTodoClaimRejectedDecision {
117+
return {
68118
...detail,
119+
schema_version: COORDINATION_TODO_CLAIM_DECISION_SCHEMA,
120+
status: "rejected",
121+
reason_code: code,
122+
reason,
69123
};
70124
}
71125

72-
function claimAuthority(
73-
todo: JsonObject,
126+
function rejectInvalidClaimActor(
74127
input: CoordinationTodoClaimInput,
75-
): { owner: string; actor: string | null; mode: string } | CoordinationTodoClaimResult {
76-
const registered = input.registered_agents;
77-
const owner = input.claimed_by;
78-
const actor = input.actor_agent_id;
128+
): ClaimRejection {
129+
const { registered_agents: registered, claimed_by: owner, actor_agent_id: actor } = input;
79130
if (!registered.includes(owner)) {
80-
return failure("actor_not_registered", "claimed_by is not registered for this goal", {
81-
claimed_by: owner,
82-
});
131+
return decisionFailure(
132+
"actor_not_registered",
133+
"claimed_by is not registered for this goal",
134+
{ claimed_by: owner },
135+
);
83136
}
84137
if (actor !== null && !registered.includes(actor)) {
85-
return failure("actor_not_registered", "actor_agent_id is not registered for this goal", {
86-
actor_agent_id: actor,
87-
});
138+
return decisionFailure(
139+
"actor_not_registered",
140+
"actor_agent_id is not registered for this goal",
141+
{ actor_agent_id: actor },
142+
);
88143
}
89144
if (registered.length > 1 && actor === null) {
90-
return failure("actor_required", "multi-agent Todo claim requires actor_agent_id");
145+
return decisionFailure(
146+
"actor_required",
147+
"multi-agent Todo claim requires actor_agent_id",
148+
);
91149
}
92-
if (actor !== null && actor !== owner) {
93-
return failure(
150+
return actor !== null && actor !== owner
151+
? decisionFailure(
94152
"claim_actor_mismatch",
95153
"Todo claim requires claimed_by to match actor_agent_id",
96154
{ actor_agent_id: actor, claimed_by: owner },
97-
);
98-
}
155+
)
156+
: null;
157+
}
158+
159+
function rejectIneligibleTodo(
160+
todo: JsonObject,
161+
input: CoordinationTodoClaimInput,
162+
): ClaimRejection {
99163
if (todo.role !== "agent") {
100-
return failure("todo_not_agent", "claimed_by is only valid for agent Todos");
164+
return decisionFailure("todo_not_agent", "claimed_by is only valid for agent Todos");
101165
}
102166
if (input.expected_role !== null && input.expected_role !== todo.role) {
103-
return failure("todo_role_mismatch", "Todo does not have the requested role", {
104-
requested_role: input.expected_role,
105-
todo_role: todo.role,
106-
});
167+
return decisionFailure(
168+
"todo_role_mismatch",
169+
"Todo does not have the requested role",
170+
{ requested_role: input.expected_role, todo_role: todo.role },
171+
);
107172
}
108173
if (todo.status !== "open") {
109-
return failure("todo_not_open", "Todo claim requires status=open", {
110-
todo_status: todo.status,
111-
});
174+
return decisionFailure(
175+
"todo_not_open",
176+
"Todo claim requires status=open",
177+
{ todo_status: todo.status },
178+
);
112179
}
113180
if (todo.archive_state !== "active") {
114-
return failure("todo_archived", "Todo claim requires an active Todo");
181+
return decisionFailure("todo_archived", "Todo claim requires an active Todo");
115182
}
116-
if (typeof todo.removed_continuation_policy === "string" &&
117-
todo.removed_continuation_policy.length > 0) {
118-
return failure(
183+
return typeof todo.removed_continuation_policy === "string" &&
184+
todo.removed_continuation_policy.length > 0
185+
? decisionFailure(
119186
"removed_continuation_policy",
120187
"Todo uses a removed continuation policy and must be repaired before claiming",
121-
);
122-
}
123-
const excluded = Array.isArray(todo.excluded_agents) ? todo.excluded_agents : [];
188+
)
189+
: null;
190+
}
191+
192+
export function evaluateCoordinationTodoClaimDecision(
193+
todo: JsonObject,
194+
input: CoordinationTodoClaimInput,
195+
): CoordinationTodoClaimDecision {
196+
const registered = input.registered_agents;
197+
const owner = input.claimed_by;
198+
const actor = input.actor_agent_id;
199+
const rejection = rejectInvalidClaimActor(input) ?? rejectIneligibleTodo(todo, input);
200+
if (rejection !== null) return rejection;
201+
const excluded = normalizeExcludedAgents(todo.excluded_agents);
124202
if (excluded.includes(owner)) {
125-
return failure("actor_excluded", "claiming agent is excluded from this Todo", {
126-
actor_agent_id: owner,
127-
});
203+
return decisionFailure(
204+
"actor_excluded",
205+
"claiming agent is excluded from this Todo",
206+
{ actor_agent_id: owner },
207+
);
128208
}
129209
const existing = typeof todo.claimed_by === "string" && todo.claimed_by.length > 0
130210
? normalizeAgent(todo.claimed_by, "todo.claimed_by")
131211
: null;
132212
if (existing !== null && existing !== owner) {
133-
return failure("claim_owner_mismatch", "Todo is already claimed by another agent", {
134-
claim_owner: existing,
135-
});
213+
return decisionFailure(
214+
"claim_owner_mismatch",
215+
"Todo is already claimed by another agent",
216+
{ claim_owner: existing },
217+
);
136218
}
219+
const mode = registered.length <= 1 ? "single_agent_compatibility" : "registered_peer_actor";
137220
return {
221+
schema_version: COORDINATION_TODO_CLAIM_DECISION_SCHEMA,
222+
status: "accepted",
138223
owner,
139224
actor,
140-
mode: registered.length <= 1 ? "single_agent_compatibility" : "registered_peer_actor",
225+
mode,
226+
mutation_authority: {
227+
schema_version: "todo_mutation_authority_v0",
228+
command: "claim",
229+
mode,
230+
actor_agent_id: actor,
231+
todo_id: todo.todo_id,
232+
registered_agent_count: registered.length,
233+
...(mode === "registered_peer_actor" ? { claim_owner: existing } : {}),
234+
},
141235
};
142236
}
143237

@@ -269,16 +363,22 @@ export async function executeCoordinationTodoClaim(
269363
});
270364
}
271365

272-
let authority: ReturnType<typeof claimAuthority>;
366+
let authority: ReturnType<typeof evaluateCoordinationTodoClaimDecision>;
273367
try {
274-
authority = claimAuthority(todo, input);
368+
authority = evaluateCoordinationTodoClaimDecision(todo, input);
275369
} catch (error) {
276370
return failure(
277371
"invalid_coordination_todo_claim",
278372
error instanceof Error ? error.message : "invalid Todo claim",
279373
);
280374
}
281-
if (typeof authority.owner !== "string") return authority as CoordinationTodoClaimResult;
375+
if (authority.status !== "accepted" || typeof authority.owner !== "string") {
376+
return failure(
377+
typeof authority.reason_code === "string" ? authority.reason_code : "invalid_coordination_todo_claim",
378+
typeof authority.reason === "string" ? authority.reason : "Todo claim was rejected",
379+
authority,
380+
);
381+
}
282382

283383
const handoffMode = typeof head.head.handoff_mode === "string"
284384
? head.head.handoff_mode
@@ -295,17 +395,10 @@ export async function executeCoordinationTodoClaim(
295395
);
296396
}
297397

298-
const mutationAuthority = {
299-
schema_version: "todo_mutation_authority_v0",
300-
command: "claim",
301-
mode: authority.mode,
302-
actor_agent_id: authority.actor,
303-
todo_id: input.todo_id,
304-
registered_agent_count: input.registered_agents.length,
305-
...(authority.mode === "registered_peer_actor"
306-
? { claim_owner: typeof todo.claimed_by === "string" ? todo.claimed_by : null }
307-
: {}),
308-
};
398+
const mutationAuthority = canonicalAuthorityObject(
399+
authority.mutation_authority,
400+
"Todo claim mutation authority",
401+
);
309402
const updatedAt = input.now.toISOString().replace(/\.\d{3}Z$/u, "Z");
310403
const changed = todo.claimed_by !== authority.owner;
311404
const result = {

‎loopx/control_plane/effect_runtime_handlers.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ import {
120120
promoteLocalCoordinationAuthority,
121121
readLocalCoordinationTodo,
122122
} from "./coordination/local_authority_runtime.ts";
123+
import { evaluateCoordinationTodoClaimDecision } from "./coordination/todo_claim.ts";
123124
import {
124125
checkLegacyCoordinationWriteAllowed,
125126
engageLegacyCoordinationWriterFence,
@@ -350,6 +351,25 @@ export function createEffectRuntimeHandlers(
350351
["todo.completion_state.require_metadata", requireTodoCompletionMetadataValue],
351352
["todo.completion_state.continuation_for_write", selectTodoCompletionContinuation],
352353
["todo.completion_state.metadata_updates", buildTodoCompletionMetadataUpdates],
354+
[
355+
"todo.claim.decide",
356+
(params) => evaluateCoordinationTodoClaimDecision(
357+
requiredObject(params.todo, "todo"),
358+
{
359+
goal_id: requiredString(params.goal_id, "goal_id"),
360+
todo_id: requiredString(params.todo_id, "todo_id"),
361+
claimed_by: requiredString(params.claimed_by, "claimed_by"),
362+
actor_agent_id: params.actor_agent_id === null
363+
? null : requiredString(params.actor_agent_id, "actor_agent_id"),
364+
expected_role: params.expected_role === null
365+
? null : requiredString(params.expected_role, "expected_role"),
366+
registered_agents: stringArray(params.registered_agents, "registered_agents"),
367+
operation_id: "decision-only",
368+
dry_run: true,
369+
now: new Date(0),
370+
},
371+
),
372+
],
353373
["todo.completion.reduce", reduceTodoCompletionTransaction],
354374
["todo.next_action.transition", transitionTodoNextAction],
355375
["todo.resume_condition.normalize", normalizeTodoResumeWhen],

0 commit comments

Comments
 (0)