11import 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" ;
310import { tmpdir } from "node:os" ;
411import { join } from "node:path" ;
512import test from "node:test" ;
@@ -50,6 +57,12 @@ test("semantic replan guard distinguishes legacy, none, and exact selection", ()
5057
5158async 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 , / c a r r i e s n o s e t t l e m e n t b i n d i n g y e t / ) ;
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 , / r e c e i p t t o d o = t o d o _ o t h e r _ w o r k _ i t e m / ) ;
341+ assert . equal ( failure . details , undefined ) ;
342+ } ) ;
343+
265344test ( "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