@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.
- package/dist/migrate.d.ts.map +1 -1
- package/dist/migrate.js +2 -0
- package/dist/migrate.js.map +1 -1
- package/dist/review-engine.d.ts +28 -2
- package/dist/review-engine.d.ts.map +1 -1
- package/dist/review-engine.js +306 -60
- package/dist/review-engine.js.map +1 -1
- package/dist/state-store.d.ts +4 -3
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +5 -4
- package/dist/state-store.js.map +1 -1
- package/migrations/001_initial.sql +62 -0
- package/migrations/002_pending_followup.sql +4 -0
- package/migrations/003_reviewing_item.sql +1 -0
- package/package.json +3 -2
package/dist/review-engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
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 (
|
|
269
|
-
|
|
312
|
+
if (chainAfterPending.confirm_left !== null &&
|
|
313
|
+
chainAfterPending.confirm_left > 0) {
|
|
314
|
+
return this.e4Confirm(session, chainAfterPending);
|
|
270
315
|
}
|
|
271
|
-
if (
|
|
272
|
-
(
|
|
273
|
-
|
|
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 (
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
//
|
|
308
|
-
//
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
*
|
|
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
|
|
739
|
-
|
|
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
|
-
|
|
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
|
|
773
|
-
// Ambient soft-reset
|
|
774
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1153
|
-
|
|
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
|
-
|
|
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.
|
|
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).
|
|
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
|
|
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)) {
|