Skip to content

Commit e80f5f8

Browse files
authored
Merge pull request #4595 from huangruiteng/codex/steward-unbound-receipt-repair
2 parents 3ca8681 + 8937087 commit e80f5f8

2 files changed

Lines changed: 114 additions & 5 deletions

File tree

‎loopx/control_plane/quota/settlement_readback.ts‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -501,8 +501,17 @@ function inferPersistedIdentity(
501501
return null;
502502
}
503503

504-
function failedIdentity(reason: string, kind: "invalid_identity" | "identity_mismatch" | "receipt_missing") {
505-
return settlementFailed<JsonObject>({ kind, step_kind: "validation", reason });
504+
function failedIdentity(
505+
reason: string,
506+
kind: "invalid_identity" | "identity_mismatch" | "receipt_missing",
507+
details?: JsonObject,
508+
) {
509+
return settlementFailed<JsonObject>({
510+
kind,
511+
step_kind: "validation",
512+
reason,
513+
...(details ? { details } : {}),
514+
});
506515
}
507516

508517
function resolveIdentity(
@@ -573,6 +582,27 @@ function resolveIdentity(
573582
const receiptReplanObligationId = normalizeReplanObligationId(
574583
receiptDetails.replan_obligation_id,
575584
);
585+
if (receiptTodoId === null && receiptReplanObligationId === null) {
586+
// A same-turn guard that ran before any work item was chosen commits a
587+
// receipt with no settlement binding, and the documented wake order (guard,
588+
// then select) produces exactly that state. This read model never binds --
589+
// the guard's own same-turn reconciliation owns that, so there is one
590+
// binder rather than two -- which means the caller has to be told the state
591+
// and the exact repair instead of being handed a binding mismatch it cannot
592+
// act on.
593+
return failedIdentity(
594+
"the quota should-run receipt for this turn carries no settlement binding " +
595+
`yet (turn_instance_id ${turnInstanceId}); rebind it through the guard's ` +
596+
"same-turn reconciliation, then settle: quota should-run --turn-instance-id " +
597+
`${turnInstanceId} --todo-id ${identity.todo_id ?? "<todo_id>"}`,
598+
"identity_mismatch",
599+
{
600+
binding_kind: "unbound",
601+
requested_binding_kind: identity.binding_kind,
602+
turn_instance_id: turnInstanceId,
603+
},
604+
);
605+
}
576606
if (
577607
receiptTodoId !== identity.todo_id ||
578608
receiptReplanObligationId !== identity.replan_obligation_id

‎tests/control_plane_ts/quota_settlement_readback.test.ts‎

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import assert from "node:assert/strict";
2-
import { appendFile, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
2+
import {
3+
appendFile,
4+
mkdir,
5+
mkdtemp,
6+
readFile,
7+
rm,
8+
writeFile,
9+
} from "node:fs/promises";
310
import { tmpdir } from "node:os";
411
import { join } from "node:path";
512
import test from "node:test";
@@ -50,6 +57,12 @@ test("semantic replan guard distinguishes legacy, none, and exact selection", ()
5057

5158
async function fixture(options: {
5259
guard?: boolean;
60+
/**
61+
* Commit the same-turn guard receipt the way the documented wake order does:
62+
* the guard runs before a work item is chosen, so the receipt exists for this
63+
* turn but carries no settlement binding.
64+
*/
65+
guardUnbound?: boolean;
5366
writeback?: boolean;
5467
spend?: boolean;
5568
completion?: boolean;
@@ -73,8 +86,12 @@ async function fixture(options: {
7386
agent_id: agentId,
7487
run_id: turnId,
7588
details: {
76-
todo_id: todoId,
77-
settlement_effect_id: identity.effect_id,
89+
...(options.guardUnbound
90+
? {}
91+
: {
92+
todo_id: todoId,
93+
settlement_effect_id: identity.effect_id,
94+
}),
7895
...(options.workspace
7996
? {
8097
delivery_workspace_causality_schema_version:
@@ -262,6 +279,68 @@ test("keeps partial settlement fail-closed without losing durable facts", async
262279
assert.equal((result.writeback_run as any).delivery_outcome, "outcome_progress");
263280
});
264281

282+
test("names the unbound same-turn receipt and the repair instead of a mismatch", async () => {
283+
// The documented wake order runs the guard before any work item is chosen, so
284+
// the turn's receipt exists with no settlement binding. This read model never
285+
// binds one (the guard's same-turn reconciliation owns that, so there is one
286+
// binder), which means the caller has to be told the state and the exact
287+
// repair rather than the binding mismatch a "receipt todo=missing" message
288+
// reports.
289+
const runtimeRoot = await fixture({ guardUnbound: true });
290+
291+
const result = await readQuotaSettlement(request(runtimeRoot));
292+
293+
const failure = (result.settlement as any).result.failure;
294+
assert.equal(failure.kind, "identity_mismatch");
295+
assert.match(failure.reason, /carries no settlement binding yet/);
296+
assert.match(
297+
failure.reason,
298+
new RegExp(
299+
`quota should-run --turn-instance-id ${turnId} --todo-id ${todoId}`,
300+
),
301+
);
302+
assert.deepEqual(failure.details, {
303+
binding_kind: "unbound",
304+
requested_binding_kind: "todo",
305+
turn_instance_id: turnId,
306+
});
307+
});
308+
309+
test("still reports a receipt bound to another work item as a mismatch", async () => {
310+
// The unbound state must not swallow the case where the receipt was bound and
311+
// the caller asked for something else: that is a real conflict, and its repair
312+
// is not "bind it".
313+
const runtimeRoot = await fixture({});
314+
const eventsPath = join(
315+
runtimeRoot,
316+
"goals",
317+
goalId,
318+
"rollout-event-log.jsonl",
319+
);
320+
const events = (await readFile(eventsPath, "utf8"))
321+
.trim()
322+
.split("\n")
323+
.map((line) => JSON.parse(line));
324+
events[0].details.todo_id = "todo_other_work_item";
325+
events[0].details.settlement_effect_id = settlementIdentity({
326+
goal_id: goalId,
327+
agent_id: agentId,
328+
todo_id: "todo_other_work_item",
329+
turn_instance_id: turnId,
330+
}).effect_id;
331+
await writeFile(
332+
eventsPath,
333+
`${events.map((event) => JSON.stringify(event)).join("\n")}\n`,
334+
);
335+
336+
const result = await readQuotaSettlement(request(runtimeRoot));
337+
338+
const failure = (result.settlement as any).result.failure;
339+
assert.equal(failure.kind, "identity_mismatch");
340+
assert.match(failure.reason, /receipt todo=todo_other_work_item/);
341+
assert.equal(failure.details, undefined);
342+
});
343+
265344
test("rejects non-ENOENT settlement readback I/O failures", async (t) => {
266345
const runtimeRoot = await fixture();
267346
const indexPath = join(runtimeRoot, "goals", goalId, "runs", "index.jsonl");

0 commit comments

Comments
 (0)