@autopilot-harness/core 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -250,45 +250,160 @@ export class ReviewEngine {
250
250
  this.maybeResetErrorCountOnItemChange(session);
251
251
  const transcriptPath = input.transcriptPath?.trim() || undefined;
252
252
  const events = transcriptPath ? readTranscriptTail(transcriptPath) : [];
253
- // Code-edit fix wins over pending redelivery (marker-before-pending).
253
+ // Code-edit fix wins over pending redelivery (marker-before-pending),
254
+ // except undelivered recover/stuck — E2 must not clobber that tip
255
+ // (resumeFix sticky edit during recover claim/emit window).
254
256
  if (chain.code_edited === 1) {
255
- return this.e2Fix(session, chain);
257
+ const tip = chain.pending_followup?.trim() ?? "";
258
+ if (!isRecoverOrStuckFollowupMessage(tip)) {
259
+ const fix = this.e2Fix(session, chain);
260
+ // null: paused, or recover/stuck landed under the write lock — fall
261
+ // through so tryRedeliverPending can still inject the live tip.
262
+ if (fix)
263
+ return fix;
264
+ }
256
265
  }
257
266
  // Pending redelivery / in-flight: never advance confirm_left while prior undelivered.
258
- const redelivered = this.tryRedeliverPending(session.conversation_id, chain, events, transcriptPath);
267
+ // Refresh after possible E2 miss (recover may have landed under e2Fix lock).
268
+ const chainLive = this.store.getReviewChain(session.conversation_id) ?? chain;
269
+ const redelivered = this.tryRedeliverPending(session.conversation_id, chainLive, events, transcriptPath);
259
270
  if (redelivered)
260
271
  return redelivered;
261
272
  if (events.length > 0 && followupInFlight(events)) {
262
273
  return null;
263
274
  }
264
- if (this.pendingBlocksAdvance(session.conversation_id, chain, events, transcriptPath)) {
275
+ if (this.pendingBlocksAdvance(session.conversation_id, chainLive, events, transcriptPath)) {
265
276
  return null;
266
277
  }
267
- // Order: E4 E5 E3 E0 (E2 handled above)
268
- if (chain.confirm_left !== null && chain.confirm_left > 0) {
269
- return this.e4Confirm(session, chain);
270
- }
271
- if (chain.confirm_left === 0 ||
272
- (chain.item_confirm_complete === 1 && chain.confirm_left === null)) {
273
- return this.e5Gate(session, chain);
274
- }
275
- // loopCount alone must not re-arm after a hard halt neutralize (fix_round
276
- // cleared). Keep E8 recovery for checklist executing only: after
277
- // clearChainPending mid-fix, fix_round>0 + loopCount still reaches E3.
278
- // Do NOT use that path for ambient/planning leftover fix_round after an
279
- // error recover (armChain=false) would otherwise open a phantom confirm
280
- // chain with no new code_edited (seen with project-scope ambient).
281
- const inChain = chain.chain_pending === 1 ||
282
- (input.loopCount > 0 &&
283
- chain.fix_round > 0 &&
284
- isChecklistExecuting(session));
285
- if (chain.confirm_left === null &&
286
- chain.item_confirm_complete === 0 &&
278
+ // Refresh after redeliver/clear chainLive may still hold a tip that
279
+ // tryRedeliverPending / pendingBlocksAdvance just cleared as delivered.
280
+ const chainAfterPending = this.store.getReviewChain(session.conversation_id) ?? chainLive;
281
+ // Undelivered recover/stuck owns the tip — do not E4/E5/residue/E3/E0 over it
282
+ // (pendingBlocksAdvance fail-opens without transcript_path in unit tests).
283
+ {
284
+ const liveTip = chainAfterPending.pending_followup?.trim() ?? "";
285
+ if (isRecoverOrStuckFollowupMessage(liveTip)) {
286
+ return null;
287
+ }
288
+ }
289
+ // Early E2 may have skipped while recover/stuck owned the tip. After that
290
+ // tip is cleared, sticky code_edited must still win over E4/E5 (otherwise
291
+ // e4Confirm refuses code_edited and returns null mid-confirm stall).
292
+ if (chainAfterPending.code_edited === 1) {
293
+ const tip = chainAfterPending.pending_followup?.trim() ?? "";
294
+ if (!isRecoverOrStuckFollowupMessage(tip)) {
295
+ const fix = this.e2Fix(session, chainAfterPending);
296
+ if (fix)
297
+ return fix;
298
+ // e2Fix lost a race (recover/stuck landed under the write lock) —
299
+ // redeliver that tip instead of falling into E4 refuse-null.
300
+ const afterE2 = this.store.getReviewChain(session.conversation_id) ??
301
+ chainAfterPending;
302
+ const afterTip = afterE2.pending_followup?.trim() ?? "";
303
+ if (isRecoverOrStuckFollowupMessage(afterTip)) {
304
+ const again = this.tryRedeliverPending(session.conversation_id, afterE2, events, transcriptPath);
305
+ if (again)
306
+ return again;
307
+ return null;
308
+ }
309
+ }
310
+ }
311
+ // Order: E4 → E5 → residue-E2 → E3 → E0 (E2 via code_edited handled above)
312
+ if (chainAfterPending.confirm_left !== null &&
313
+ chainAfterPending.confirm_left > 0) {
314
+ return this.e4Confirm(session, chainAfterPending);
315
+ }
316
+ if (chainAfterPending.confirm_left === 0 ||
317
+ (chainAfterPending.item_confirm_complete === 1 &&
318
+ chainAfterPending.confirm_left === null)) {
319
+ return this.e5Gate(session, chainAfterPending);
320
+ }
321
+ // Recover/abort residue on checklist executing: chain_pending cleared and
322
+ // code_edited may be lost, but fix_round>0 means mid-item review was
323
+ // active. Prefer another fix round over E3 confirm so prior edits are not
324
+ // "confirmed" without a fix pass (Commerce M2 / abort-after-recover).
325
+ if (chainAfterPending.chain_pending === 0 &&
326
+ chainAfterPending.fix_round > 0 &&
327
+ chainAfterPending.code_edited === 0 &&
328
+ chainAfterPending.item_confirm_complete === 0 &&
329
+ isChecklistExecuting(session)) {
330
+ const cid = session.conversation_id;
331
+ const rearmed = this.store.exclusiveWrite(() => {
332
+ if (!this.sessionRunnable(cid)) {
333
+ return { commit: false, value: false };
334
+ }
335
+ const fresh = this.store.getReviewChain(cid);
336
+ if (!fresh ||
337
+ fresh.code_edited === 1 ||
338
+ fresh.confirm_left !== null ||
339
+ fresh.item_confirm_complete === 1 ||
340
+ fresh.chain_pending === 1 ||
341
+ fresh.fix_round <= 0 ||
342
+ isRecoverOrStuckFollowupMessage(fresh.pending_followup?.trim() ?? "")) {
343
+ return { commit: false, value: false };
344
+ }
345
+ this.store.updateReviewChain(cid, { code_edited: 1 });
346
+ return { commit: true, value: true };
347
+ });
348
+ if (rearmed) {
349
+ const live = this.store.getReviewChain(cid);
350
+ if (live?.code_edited === 1) {
351
+ const fix = this.e2Fix(session, live);
352
+ if (fix)
353
+ return fix;
354
+ // Same as post-gate E2: recover/stuck may have won the lock — redeliver
355
+ // instead of falling through to E3/E0 with a sticky edit marker.
356
+ const afterE2 = this.store.getReviewChain(cid) ?? live;
357
+ const afterTip = afterE2.pending_followup?.trim() ?? "";
358
+ if (isRecoverOrStuckFollowupMessage(afterTip)) {
359
+ const again = this.tryRedeliverPending(cid, afterE2, events, transcriptPath);
360
+ if (again)
361
+ return again;
362
+ return null;
363
+ }
364
+ }
365
+ }
366
+ }
367
+ // Refresh after possible residue rearm so E3/E0 see live markers.
368
+ const chainNow = this.store.getReviewChain(input.conversationId) ?? chain;
369
+ // Concurrent residue/recover may have armed code_edited after the outer
370
+ // snapshot — prefer E2 over E3/E0 on the live marker, but still refuse to
371
+ // clobber undelivered recover/stuck (same as the early gate).
372
+ if (chainNow.code_edited === 1) {
373
+ const tip = chainNow.pending_followup?.trim() ?? "";
374
+ if (isRecoverOrStuckFollowupMessage(tip)) {
375
+ const again = this.tryRedeliverPending(session.conversation_id, chainNow, events, transcriptPath);
376
+ if (again)
377
+ return again;
378
+ return null;
379
+ }
380
+ const fix = this.e2Fix(session, chainNow);
381
+ if (fix)
382
+ return fix;
383
+ const afterE2 = this.store.getReviewChain(session.conversation_id) ?? chainNow;
384
+ const afterTip = afterE2.pending_followup?.trim() ?? "";
385
+ if (isRecoverOrStuckFollowupMessage(afterTip)) {
386
+ const again = this.tryRedeliverPending(session.conversation_id, afterE2, events, transcriptPath);
387
+ if (again)
388
+ return again;
389
+ return null;
390
+ }
391
+ }
392
+ // After hard halt neutralize, fix_round is cleared so bare residue cannot
393
+ // re-arm. Checklist executing: fix_round>0 still reaches E3 even when
394
+ // chain_pending was cleared (recover armChain=false) and loopCount=0
395
+ // (Cursor often reports 0). Do NOT use fix_round alone for ambient/
396
+ // planning — leftover fix_round after recover would open a phantom
397
+ // confirm chain with no new code_edited (project-scope ambient).
398
+ const inChain = chainNow.chain_pending === 1 ||
399
+ (chainNow.fix_round > 0 && isChecklistExecuting(session));
400
+ if (chainNow.confirm_left === null &&
401
+ chainNow.item_confirm_complete === 0 &&
287
402
  inChain) {
288
- return this.e3ArmConfirm(session, chain);
403
+ return this.e3ArmConfirm(session, chainNow);
289
404
  }
290
405
  // E0': no product-code edit — checklist continue via verify / soft evidence.
291
- return this.e0NoCodeContinue(session, chain);
406
+ return this.e0NoCodeContinue(session, chainNow);
292
407
  }
293
408
  catch (err) {
294
409
  // Purge races / late id throws from upsert|updateReviewChain must not crash the hook.
@@ -305,22 +420,21 @@ export class ReviewEngine {
305
420
  if (!pending)
306
421
  return false;
307
422
  if (events.length > 0 && automationFollowupPresent(events, pending)) {
308
- try {
309
- // Only clear the snapshot needle a concurrent replace must survive.
310
- const cleared = this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === pending);
311
- if (!cleared) {
312
- // Live pending was replaced (e.g. error-recover claim). Keep blocking
313
- // so stale confirm_left cannot advance and overwrite the new pending.
423
+ // Clear + ambient E3 arm in one writer txn (crash must not leave tip
424
+ // gone with chain_pending still 0 silent ambient stall).
425
+ const cleared = this.clearMatchingPendingAndArmAmbient(conversationId, pending);
426
+ if (!cleared) {
427
+ // Needle miss, lock/arm failure (rolled back), or concurrent replace —
428
+ // any remaining live tip must block so E4 cannot clobber it.
429
+ try {
314
430
  const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
315
431
  "";
316
- if (live &&
317
- !(events.length > 0 && automationFollowupPresent(events, live))) {
432
+ if (live)
318
433
  return true;
319
- }
320
434
  }
321
- }
322
- catch {
323
- /* already delivered — clearing stamp is best-effort */
435
+ catch {
436
+ /* fail-open only when chain unreadable */
437
+ }
324
438
  }
325
439
  return false;
326
440
  }
@@ -331,7 +445,19 @@ export class ReviewEngine {
331
445
  return true;
332
446
  }
333
447
  tryRedeliverPending(conversationId, chain, events, transcriptPath) {
334
- const pending = chain.pending_followup?.trim();
448
+ // Prefer live pending outer snapshot may predate a concurrent recover claim
449
+ // (E2 TOCTOU fall-through must still redeliver the tip that won the lock).
450
+ let pending = chain.pending_followup?.trim() ?? "";
451
+ if (!pending) {
452
+ try {
453
+ pending =
454
+ this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
455
+ "";
456
+ }
457
+ catch {
458
+ pending = "";
459
+ }
460
+ }
335
461
  if (!pending)
336
462
  return null;
337
463
  // Without transcript_path, skip redelivery (avoid duplicate spam in unit tests).
@@ -341,15 +467,11 @@ export class ReviewEngine {
341
467
  // cannot inject during claim sleep (would race CAS for a second recover).
342
468
  // CAS emit clears that hold → host-drop redelivery is immediate again.
343
469
  if (events.length > 0 && automationFollowupPresent(events, pending)) {
344
- try {
345
- const cleared = this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === pending);
346
- // Snapshot looked delivered but live was replaced fall through to the
347
- // locked live-row path instead of dropping the new pending.
348
- if (cleared)
349
- return null;
350
- }
351
- catch {
352
- /* already delivered — clearing stamp is best-effort */
470
+ const cleared = this.clearMatchingPendingAndArmAmbient(conversationId, pending);
471
+ // Snapshot looked delivered but live was replaced / arm rolled back —
472
+ // fall through to the locked live-row path instead of dropping the tip.
473
+ if (cleared) {
474
+ return null;
353
475
  }
354
476
  }
355
477
  // Apply even after a delivered-snapshot miss (replaced live pending): do not
@@ -376,7 +498,10 @@ export class ReviewEngine {
376
498
  // already present on the transcript.
377
499
  if (events.length > 0 &&
378
500
  automationFollowupPresent(events, livePending)) {
379
- this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === livePending);
501
+ const cleared = this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === livePending);
502
+ if (cleared) {
503
+ this.armAmbientChainAfterDeliveredFixTip(conversationId, livePending);
504
+ }
380
505
  return { commit: true, value: null };
381
506
  }
382
507
  try {
@@ -397,7 +522,10 @@ export class ReviewEngine {
397
522
  return { commit: true, value: null };
398
523
  }
399
524
  if (events.length > 0 && automationFollowupPresent(events, after)) {
400
- this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === after);
525
+ const cleared = this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === after);
526
+ if (cleared) {
527
+ this.armAmbientChainAfterDeliveredFixTip(conversationId, after);
528
+ }
401
529
  return { commit: true, value: null };
402
530
  }
403
531
  return {
@@ -465,15 +593,56 @@ export class ReviewEngine {
465
593
  * Clear sticky code_edited so Stop→revert→resend cannot open a phantom fix
466
594
  * chain on the next completed stop (disk may already be clean).
467
595
  * Leave fix/confirm pending alone (delivery retry still valid if inject raced).
468
- * Both clears share one exclusiveWrite so a mid-abort failure cannot leave
596
+ *
597
+ * Project-scope ambient/planning: after recover soft-reset cleared
598
+ * chain_pending, sticky code_edited may be the only handoff marker. Clearing
599
+ * it without a freeze leaves fix_round residue that cannot E3 (bare fix_round
600
+ * must not phantom-confirm). Freeze into confirm_left, or re-arm chain_pending
601
+ * when an undelivered fix tip remains. Checklist executing keeps residue-E2.
602
+ *
603
+ * Clears + freeze share one exclusiveWrite so a mid-abort failure cannot leave
469
604
  * recover gone while code_edited sticky (or the reverse).
470
605
  */
471
606
  handleAbortedStop(session) {
472
607
  try {
473
608
  const cid = session.conversation_id;
609
+ const ambientFreeze = this.config.reviewScope === "project" &&
610
+ !isChecklistExecuting(session);
474
611
  this.store.exclusiveWrite(() => {
475
612
  this.store.clearPendingFollowupIf(cid, isRecoverOrStuckFollowupMessage);
613
+ const chain = this.store.getReviewChain(cid);
614
+ const pending = chain?.pending_followup?.trim() ?? "";
615
+ const fixPending = this.isFixFollowupMessage(pending);
616
+ const mid = this.isMidConfirmOrE5(chain);
617
+ const fixRound = chain?.fix_round ?? 0;
618
+ const wasArmed = chain?.chain_pending === 1;
476
619
  this.store.clearCodeEdited(cid);
620
+ if (ambientFreeze && !mid && fixRound > 0) {
621
+ if (fixPending) {
622
+ // Tip still undelivered: keep it; ensure chain_pending so a later
623
+ // clean completion can E3 (soft-reset may have cleared it).
624
+ this.store.updateReviewChain(cid, { chain_pending: 1 });
625
+ }
626
+ else if (!pending && wasArmed) {
627
+ // Armed chain was the handoff — freeze into confirm (Stop = pause
628
+ // progress, not open another auto fix). Do NOT key off hadEdit alone:
629
+ // stale fix_round + a fresh afterFileEdit would phantom-confirm.
630
+ // Mid-fix recover soft-reset keeps chain_pending when resumeFix.
631
+ // Require empty pending so a custom tip is not left desynced under a
632
+ // new confirm_left (recover/stuck already cleared above).
633
+ const rounds = this.config.confirmRounds;
634
+ if (rounds > 0) {
635
+ this.store.updateReviewChain(cid, {
636
+ confirm_left: rounds,
637
+ chain_pending: 0,
638
+ code_edited: 0,
639
+ });
640
+ }
641
+ else {
642
+ this.store.updateReviewChain(cid, { chain_pending: 1 });
643
+ }
644
+ }
645
+ }
477
646
  return { commit: true, value: undefined };
478
647
  });
479
648
  }
@@ -692,8 +861,12 @@ export class ReviewEngine {
692
861
  if (!stamp) {
693
862
  return { commit: false, value: { role: "failed" } };
694
863
  }
695
- // Recover must not keep a leftover fix chain_pending (phantom E3).
696
- this.store.clearChainPending(conversationId);
864
+ // Recover must not keep bare leftover chain_pending (phantom E3).
865
+ // Preserve mid-fix ambient resumeFix (code_edited sticky + armed).
866
+ const liveRedeliver = this.store.getReviewChain(conversationId);
867
+ if (liveRedeliver?.code_edited !== 1) {
868
+ this.store.clearChainPending(conversationId);
869
+ }
697
870
  // Hold completed-stop redeliver through claim sleep; CAS clears it.
698
871
  this.armRecoverClaimRedeliverHold(conversationId, debounceMs);
699
872
  return {
@@ -710,19 +883,18 @@ export class ReviewEngine {
710
883
  if (fixTipAtClaim &&
711
884
  live &&
712
885
  !this.isMidConfirmOrE5(live)) {
713
- this.store.updateReviewChain(conversationId, { code_edited: 1 });
886
+ // Keep chain_pending with sticky edit so abort wasArmed can freeze.
887
+ this.store.updateReviewChain(conversationId, {
888
+ code_edited: 1,
889
+ chain_pending: 1,
890
+ });
714
891
  }
715
892
  }
716
893
  else {
717
- // Executing skips ambient soft-reset; re-arm fix for dead fix tip /
718
- // pending, but not while confirm_left says mid-confirm or E5-ready.
719
- const resumeFix = !this.isMidConfirmOrE5(chain) &&
720
- (this.isFixFollowupMessage(pending) ||
721
- chain?.code_edited === 1 ||
722
- fixTipAtClaim);
723
- if (resumeFix) {
724
- this.store.updateReviewChain(conversationId, { code_edited: 1 });
725
- }
894
+ // Executing skips ambient soft-reset same readyForE3 / resumeFix rules.
895
+ this.applyExecutingErrorRecoverChainAdjustments(conversationId, {
896
+ fixTipAtClaim,
897
+ });
726
898
  }
727
899
  this.store.savePendingFollowup(conversationId, message, {
728
900
  armChain: false,
@@ -731,9 +903,18 @@ export class ReviewEngine {
731
903
  if (!stamp) {
732
904
  return { commit: false, value: { role: "failed" } };
733
905
  }
734
- // Executing claim skips ambient soft-reset still disarm chain_pending.
735
- // Ambient soft-reset already cleared chain_pending; clear again is idempotent.
736
- this.store.clearChainPending(conversationId);
906
+ // Executing: always disarm leftover fix chain_pending (phantom E3).
907
+ // Ambient: soft-reset / fixTipAtClaim may keep chain_pending with
908
+ // code_edited for abort handoff — do not wipe that.
909
+ if (!ambient) {
910
+ this.store.clearChainPending(conversationId);
911
+ }
912
+ else {
913
+ const liveAfter = this.store.getReviewChain(conversationId);
914
+ if (liveAfter?.code_edited !== 1) {
915
+ this.store.clearChainPending(conversationId);
916
+ }
917
+ }
737
918
  this.armRecoverClaimRedeliverHold(conversationId, debounceMs);
738
919
  return {
739
920
  commit: true,
@@ -947,8 +1128,13 @@ export class ReviewEngine {
947
1128
  if (!this.store.casBumpPendingFollowupAt(conversationId, expectedStamp)) {
948
1129
  return { commit: false, value: null };
949
1130
  }
950
- // Defense: recover emit must not leave chain_pending armed.
951
- this.store.clearChainPending(conversationId);
1131
+ // Defense: recover emit must not leave bare chain_pending armed (phantom
1132
+ // E3). Mid-fix ambient resume keeps code_edited + chain_pending for
1133
+ // abort wasArmed handoff — preserve that pair.
1134
+ const afterBump = this.store.getReviewChain(conversationId);
1135
+ if (afterBump?.code_edited !== 1) {
1136
+ this.store.clearChainPending(conversationId);
1137
+ }
952
1138
  return {
953
1139
  commit: true,
954
1140
  value: {
@@ -1084,34 +1270,48 @@ export class ReviewEngine {
1084
1270
  */
1085
1271
  emitRecoverAfterFailedClaim(conversationId, action, opts) {
1086
1272
  const ambientSoftReset = opts?.ambientSoftReset === true;
1087
- try {
1088
- return this.store.exclusiveWrite(() => {
1089
- const session = this.store.getSession(conversationId);
1090
- if (!session || !this.sessionErrorRecoverable(session)) {
1091
- return { commit: false, value: null };
1092
- }
1093
- const pending = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
1094
- "";
1095
- if (isRecoverFollowupMessage(pending)) {
1096
- return { commit: false, value: null };
1097
- }
1098
- if (ambientSoftReset) {
1099
- this.applySoftResetAmbientChainForErrorRecover(conversationId);
1100
- }
1101
- this.store.savePendingFollowup(conversationId, action.message, {
1102
- armChain: false,
1103
- });
1104
- const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
1105
- "";
1106
- // Under lock, require a stamped recover row — silent savePending no-op
1107
- // must not return action (two compensators could both "win").
1108
- if (!isRecoverFollowupMessage(live)) {
1109
- return { commit: false, value: null };
1110
- }
1111
- // armChain=false does not clear a leftover fix chain_pending=1 → phantom E3.
1112
- this.store.clearChainPending(conversationId);
1113
- return { commit: true, value: action };
1273
+ const writeRecover = () => this.store.exclusiveWrite(() => {
1274
+ const session = this.store.getSession(conversationId);
1275
+ if (!session || !this.sessionErrorRecoverable(session)) {
1276
+ return { commit: false, value: null };
1277
+ }
1278
+ const pending = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
1279
+ "";
1280
+ if (isRecoverFollowupMessage(pending)) {
1281
+ return { commit: false, value: null };
1282
+ }
1283
+ if (ambientSoftReset) {
1284
+ this.applySoftResetAmbientChainForErrorRecover(conversationId);
1285
+ }
1286
+ else {
1287
+ // Executing compensate: same readyForE3 / resumeFix as successful claim.
1288
+ this.applyExecutingErrorRecoverChainAdjustments(conversationId);
1289
+ }
1290
+ this.store.savePendingFollowup(conversationId, action.message, {
1291
+ armChain: false,
1114
1292
  });
1293
+ const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
1294
+ "";
1295
+ // Under lock, require a stamped recover row — silent savePending no-op
1296
+ // must not return action (two compensators could both "win").
1297
+ if (!isRecoverFollowupMessage(live)) {
1298
+ return { commit: false, value: null };
1299
+ }
1300
+ // Executing: disarm leftover fix chain_pending. Ambient soft-reset may
1301
+ // keep chain_pending with resumeFix sticky edit for abort handoff.
1302
+ if (!ambientSoftReset) {
1303
+ this.store.clearChainPending(conversationId);
1304
+ }
1305
+ else {
1306
+ const liveChain = this.store.getReviewChain(conversationId);
1307
+ if (liveChain?.code_edited !== 1) {
1308
+ this.store.clearChainPending(conversationId);
1309
+ }
1310
+ }
1311
+ return { commit: true, value: action };
1312
+ });
1313
+ try {
1314
+ return writeRecover();
1115
1315
  }
1116
1316
  catch {
1117
1317
  if (!this.sessionStillErrorRecoverable(conversationId)) {
@@ -1125,12 +1325,55 @@ export class ReviewEngine {
1125
1325
  }
1126
1326
  }
1127
1327
  catch {
1128
- /* fall through to legacy emit */
1328
+ /* fall through */
1329
+ }
1330
+ // One full locked retry (adjust + stamp + clear). Do NOT adjust in a
1331
+ // separate txn then unlocked-emit: that can leave confirm_left/code_edited
1332
+ // armed without a recover stamp if emit/savePending no-ops.
1333
+ try {
1334
+ return writeRecover();
1335
+ }
1336
+ catch {
1337
+ /* fall through */
1338
+ }
1339
+ // Locked stamp+clear without chain adjust (degraded). Closes the gap where
1340
+ // unlocked emit leaves chain_pending=1 alongside recover pending for E3.
1341
+ try {
1342
+ return this.store.exclusiveWrite(() => {
1343
+ if (!this.sessionStillErrorRecoverable(conversationId)) {
1344
+ return { commit: false, value: null };
1345
+ }
1346
+ const pending = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
1347
+ "";
1348
+ if (isRecoverFollowupMessage(pending)) {
1349
+ return { commit: false, value: null };
1350
+ }
1351
+ this.store.savePendingFollowup(conversationId, action.message, {
1352
+ armChain: false,
1353
+ });
1354
+ const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
1355
+ "";
1356
+ if (!isRecoverFollowupMessage(live)) {
1357
+ return { commit: false, value: null };
1358
+ }
1359
+ // Degraded: no soft-reset — only disarm bare leftover chain_pending.
1360
+ const liveChain = this.store.getReviewChain(conversationId);
1361
+ if (liveChain?.code_edited !== 1) {
1362
+ this.store.clearChainPending(conversationId);
1363
+ }
1364
+ return { commit: true, value: action };
1365
+ });
1366
+ }
1367
+ catch {
1368
+ /* fall through to legacy unlocked emit */
1129
1369
  }
1130
1370
  // Legacy path: never unlocked soft-reset (confirm/fix pending desync).
1131
1371
  this.emit(conversationId, action);
1132
1372
  try {
1133
- this.store.clearChainPending(conversationId);
1373
+ const liveChain = this.store.getReviewChain(conversationId);
1374
+ if (liveChain?.code_edited !== 1) {
1375
+ this.store.clearChainPending(conversationId);
1376
+ }
1134
1377
  }
1135
1378
  catch {
1136
1379
  /* best-effort */
@@ -1186,6 +1429,46 @@ export class ReviewEngine {
1186
1429
  return "failed";
1187
1430
  }
1188
1431
  }
1432
+ /**
1433
+ * Executing error-recover chain adjust (call under exclusiveWrite, before
1434
+ * savePendingFollowup + clearChainPending). Mirrors ambient readyForE3 /
1435
+ * resumeFix without soft-resetting mid-confirm/E5.
1436
+ *
1437
+ * Ready-for-E3 (delivered fix, chain_pending only, empty pending) arms
1438
+ * confirm_left=confirmRounds so post-recover continues E4 — not another E2
1439
+ * solely from fix_round>0. Mid-fix residue still forces code_edited.
1440
+ */
1441
+ applyExecutingErrorRecoverChainAdjustments(conversationId, opts) {
1442
+ this.store.ensureReviewChain(conversationId);
1443
+ const chain = this.store.getReviewChain(conversationId);
1444
+ if (!chain)
1445
+ return;
1446
+ // Never reshape under an already-stamped recover row (peer / re-entry).
1447
+ if (isRecoverFollowupMessage(chain.pending_followup?.trim() ?? "")) {
1448
+ return;
1449
+ }
1450
+ const pending = chain.pending_followup?.trim() ?? "";
1451
+ const readyForE3 = !this.isMidConfirmOrE5(chain) &&
1452
+ chain.chain_pending === 1 &&
1453
+ chain.code_edited === 0 &&
1454
+ pending.length === 0;
1455
+ const rounds = this.config.confirmRounds;
1456
+ if (readyForE3 && rounds > 0) {
1457
+ this.store.updateReviewChain(conversationId, {
1458
+ confirm_left: rounds,
1459
+ code_edited: 0,
1460
+ });
1461
+ return;
1462
+ }
1463
+ const resumeFix = !this.isMidConfirmOrE5(chain) &&
1464
+ (this.isFixFollowupMessage(pending) ||
1465
+ chain.code_edited === 1 ||
1466
+ opts?.fixTipAtClaim === true ||
1467
+ chain.fix_round > 0);
1468
+ if (resumeFix) {
1469
+ this.store.updateReviewChain(conversationId, { code_edited: 1 });
1470
+ }
1471
+ }
1189
1472
  /**
1190
1473
  * Ambient/planning error recover: drop confirm/pending arming so recover
1191
1474
  * (armChain=false) cannot leave chain_pending=1 → phantom E3.
@@ -1200,7 +1483,8 @@ export class ReviewEngine {
1200
1483
  * do not regress to another fix. Ready-for-E3 requires empty pending (not
1201
1484
  * merely !fixPending). Force code_edited only for an active fix
1202
1485
  * (code_edited / undelivered fix pending / chain_pending with pending still
1203
- * set). Bare leftover fix_round is ignored (ambient phantom residue).
1486
+ * set). Mid-fix resumeFix keeps chain_pending=1 so abort can freeze via
1487
+ * wasArmed; bare leftover fix_round stays disarmed (ambient phantom residue).
1204
1488
  */
1205
1489
  applySoftResetAmbientChainForErrorRecover(conversationId) {
1206
1490
  this.store.ensureReviewChain(conversationId);
@@ -1238,7 +1522,10 @@ export class ReviewEngine {
1238
1522
  ? rounds
1239
1523
  : null,
1240
1524
  item_confirm_complete: atE5 ? chain.item_confirm_complete : 0,
1241
- chain_pending: 0,
1525
+ // Mid-fix resume keeps chain_pending so abort clearing code_edited still
1526
+ // has wasArmed handoff (freeze into confirm). Ready-for-E3 / idle residue
1527
+ // stay 0 — bare fix_round must not phantom-E3 after recover tip clears.
1528
+ chain_pending: resumeFix ? 1 : 0,
1242
1529
  // Preserve an in-flight edit marker (E2 wins over E4); else force only for mid-fix.
1243
1530
  code_edited: resumeFix || chain.code_edited === 1 ? 1 : 0,
1244
1531
  });
@@ -1248,6 +1535,62 @@ export class ReviewEngine {
1248
1535
  const line = firstSubstantiveLine(message) || message.trim();
1249
1536
  return line.startsWith("Review fix") || line.startsWith("自审修复");
1250
1537
  }
1538
+ /**
1539
+ * Unlocked callers: clear snapshot needle + ambient E3 arm in one
1540
+ * exclusiveWrite. A crash mid-pair must not leave tip cleared with
1541
+ * chain_pending still 0 (silent ambient stall). Safe under an open writer
1542
+ * only via {@link armAmbientChainAfterDeliveredFixTip} after an inline clear
1543
+ * (clearPendingFollowupIf runs in-txn when writeDepth > 0).
1544
+ */
1545
+ clearMatchingPendingAndArmAmbient(conversationId, pending) {
1546
+ try {
1547
+ return this.store.exclusiveWrite(() => {
1548
+ const cleared = this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === pending);
1549
+ if (cleared) {
1550
+ // Do not swallow: arm failure must ROLLBACK the clear in this txn.
1551
+ this.armAmbientChainAfterDeliveredFixTip(conversationId, pending);
1552
+ }
1553
+ return { commit: true, value: cleared };
1554
+ });
1555
+ }
1556
+ catch {
1557
+ // Nested lock / SQLITE_BUSY / arm throw after rollback — caller treats
1558
+ // false like a needle miss (re-check live tip / fall through).
1559
+ return false;
1560
+ }
1561
+ }
1562
+ /**
1563
+ * After a delivered ambient fix tip is cleared, recover/abort may have left
1564
+ * chain_pending=0. Re-arm so this completed stop can E3 → confirm instead of
1565
+ * silent stall. Never arms bare stale fix_round without a delivered fix tip
1566
+ * (keeps F-ERR-AMBIENT-STALE-FIX-ROUND / F-ABORT-NO-PHANTOM-FIX).
1567
+ * Call under the same writer txn as the clear when possible — throws must
1568
+ * propagate so the clear can roll back. Still arms when code_edited=1 so a
1569
+ * concurrent abort clearing sticky edit keeps wasArmed handoff.
1570
+ */
1571
+ armAmbientChainAfterDeliveredFixTip(conversationId, deliveredPending) {
1572
+ if (!this.isFixFollowupMessage(deliveredPending))
1573
+ return;
1574
+ if (this.config.reviewScope !== "project")
1575
+ return;
1576
+ const session = this.store.getSession(conversationId);
1577
+ if (!session || isChecklistExecuting(session))
1578
+ return;
1579
+ const chain = this.store.getReviewChain(conversationId);
1580
+ if (!chain || chain.fix_round <= 0)
1581
+ return;
1582
+ if (this.isMidConfirmOrE5(chain))
1583
+ return;
1584
+ // Already armed — idempotent. Do NOT skip when code_edited=1: sticky edit
1585
+ // alone is not enough if abort clears it before same-stop E2 (wasArmed
1586
+ // freeze needs chain_pending).
1587
+ if (chain.chain_pending === 1)
1588
+ return;
1589
+ const live = chain.pending_followup?.trim() ?? "";
1590
+ if (live)
1591
+ return;
1592
+ this.store.updateReviewChain(conversationId, { chain_pending: 1 });
1593
+ }
1251
1594
  /** Mid-confirm or E5-ready — must not phantom-arm code_edited over E4/E5. */
1252
1595
  isMidConfirmOrE5(chain) {
1253
1596
  if (!chain)
@@ -1311,7 +1654,10 @@ export class ReviewEngine {
1311
1654
  if (chain.code_edited === 1 ||
1312
1655
  chain.confirm_left !== null ||
1313
1656
  chain.item_confirm_complete === 1 ||
1314
- chain.chain_pending === 1) {
1657
+ chain.chain_pending === 1 ||
1658
+ // Mid-item review residue (e.g. recover cleared chain_pending): never
1659
+ // soft-advance/done — caller should have taken residue-E2 or E3.
1660
+ chain.fix_round > 0) {
1315
1661
  return null;
1316
1662
  }
1317
1663
  const parsed = this.parseSessionChecklist(session);
@@ -1359,6 +1705,7 @@ export class ReviewEngine {
1359
1705
  reportPath,
1360
1706
  currentItemId: liveId,
1361
1707
  projectRoot: trustRoot ?? undefined,
1708
+ notBefore: liveChain?.updated_at,
1362
1709
  })) {
1363
1710
  return null;
1364
1711
  }
@@ -1431,7 +1778,9 @@ export class ReviewEngine {
1431
1778
  fresh.code_edited === 1 ||
1432
1779
  fresh.confirm_left !== null ||
1433
1780
  fresh.item_confirm_complete === 1 ||
1434
- fresh.chain_pending === 1) {
1781
+ fresh.chain_pending === 1 ||
1782
+ fresh.fix_round > 0 ||
1783
+ isRecoverOrStuckFollowupMessage(fresh.pending_followup?.trim() ?? "")) {
1435
1784
  return { commit: false, value: null };
1436
1785
  }
1437
1786
  const refreshed = this.parseSessionChecklist(lockedSession);
@@ -1449,6 +1798,7 @@ export class ReviewEngine {
1449
1798
  reportPath,
1450
1799
  currentItemId: targets.current.id,
1451
1800
  projectRoot: trustRoot ?? undefined,
1801
+ notBefore: fresh.updated_at,
1452
1802
  })) {
1453
1803
  return { commit: false, value: null };
1454
1804
  }
@@ -1550,7 +1900,9 @@ export class ReviewEngine {
1550
1900
  fresh.code_edited === 1 ||
1551
1901
  fresh.confirm_left !== null ||
1552
1902
  fresh.item_confirm_complete === 1 ||
1553
- fresh.chain_pending === 1) {
1903
+ fresh.chain_pending === 1 ||
1904
+ fresh.fix_round > 0 ||
1905
+ isRecoverOrStuckFollowupMessage(fresh.pending_followup?.trim() ?? "")) {
1554
1906
  return { commit: false, value: null };
1555
1907
  }
1556
1908
  const refreshed = this.parseSessionChecklist(sess);
@@ -1573,6 +1925,7 @@ export class ReviewEngine {
1573
1925
  reportPath,
1574
1926
  currentItemId: lockedItem.id,
1575
1927
  projectRoot: trustRoot ?? undefined,
1928
+ notBefore: fresh.updated_at,
1576
1929
  })) {
1577
1930
  return { commit: false, value: null };
1578
1931
  }
@@ -1636,7 +1989,8 @@ export class ReviewEngine {
1636
1989
  fresh.code_edited === 1 ||
1637
1990
  fresh.confirm_left !== null ||
1638
1991
  fresh.item_confirm_complete === 1 ||
1639
- fresh.chain_pending === 1) {
1992
+ fresh.chain_pending === 1 ||
1993
+ fresh.fix_round > 0) {
1640
1994
  return { commit: false, value: false };
1641
1995
  }
1642
1996
  this.store.updateReviewChain(conversationId, {
@@ -1692,6 +2046,11 @@ export class ReviewEngine {
1692
2046
  if (!fresh || fresh.code_edited !== 1) {
1693
2047
  return { commit: false, value: null };
1694
2048
  }
2049
+ // TOCTOU: recover/stuck may land after the outer completed-stop gate.
2050
+ const pendingLive = fresh.pending_followup?.trim() ?? "";
2051
+ if (isRecoverOrStuckFollowupMessage(pendingLive)) {
2052
+ return { commit: false, value: null };
2053
+ }
1695
2054
  const fixRound = this.nextSessionRound(fresh);
1696
2055
  const message = this.render("review.fix", { round: fixRound, total: rounds });
1697
2056
  const out = {
@@ -1729,10 +2088,16 @@ export class ReviewEngine {
1729
2088
  }
1730
2089
  const fresh = this.store.getReviewChain(cid);
1731
2090
  // Lost the race to another stop (already confirming, at E5, or code edit).
2091
+ // Refuse undelivered fix/recover/stuck pending: unlocked emit can leave
2092
+ // chain_pending=1 with recover stamped until clearChainPending runs —
2093
+ // E3 must not clobber that recover with confirm.
2094
+ const pendingLive = fresh?.pending_followup?.trim() ?? "";
1732
2095
  if (!fresh ||
1733
2096
  fresh.code_edited === 1 ||
1734
2097
  fresh.confirm_left !== null ||
1735
- fresh.item_confirm_complete === 1) {
2098
+ fresh.item_confirm_complete === 1 ||
2099
+ this.isFixFollowupMessage(pendingLive) ||
2100
+ isRecoverOrStuckFollowupMessage(pendingLive)) {
1736
2101
  return { commit: false, value: null };
1737
2102
  }
1738
2103
  const sessionRound = this.nextSessionRound(fresh);
@@ -1786,6 +2151,11 @@ export class ReviewEngine {
1786
2151
  if (!fresh || fresh.code_edited === 1 || fresh.confirm_left !== expectedLeft) {
1787
2152
  return { commit: false, value: null };
1788
2153
  }
2154
+ // Do not clobber undelivered recover/stuck (same as E2/E3 gates).
2155
+ const pendingLive = fresh.pending_followup?.trim() ?? "";
2156
+ if (isRecoverOrStuckFollowupMessage(pendingLive)) {
2157
+ return { commit: false, value: null };
2158
+ }
1789
2159
  const sessionRound = this.nextSessionRound(fresh);
1790
2160
  const message = this.render(kind, {
1791
2161
  n,
@@ -1885,6 +2255,11 @@ export class ReviewEngine {
1885
2255
  if (!atE5) {
1886
2256
  return { commit: false, value: null };
1887
2257
  }
2258
+ // Do not clobber undelivered recover/stuck (same as E2/E3/E4/E5b).
2259
+ const pendingLive = fresh.pending_followup?.trim() ?? "";
2260
+ if (isRecoverOrStuckFollowupMessage(pendingLive)) {
2261
+ return { commit: false, value: null };
2262
+ }
1888
2263
  const sess = this.store.getSession(cid);
1889
2264
  if (!sess ||
1890
2265
  !isChecklistExecuting(sess)) {
@@ -1987,6 +2362,11 @@ export class ReviewEngine {
1987
2362
  if (!atE5) {
1988
2363
  return { commit: false, value: null };
1989
2364
  }
2365
+ // Do not clobber undelivered recover/stuck (same as E2/E3/E4).
2366
+ const pendingLive = fresh.pending_followup?.trim() ?? "";
2367
+ if (isRecoverOrStuckFollowupMessage(pendingLive)) {
2368
+ return { commit: false, value: null };
2369
+ }
1990
2370
  const lockedSession = this.store.getSession(cid);
1991
2371
  if (!lockedSession ||
1992
2372
  !sessionReviewRunnable(lockedSession, this.config.reviewScope)) {