@autopilot-harness/core 0.2.6 → 0.2.8

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,36 +250,82 @@ 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
  }
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
+ }
267
311
  // Order: E4 → E5 → residue-E2 → E3 → E0 (E2 via code_edited handled above)
268
- if (chain.confirm_left !== null && chain.confirm_left > 0) {
269
- return this.e4Confirm(session, chain);
312
+ if (chainAfterPending.confirm_left !== null &&
313
+ chainAfterPending.confirm_left > 0) {
314
+ return this.e4Confirm(session, chainAfterPending);
270
315
  }
271
- if (chain.confirm_left === 0 ||
272
- (chain.item_confirm_complete === 1 && chain.confirm_left === null)) {
273
- return this.e5Gate(session, chain);
316
+ if (chainAfterPending.confirm_left === 0 ||
317
+ (chainAfterPending.item_confirm_complete === 1 &&
318
+ chainAfterPending.confirm_left === null)) {
319
+ return this.e5Gate(session, chainAfterPending);
274
320
  }
275
321
  // Recover/abort residue on checklist executing: chain_pending cleared and
276
322
  // code_edited may be lost, but fix_round>0 means mid-item review was
277
323
  // active. Prefer another fix round over E3 confirm so prior edits are not
278
324
  // "confirmed" without a fix pass (Commerce M2 / abort-after-recover).
279
- if (chain.chain_pending === 0 &&
280
- chain.fix_round > 0 &&
281
- chain.code_edited === 0 &&
282
- chain.item_confirm_complete === 0 &&
325
+ if (chainAfterPending.chain_pending === 0 &&
326
+ chainAfterPending.fix_round > 0 &&
327
+ chainAfterPending.code_edited === 0 &&
328
+ chainAfterPending.item_confirm_complete === 0 &&
283
329
  isChecklistExecuting(session)) {
284
330
  const cid = session.conversation_id;
285
331
  const rearmed = this.store.exclusiveWrite(() => {
@@ -292,7 +338,8 @@ export class ReviewEngine {
292
338
  fresh.confirm_left !== null ||
293
339
  fresh.item_confirm_complete === 1 ||
294
340
  fresh.chain_pending === 1 ||
295
- fresh.fix_round <= 0) {
341
+ fresh.fix_round <= 0 ||
342
+ isRecoverOrStuckFollowupMessage(fresh.pending_followup?.trim() ?? "")) {
296
343
  return { commit: false, value: false };
297
344
  }
298
345
  this.store.updateReviewChain(cid, { code_edited: 1 });
@@ -304,18 +351,43 @@ export class ReviewEngine {
304
351
  const fix = this.e2Fix(session, live);
305
352
  if (fix)
306
353
  return fix;
307
- // e2Fix lost a race fall through with a refreshed chain snapshot
308
- // (do not keep using the pre-rearm `chain`, or E0 could soft-advance
309
- // while live code_edited is still 1 / or miss a live clear).
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
+ }
310
364
  }
311
365
  }
312
366
  }
313
367
  // Refresh after possible residue rearm so E3/E0 see live markers.
314
368
  const chainNow = this.store.getReviewChain(input.conversationId) ?? chain;
315
369
  // Concurrent residue/recover may have armed code_edited after the outer
316
- // snapshot — prefer E2 over E3/E0 on the live marker.
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).
317
372
  if (chainNow.code_edited === 1) {
318
- return this.e2Fix(session, chainNow);
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
+ }
319
391
  }
320
392
  // After hard halt neutralize, fix_round is cleared so bare residue cannot
321
393
  // re-arm. Checklist executing: fix_round>0 still reaches E3 even when
@@ -348,22 +420,21 @@ export class ReviewEngine {
348
420
  if (!pending)
349
421
  return false;
350
422
  if (events.length > 0 && automationFollowupPresent(events, pending)) {
351
- try {
352
- // Only clear the snapshot needle a concurrent replace must survive.
353
- const cleared = this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === pending);
354
- if (!cleared) {
355
- // Live pending was replaced (e.g. error-recover claim). Keep blocking
356
- // 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 {
357
430
  const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
358
431
  "";
359
- if (live &&
360
- !(events.length > 0 && automationFollowupPresent(events, live))) {
432
+ if (live)
361
433
  return true;
362
- }
363
434
  }
364
- }
365
- catch {
366
- /* already delivered — clearing stamp is best-effort */
435
+ catch {
436
+ /* fail-open only when chain unreadable */
437
+ }
367
438
  }
368
439
  return false;
369
440
  }
@@ -374,7 +445,19 @@ export class ReviewEngine {
374
445
  return true;
375
446
  }
376
447
  tryRedeliverPending(conversationId, chain, events, transcriptPath) {
377
- 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
+ }
378
461
  if (!pending)
379
462
  return null;
380
463
  // Without transcript_path, skip redelivery (avoid duplicate spam in unit tests).
@@ -384,15 +467,11 @@ export class ReviewEngine {
384
467
  // cannot inject during claim sleep (would race CAS for a second recover).
385
468
  // CAS emit clears that hold → host-drop redelivery is immediate again.
386
469
  if (events.length > 0 && automationFollowupPresent(events, pending)) {
387
- try {
388
- const cleared = this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === pending);
389
- // Snapshot looked delivered but live was replaced fall through to the
390
- // locked live-row path instead of dropping the new pending.
391
- if (cleared)
392
- return null;
393
- }
394
- catch {
395
- /* 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;
396
475
  }
397
476
  }
398
477
  // Apply even after a delivered-snapshot miss (replaced live pending): do not
@@ -419,7 +498,10 @@ export class ReviewEngine {
419
498
  // already present on the transcript.
420
499
  if (events.length > 0 &&
421
500
  automationFollowupPresent(events, livePending)) {
422
- 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
+ }
423
505
  return { commit: true, value: null };
424
506
  }
425
507
  try {
@@ -440,7 +522,10 @@ export class ReviewEngine {
440
522
  return { commit: true, value: null };
441
523
  }
442
524
  if (events.length > 0 && automationFollowupPresent(events, after)) {
443
- 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
+ }
444
529
  return { commit: true, value: null };
445
530
  }
446
531
  return {
@@ -508,15 +593,56 @@ export class ReviewEngine {
508
593
  * Clear sticky code_edited so Stop→revert→resend cannot open a phantom fix
509
594
  * chain on the next completed stop (disk may already be clean).
510
595
  * Leave fix/confirm pending alone (delivery retry still valid if inject raced).
511
- * 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
512
604
  * recover gone while code_edited sticky (or the reverse).
513
605
  */
514
606
  handleAbortedStop(session) {
515
607
  try {
516
608
  const cid = session.conversation_id;
609
+ const ambientFreeze = this.config.reviewScope === "project" &&
610
+ !isChecklistExecuting(session);
517
611
  this.store.exclusiveWrite(() => {
518
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;
519
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
+ }
520
646
  return { commit: true, value: undefined };
521
647
  });
522
648
  }
@@ -735,8 +861,12 @@ export class ReviewEngine {
735
861
  if (!stamp) {
736
862
  return { commit: false, value: { role: "failed" } };
737
863
  }
738
- // Recover must not keep a leftover fix chain_pending (phantom E3).
739
- 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
+ }
740
870
  // Hold completed-stop redeliver through claim sleep; CAS clears it.
741
871
  this.armRecoverClaimRedeliverHold(conversationId, debounceMs);
742
872
  return {
@@ -753,7 +883,11 @@ export class ReviewEngine {
753
883
  if (fixTipAtClaim &&
754
884
  live &&
755
885
  !this.isMidConfirmOrE5(live)) {
756
- 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
+ });
757
891
  }
758
892
  }
759
893
  else {
@@ -769,9 +903,18 @@ export class ReviewEngine {
769
903
  if (!stamp) {
770
904
  return { commit: false, value: { role: "failed" } };
771
905
  }
772
- // Executing claim skips ambient soft-reset still disarm chain_pending.
773
- // Ambient soft-reset already cleared chain_pending; clear again is idempotent.
774
- 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
+ }
775
918
  this.armRecoverClaimRedeliverHold(conversationId, debounceMs);
776
919
  return {
777
920
  commit: true,
@@ -985,8 +1128,13 @@ export class ReviewEngine {
985
1128
  if (!this.store.casBumpPendingFollowupAt(conversationId, expectedStamp)) {
986
1129
  return { commit: false, value: null };
987
1130
  }
988
- // Defense: recover emit must not leave chain_pending armed.
989
- 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
+ }
990
1138
  return {
991
1139
  commit: true,
992
1140
  value: {
@@ -1149,8 +1297,17 @@ export class ReviewEngine {
1149
1297
  if (!isRecoverFollowupMessage(live)) {
1150
1298
  return { commit: false, value: null };
1151
1299
  }
1152
- // armChain=false does not clear a leftover fix chain_pending=1 phantom E3.
1153
- this.store.clearChainPending(conversationId);
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
+ }
1154
1311
  return { commit: true, value: action };
1155
1312
  });
1156
1313
  try {
@@ -1199,7 +1356,11 @@ export class ReviewEngine {
1199
1356
  if (!isRecoverFollowupMessage(live)) {
1200
1357
  return { commit: false, value: null };
1201
1358
  }
1202
- this.store.clearChainPending(conversationId);
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
+ }
1203
1364
  return { commit: true, value: action };
1204
1365
  });
1205
1366
  }
@@ -1209,7 +1370,10 @@ export class ReviewEngine {
1209
1370
  // Legacy path: never unlocked soft-reset (confirm/fix pending desync).
1210
1371
  this.emit(conversationId, action);
1211
1372
  try {
1212
- this.store.clearChainPending(conversationId);
1373
+ const liveChain = this.store.getReviewChain(conversationId);
1374
+ if (liveChain?.code_edited !== 1) {
1375
+ this.store.clearChainPending(conversationId);
1376
+ }
1213
1377
  }
1214
1378
  catch {
1215
1379
  /* best-effort */
@@ -1319,7 +1483,8 @@ export class ReviewEngine {
1319
1483
  * do not regress to another fix. Ready-for-E3 requires empty pending (not
1320
1484
  * merely !fixPending). Force code_edited only for an active fix
1321
1485
  * (code_edited / undelivered fix pending / chain_pending with pending still
1322
- * 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).
1323
1488
  */
1324
1489
  applySoftResetAmbientChainForErrorRecover(conversationId) {
1325
1490
  this.store.ensureReviewChain(conversationId);
@@ -1357,7 +1522,10 @@ export class ReviewEngine {
1357
1522
  ? rounds
1358
1523
  : null,
1359
1524
  item_confirm_complete: atE5 ? chain.item_confirm_complete : 0,
1360
- 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,
1361
1529
  // Preserve an in-flight edit marker (E2 wins over E4); else force only for mid-fix.
1362
1530
  code_edited: resumeFix || chain.code_edited === 1 ? 1 : 0,
1363
1531
  });
@@ -1367,6 +1535,62 @@ export class ReviewEngine {
1367
1535
  const line = firstSubstantiveLine(message) || message.trim();
1368
1536
  return line.startsWith("Review fix") || line.startsWith("自审修复");
1369
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
+ }
1370
1594
  /** Mid-confirm or E5-ready — must not phantom-arm code_edited over E4/E5. */
1371
1595
  isMidConfirmOrE5(chain) {
1372
1596
  if (!chain)
@@ -1555,7 +1779,8 @@ export class ReviewEngine {
1555
1779
  fresh.confirm_left !== null ||
1556
1780
  fresh.item_confirm_complete === 1 ||
1557
1781
  fresh.chain_pending === 1 ||
1558
- fresh.fix_round > 0) {
1782
+ fresh.fix_round > 0 ||
1783
+ isRecoverOrStuckFollowupMessage(fresh.pending_followup?.trim() ?? "")) {
1559
1784
  return { commit: false, value: null };
1560
1785
  }
1561
1786
  const refreshed = this.parseSessionChecklist(lockedSession);
@@ -1676,7 +1901,8 @@ export class ReviewEngine {
1676
1901
  fresh.confirm_left !== null ||
1677
1902
  fresh.item_confirm_complete === 1 ||
1678
1903
  fresh.chain_pending === 1 ||
1679
- fresh.fix_round > 0) {
1904
+ fresh.fix_round > 0 ||
1905
+ isRecoverOrStuckFollowupMessage(fresh.pending_followup?.trim() ?? "")) {
1680
1906
  return { commit: false, value: null };
1681
1907
  }
1682
1908
  const refreshed = this.parseSessionChecklist(sess);
@@ -1820,6 +2046,11 @@ export class ReviewEngine {
1820
2046
  if (!fresh || fresh.code_edited !== 1) {
1821
2047
  return { commit: false, value: null };
1822
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
+ }
1823
2054
  const fixRound = this.nextSessionRound(fresh);
1824
2055
  const message = this.render("review.fix", { round: fixRound, total: rounds });
1825
2056
  const out = {
@@ -1920,6 +2151,11 @@ export class ReviewEngine {
1920
2151
  if (!fresh || fresh.code_edited === 1 || fresh.confirm_left !== expectedLeft) {
1921
2152
  return { commit: false, value: null };
1922
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
+ }
1923
2159
  const sessionRound = this.nextSessionRound(fresh);
1924
2160
  const message = this.render(kind, {
1925
2161
  n,
@@ -2019,6 +2255,11 @@ export class ReviewEngine {
2019
2255
  if (!atE5) {
2020
2256
  return { commit: false, value: null };
2021
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
+ }
2022
2263
  const sess = this.store.getSession(cid);
2023
2264
  if (!sess ||
2024
2265
  !isChecklistExecuting(sess)) {
@@ -2121,6 +2362,11 @@ export class ReviewEngine {
2121
2362
  if (!atE5) {
2122
2363
  return { commit: false, value: null };
2123
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
+ }
2124
2370
  const lockedSession = this.store.getSession(cid);
2125
2371
  if (!lockedSession ||
2126
2372
  !sessionReviewRunnable(lockedSession, this.config.reviewScope)) {