@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.
- package/dist/review-engine.d.ts +38 -2
- package/dist/review-engine.d.ts.map +1 -1
- package/dist/review-engine.js +484 -104
- package/dist/review-engine.js.map +1 -1
- package/dist/state-store.d.ts +7 -5
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +8 -6
- package/dist/state-store.js.map +1 -1
- package/dist/verify-report.d.ts +10 -0
- package/dist/verify-report.d.ts.map +1 -1
- package/dist/verify-report.js +65 -1
- package/dist/verify-report.js.map +1 -1
- package/package.json +1 -1
package/dist/review-engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
275
|
+
if (this.pendingBlocksAdvance(session.conversation_id, chainLive, events, transcriptPath)) {
|
|
265
276
|
return null;
|
|
266
277
|
}
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
(
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
*
|
|
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
|
|
696
|
-
|
|
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
|
-
|
|
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
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
|
735
|
-
// Ambient soft-reset
|
|
736
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
|
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.
|
|
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).
|
|
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
|
|
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)) {
|