@@ -512,20 +512,23 @@ export async function triggerSessionTurn(
512512 // "session exists → reuse" map: a turn that crashed before/mid dispatch must
513513 // resolve to a terminal state, never silently re-run or hang `running`.
514514 const idempotencyKey = req . options ?. idempotencyKey ?. trim ( ) ;
515- // requestHash binds the key to its business payload — a same-key retry with a
516- // different payload is a caller bug (409), not a silent join. Exclude the key
517- // itself and daemon-generated ids (session/chat); include what drives execution.
515+ // requestHash binds the key to its full business payload — a same-key retry
516+ // with a DIFFERENT payload is a caller bug (409), not a silent join. It must
517+ // cover everything that renders into the prompt / drives execution:
518+ // instruction, envelope, source, presentation, and the WHOLE options object
519+ // EXCEPT the idempotencyKey itself (that's the lookup key, not payload). Hashing
520+ // only a hand-picked subset (model/effort/suppress) silently reused a turn when
521+ // e.g. options.status firing→resolved changed the prompt but not the hash
522+ // (codex #776 round-4). No daemon-generated ids (session/chat/triggerId) are in
523+ // these inputs, so the hash is stable across retries.
524+ const { idempotencyKey : _omitKey , ...optionsForHash } = ( req . options ?? { } ) as Record < string , unknown > ;
518525 const requestHash = idempotencyKey
519526 ? computeInputHash ( {
520527 instruction : req . instruction ?? null ,
521528 envelope : req . envelope ,
522529 source : req . source ,
523530 presentation : req . presentation ?? null ,
524- options : {
525- model : req . options ?. model ?? null ,
526- reasoningEffort : req . options ?. reasoningEffort ?? null ,
527- suppressFinalOutput : req . options ?. suppressFinalOutput ?? null ,
528- } ,
531+ options : optionsForHash ,
529532 } )
530533 : '' ;
531534 const ownerBootId = getDaemonBootId ( ) ;
0 commit comments