@autopilot-harness/core 0.1.0
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/LICENSE +21 -0
- package/README.md +8 -0
- package/dist/autopilot-ignore.d.ts +23 -0
- package/dist/autopilot-ignore.d.ts.map +1 -0
- package/dist/autopilot-ignore.js +271 -0
- package/dist/autopilot-ignore.js.map +1 -0
- package/dist/checklist-md.d.ts +62 -0
- package/dist/checklist-md.d.ts.map +1 -0
- package/dist/checklist-md.js +217 -0
- package/dist/checklist-md.js.map +1 -0
- package/dist/code-edit-detector.d.ts +10 -0
- package/dist/code-edit-detector.d.ts.map +1 -0
- package/dist/code-edit-detector.js +44 -0
- package/dist/code-edit-detector.js.map +1 -0
- package/dist/i18n-render.d.ts +3 -0
- package/dist/i18n-render.d.ts.map +1 -0
- package/dist/i18n-render.js +8 -0
- package/dist/i18n-render.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/list-tracks.d.ts +29 -0
- package/dist/list-tracks.d.ts.map +1 -0
- package/dist/list-tracks.js +232 -0
- package/dist/list-tracks.js.map +1 -0
- package/dist/migrate.d.ts +19 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +101 -0
- package/dist/migrate.js.map +1 -0
- package/dist/phase-actions.d.ts +35 -0
- package/dist/phase-actions.d.ts.map +1 -0
- package/dist/phase-actions.js +480 -0
- package/dist/phase-actions.js.map +1 -0
- package/dist/project-config.d.ts +29 -0
- package/dist/project-config.d.ts.map +1 -0
- package/dist/project-config.js +301 -0
- package/dist/project-config.js.map +1 -0
- package/dist/project-path.d.ts +26 -0
- package/dist/project-path.d.ts.map +1 -0
- package/dist/project-path.js +104 -0
- package/dist/project-path.js.map +1 -0
- package/dist/review-engine.d.ts +263 -0
- package/dist/review-engine.d.ts.map +1 -0
- package/dist/review-engine.js +2273 -0
- package/dist/review-engine.js.map +1 -0
- package/dist/review-i18n.d.ts +29 -0
- package/dist/review-i18n.d.ts.map +1 -0
- package/dist/review-i18n.js +54 -0
- package/dist/review-i18n.js.map +1 -0
- package/dist/review-lenses.d.ts +13 -0
- package/dist/review-lenses.d.ts.map +1 -0
- package/dist/review-lenses.js +43 -0
- package/dist/review-lenses.js.map +1 -0
- package/dist/review-runtime.d.ts +12 -0
- package/dist/review-runtime.d.ts.map +1 -0
- package/dist/review-runtime.js +37 -0
- package/dist/review-runtime.js.map +1 -0
- package/dist/review-scope.d.ts +9 -0
- package/dist/review-scope.d.ts.map +1 -0
- package/dist/review-scope.js +92 -0
- package/dist/review-scope.js.map +1 -0
- package/dist/sqlite.d.ts +15 -0
- package/dist/sqlite.d.ts.map +1 -0
- package/dist/sqlite.js +38 -0
- package/dist/sqlite.js.map +1 -0
- package/dist/state-store.d.ts +277 -0
- package/dist/state-store.d.ts.map +1 -0
- package/dist/state-store.js +1091 -0
- package/dist/state-store.js.map +1 -0
- package/dist/track-slug.d.ts +4 -0
- package/dist/track-slug.d.ts.map +1 -0
- package/dist/track-slug.js +18 -0
- package/dist/track-slug.js.map +1 -0
- package/dist/transcript-followup.d.ts +59 -0
- package/dist/transcript-followup.d.ts.map +1 -0
- package/dist/transcript-followup.js +310 -0
- package/dist/transcript-followup.js.map +1 -0
- package/dist/trigger-parser.d.ts +54 -0
- package/dist/trigger-parser.d.ts.map +1 -0
- package/dist/trigger-parser.js +250 -0
- package/dist/trigger-parser.js.map +1 -0
- package/dist/verify-report.d.ts +52 -0
- package/dist/verify-report.d.ts.map +1 -0
- package/dist/verify-report.js +195 -0
- package/dist/verify-report.js.map +1 -0
- package/package.json +41 -0
|
@@ -0,0 +1,2273 @@
|
|
|
1
|
+
import { countUnchecked, effectiveReviewingItemId, firstUnchecked, parseAdvanceNextItemId, parseChecklist, resolveAdvanceTargets, secondUnchecked, } from "./checklist-md.js";
|
|
2
|
+
import { isRealpathInsideProject, normalizeProjectRoot } from "./project-path.js";
|
|
3
|
+
import { getLens } from "./review-lenses.js";
|
|
4
|
+
import { ensureAmbientReviewSession, isChecklistExecuting, sessionReviewRunnable, } from "./review-scope.js";
|
|
5
|
+
import { sanitizeSessionDisplayText, } from "./state-store.js";
|
|
6
|
+
import { isSafeTrackSlug } from "./track-slug.js";
|
|
7
|
+
import { automationFollowupPresent, BRIEFLY_PREFIX, followupInFlight, harnessTipBeforeTrailingTurnEnded, inFlightUserQuery, inRecoverDebounceWindow, latestUnresolvedTurnEndedErrorIndex, PENDING_REDELIVER_COOLDOWN_MS, pendingRedeliverAllowed, RECOVER_DEBOUNCE_MS, sleepSyncMs, readTranscriptTail, transcriptTipIsAssistant, userQueryText, } from "./transcript-followup.js";
|
|
8
|
+
import { firstSubstantiveLine, isRecoverFollowupMessage, isRecoverOrStuckFollowupMessage, substantivePromptBody, } from "./trigger-parser.js";
|
|
9
|
+
import { defaultVerifyReportPath, evaluateVerifyReport, hasNoCodeCompletionEvidence, } from "./verify-report.js";
|
|
10
|
+
function defaultRender(kind, vars) {
|
|
11
|
+
switch (kind) {
|
|
12
|
+
case "review.fix":
|
|
13
|
+
return (`Review fix round ${vars.round} (no hard cap; confirm needs ${vars.total} consecutive no-edit rounds). ` +
|
|
14
|
+
`Code changed this turn. Defect-first self-review and fix now: ` +
|
|
15
|
+
`1) when using git diff / git status, skip paths matching .autopilotignore, and skip untracked paths ignored by .gitignore; review only the remaining paths; ` +
|
|
16
|
+
`2) check correctness, null/boundaries, concurrency, security, regression, missing tests; ` +
|
|
17
|
+
`3) CRITICAL/HIGH must fix, MEDIUM preferably; ` +
|
|
18
|
+
`4) run relevant tests; ` +
|
|
19
|
+
`5) briefly state what you reviewed and changed (or "self-review clean"). ` +
|
|
20
|
+
`Do not commit/push. If no further code changes, next stop enters multi-lens confirm.`);
|
|
21
|
+
case "review.confirm":
|
|
22
|
+
return (`Review confirm ${vars.n}/${vars.total} (session round ${vars.sessionRound}; consecutive no-edit confirms, counted on the fix-round counter). ` +
|
|
23
|
+
`Lens 【${vars.lensTitle}】 (multi-lens confirm, not the same checklist again). ${vars.lensFocus} ` +
|
|
24
|
+
`Previous turn had no further code edits. Recheck under this lens only: ` +
|
|
25
|
+
`1) git diff / git status — no new edits vs prior turn (or only already-reviewed edits); likewise skip paths matching .autopilotignore and untracked paths ignored by .gitignore; judge only from remaining paths; ` +
|
|
26
|
+
`2) dig into this lens only; ban vague "fully rechecked, all good"; ` +
|
|
27
|
+
`3) CRITICAL/HIGH under this lens must fix; MEDIUM preferably; ` +
|
|
28
|
+
`4) if you edit, fix and run related tests; ` +
|
|
29
|
+
`5) close with: "Lens (${vars.lensTitle}): self-review clean" or a short list of fixes; if clean, do not edit further. ` +
|
|
30
|
+
`Do not commit/push.`);
|
|
31
|
+
case "review.confirm_final":
|
|
32
|
+
return (`Review confirm ${vars.n}/${vars.total} (session round ${vars.sessionRound}; consecutive no-edit confirms, counted on the fix-round counter). ` +
|
|
33
|
+
`Lens 【${vars.lensTitle}】 (multi-lens confirm, not the same checklist again). ${vars.lensFocus} ` +
|
|
34
|
+
`Previous turn had no further code edits. Recheck under this lens only: ` +
|
|
35
|
+
`1) git diff / git status — no new edits vs prior turn (or only already-reviewed edits); likewise skip paths matching .autopilotignore and untracked paths ignored by .gitignore; judge only from remaining paths; ` +
|
|
36
|
+
`2) dig into this lens only; ban vague "fully rechecked, all good"; ` +
|
|
37
|
+
`3) read-only: record CRITICAL/HIGH/missing tests — do not change code, add tests, or commit; if you already edited, accept returning to a fix round; never commit this turn; ` +
|
|
38
|
+
`4) do not run commands that mutate the repo; ` +
|
|
39
|
+
`5) close with: "Lens (${vars.lensTitle}): self-review clean" or list issues (no fixes); if clean, do not edit further. ` +
|
|
40
|
+
`Do not commit/push. Handoff (commit) and next checklist item are handled by Advance/Done after the chain — not this turn.`);
|
|
41
|
+
case "advance":
|
|
42
|
+
return (`Advance checklist: confirm chain passed cleanly (confirm rounds do not commit). ` +
|
|
43
|
+
`First mark the completed current item ${vars.currentId ?? ""} [x] in checklist.md ` +
|
|
44
|
+
`(do not mark the next item yet). ` +
|
|
45
|
+
`Never mark an item [x] while you are still implementing it — only this Advance/Done followup checks off the completed item. ` +
|
|
46
|
+
`Then, if the working tree still has uncommitted changes for this item ` +
|
|
47
|
+
`(including checklist.md when plans/ is committed), local conventional commit only: ` +
|
|
48
|
+
`git status/diff → stage only this checklist item's paths; never git add -A, never stage .env/secrets/.autopilot runtime; ` +
|
|
49
|
+
`one conventional commit; no push/--no-verify/amend/force unless the user explicitly asks. ` +
|
|
50
|
+
`If already clean after marking, skip commit. Then implement next: ${vars.nextId ?? ""} — ${vars.nextTitle ?? ""}.`);
|
|
51
|
+
case "done":
|
|
52
|
+
return (`All checklist items done. Confirm chain passed (confirm rounds do not commit). ` +
|
|
53
|
+
`Mark the last item [x]. If the working tree still has uncommitted changes for this item ` +
|
|
54
|
+
`(including checklist.md when plans/ is committed), local conventional commit only ` +
|
|
55
|
+
`(never stage .env/secrets/.autopilot runtime; no push unless the user asks); if clean, just confirm briefly. Phase is done.`);
|
|
56
|
+
case "recover":
|
|
57
|
+
return `Recover: the previous turn ended with an error. Continue the current task.`;
|
|
58
|
+
case "recover_planning":
|
|
59
|
+
return `Recover: the previous turn ended with an error. Continue planning; do not RUN or write product code.`;
|
|
60
|
+
case "recover_ambient":
|
|
61
|
+
return `Recover: the previous turn ended with an error. Continue your current work; Autopilot RUN is not active.`;
|
|
62
|
+
case "review_complete":
|
|
63
|
+
return (`Review complete. All ${vars.total ?? 5} confirm rounds passed; the review chain has ended. ` +
|
|
64
|
+
`If the working tree still has uncommitted changes from this session, ` +
|
|
65
|
+
`local commit only per the safe checklist (never stage .env/secrets/.autopilot runtime; ` +
|
|
66
|
+
`no push unless the user asks); if clean, briefly confirm only.`);
|
|
67
|
+
case "stuck":
|
|
68
|
+
return `Stuck: no progress for several stops. Change strategy or send Autopilot RESUME after fixing.`;
|
|
69
|
+
case "verify_fix":
|
|
70
|
+
return `Verify failed (${vars.reason ?? "unknown"}). Fix verify commands and rewrite verify-last.json; do not advance.`;
|
|
71
|
+
default:
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export class ReviewEngine {
|
|
76
|
+
store;
|
|
77
|
+
config;
|
|
78
|
+
constructor(store, config) {
|
|
79
|
+
this.store = store;
|
|
80
|
+
this.config = config;
|
|
81
|
+
}
|
|
82
|
+
render(kind, vars) {
|
|
83
|
+
return (this.config.renderFollowup ?? defaultRender)(kind, vars);
|
|
84
|
+
}
|
|
85
|
+
lens(roundIndex) {
|
|
86
|
+
const rounds = this.config.confirmRounds;
|
|
87
|
+
return (this.config.resolveLens ?? getLens)(roundIndex, rounds);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* FS trust root: StateStore is authoritative.
|
|
91
|
+
* config.projectRoot must not widen the boundary (mismatched/evil config).
|
|
92
|
+
*/
|
|
93
|
+
trustedProjectRoot() {
|
|
94
|
+
return (normalizeProjectRoot(this.store.projectRoot) ??
|
|
95
|
+
normalizeProjectRoot(this.config.projectRoot));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse session checklist only when realpath stays under the project root.
|
|
99
|
+
* O_NOFOLLOW alone cannot stop intermediate directory symlink escapes or a
|
|
100
|
+
* poisoned absolute checklist_path in the session row.
|
|
101
|
+
*/
|
|
102
|
+
parseSessionChecklist(session) {
|
|
103
|
+
const checklistPath = session.checklist_path;
|
|
104
|
+
if (!checklistPath)
|
|
105
|
+
return null;
|
|
106
|
+
// Trust store root only — never let config or session.project_root
|
|
107
|
+
// redefine the containment boundary.
|
|
108
|
+
const root = this.trustedProjectRoot();
|
|
109
|
+
if (!root || !isRealpathInsideProject(root, checklistPath)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const cl = parseChecklist(checklistPath, { projectRoot: root });
|
|
114
|
+
return {
|
|
115
|
+
unchecked: countUnchecked(cl),
|
|
116
|
+
currentItem: firstUnchecked(cl),
|
|
117
|
+
followingItem: secondUnchecked(cl),
|
|
118
|
+
checklist: cl,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
reviewChainResetFields() {
|
|
126
|
+
return {
|
|
127
|
+
confirm_left: null,
|
|
128
|
+
fix_round: 0,
|
|
129
|
+
code_edited: 0,
|
|
130
|
+
item_confirm_complete: 0,
|
|
131
|
+
reviewing_item_id: null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
renderAdvanceOrDone(targets) {
|
|
135
|
+
const isAdvance = targets.next != null;
|
|
136
|
+
const message = isAdvance
|
|
137
|
+
? this.render("advance", {
|
|
138
|
+
currentId: targets.current?.id ?? "",
|
|
139
|
+
currentTitle: targets.current?.title ?? "",
|
|
140
|
+
nextId: targets.next?.id ?? "",
|
|
141
|
+
nextTitle: targets.next?.title ?? "",
|
|
142
|
+
})
|
|
143
|
+
: this.render("done", {});
|
|
144
|
+
return {
|
|
145
|
+
kind: isAdvance ? "advance" : "done",
|
|
146
|
+
message,
|
|
147
|
+
loop: true,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** Resolve sticky reviewing id from chain, or fall back to hint / firstUnchecked. */
|
|
151
|
+
resolveReviewingItemId(chain, checklist, hintItemId) {
|
|
152
|
+
const sticky = chain?.reviewing_item_id?.trim() || "";
|
|
153
|
+
if (sticky) {
|
|
154
|
+
if (!checklist)
|
|
155
|
+
return sticky;
|
|
156
|
+
const usable = effectiveReviewingItemId(checklist, sticky);
|
|
157
|
+
if (usable)
|
|
158
|
+
return usable;
|
|
159
|
+
// Sticky points past an earlier open item (advance seeded next too early).
|
|
160
|
+
}
|
|
161
|
+
const hint = (hintItemId ?? "").trim();
|
|
162
|
+
if (hint) {
|
|
163
|
+
if (!checklist)
|
|
164
|
+
return hint;
|
|
165
|
+
const usableHint = effectiveReviewingItemId(checklist, hint);
|
|
166
|
+
if (usableHint)
|
|
167
|
+
return usableHint;
|
|
168
|
+
}
|
|
169
|
+
if (!checklist)
|
|
170
|
+
return null;
|
|
171
|
+
return firstUnchecked(checklist)?.id ?? null;
|
|
172
|
+
}
|
|
173
|
+
/** E1: afterFileEdit product code → code_edited=1 (+ sticky reviewing_item_id). */
|
|
174
|
+
onCodeEdited(conversationId) {
|
|
175
|
+
// Checklist FS read stays outside the write lock; only pending/sticky arming
|
|
176
|
+
// uses the live chain row under the lock (neutralize/advance TOCTOU).
|
|
177
|
+
const session = this.store.getSession(conversationId);
|
|
178
|
+
const parsed = session ? this.parseSessionChecklist(session) : null;
|
|
179
|
+
this.store.markCodeEdited(conversationId, (chain) => {
|
|
180
|
+
const fromPending = parseAdvanceNextItemId(chain.pending_followup);
|
|
181
|
+
if (parsed?.checklist) {
|
|
182
|
+
if (fromPending &&
|
|
183
|
+
effectiveReviewingItemId(parsed.checklist, fromPending)) {
|
|
184
|
+
return fromPending;
|
|
185
|
+
}
|
|
186
|
+
return parsed.currentItem?.id ?? null;
|
|
187
|
+
}
|
|
188
|
+
return fromPending;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
handleStop(input) {
|
|
192
|
+
// Corrupt/legacy ids must not reach error-stop upserts (or ensure).
|
|
193
|
+
if (!this.store.isConversationIdOk(input.conversationId)) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
let session = this.store.getSession(input.conversationId);
|
|
198
|
+
// Host sometimes writes turn_ended status=error into the transcript but
|
|
199
|
+
// delivers stop as completed (e.g. usage-limit ActionRequiredError). Treat
|
|
200
|
+
// that orphan transcript error like a real error stop for recover — unless
|
|
201
|
+
// a recover tip is already unanswered (return null; do not bump error_count).
|
|
202
|
+
// One transcript snapshot for classify — avoids split-read TOCTOU.
|
|
203
|
+
const orphanClass = input.status === "completed"
|
|
204
|
+
? this.classifyCompletedOrphan(input.transcriptPath)
|
|
205
|
+
: "none";
|
|
206
|
+
// User Stop / host abort must not bootstrap ambient sessions or recover.
|
|
207
|
+
// Genuine error stops — and completed stops with orphan transcript errors —
|
|
208
|
+
// may ensure + inject recover.
|
|
209
|
+
if (!session &&
|
|
210
|
+
(input.status === "error" || orphanClass === "salvage") &&
|
|
211
|
+
this.config.reviewScope === "project") {
|
|
212
|
+
ensureAmbientReviewSession(this.store, input.conversationId, this.config.projectRoot, this.config.reviewScope);
|
|
213
|
+
session = this.store.getSession(input.conversationId);
|
|
214
|
+
}
|
|
215
|
+
if (!session)
|
|
216
|
+
return null;
|
|
217
|
+
if (input.status === "aborted") {
|
|
218
|
+
return this.handleAbortedStop(session);
|
|
219
|
+
}
|
|
220
|
+
if (input.status === "error") {
|
|
221
|
+
return this.handleErrorStop(session, input);
|
|
222
|
+
}
|
|
223
|
+
// Orphan transcript error on completed → same path as status=error.
|
|
224
|
+
// Unanswered recover tip: return null (do not re-enter handleErrorStop).
|
|
225
|
+
// Non-recoverable sessions skip salvage; completed below is gated by
|
|
226
|
+
// sessionReviewRunnable (paused/disarmed usually return null before
|
|
227
|
+
// pending redelivery).
|
|
228
|
+
if (orphanClass === "skip_recover_tip") {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
if (orphanClass === "salvage" &&
|
|
232
|
+
this.sessionErrorRecoverable(session)) {
|
|
233
|
+
return this.handleErrorStop(session, {
|
|
234
|
+
...input,
|
|
235
|
+
status: "error",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
// Precondition: review chain may run (executing_only → RUN; project → ambient/planning/executing)
|
|
239
|
+
if (!sessionReviewRunnable(session, this.config.reviewScope)) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
const chain = this.store.ensureReviewChain(input.conversationId);
|
|
243
|
+
this.maybeResetErrorCountOnItemChange(session);
|
|
244
|
+
const transcriptPath = input.transcriptPath?.trim() || undefined;
|
|
245
|
+
const events = transcriptPath ? readTranscriptTail(transcriptPath) : [];
|
|
246
|
+
// Code-edit fix wins over pending redelivery (marker-before-pending).
|
|
247
|
+
if (chain.code_edited === 1) {
|
|
248
|
+
return this.e2Fix(session, chain);
|
|
249
|
+
}
|
|
250
|
+
// Pending redelivery / in-flight: never advance confirm_left while prior undelivered.
|
|
251
|
+
const redelivered = this.tryRedeliverPending(session.conversation_id, chain, events, transcriptPath);
|
|
252
|
+
if (redelivered)
|
|
253
|
+
return redelivered;
|
|
254
|
+
if (events.length > 0 && followupInFlight(events)) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
if (this.pendingBlocksAdvance(session.conversation_id, chain, events, transcriptPath)) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
// Order: E4 → E5 → E3 → E0 (E2 handled above)
|
|
261
|
+
if (chain.confirm_left !== null && chain.confirm_left > 0) {
|
|
262
|
+
return this.e4Confirm(session, chain);
|
|
263
|
+
}
|
|
264
|
+
if (chain.confirm_left === 0 ||
|
|
265
|
+
(chain.item_confirm_complete === 1 && chain.confirm_left === null)) {
|
|
266
|
+
return this.e5Gate(session, chain);
|
|
267
|
+
}
|
|
268
|
+
// loopCount alone must not re-arm after a hard halt neutralize (fix_round
|
|
269
|
+
// cleared). Keep E8 recovery for checklist executing only: after
|
|
270
|
+
// clearChainPending mid-fix, fix_round>0 + loopCount still reaches E3.
|
|
271
|
+
// Do NOT use that path for ambient/planning — leftover fix_round after an
|
|
272
|
+
// error recover (armChain=false) would otherwise open a phantom confirm
|
|
273
|
+
// chain with no new code_edited (seen with project-scope ambient).
|
|
274
|
+
const inChain = chain.chain_pending === 1 ||
|
|
275
|
+
(input.loopCount > 0 &&
|
|
276
|
+
chain.fix_round > 0 &&
|
|
277
|
+
isChecklistExecuting(session));
|
|
278
|
+
if (chain.confirm_left === null &&
|
|
279
|
+
chain.item_confirm_complete === 0 &&
|
|
280
|
+
inChain) {
|
|
281
|
+
return this.e3ArmConfirm(session, chain);
|
|
282
|
+
}
|
|
283
|
+
// E0': no product-code edit — checklist continue via verify / soft evidence.
|
|
284
|
+
return this.e0NoCodeContinue(session, chain);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
// Purge races / late id throws from upsert|updateReviewChain must not crash the hook.
|
|
288
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
289
|
+
if (msg.includes("No session for conversation") ||
|
|
290
|
+
msg.includes("Invalid conversation id")) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
pendingBlocksAdvance(conversationId, chain, events, transcriptPath) {
|
|
297
|
+
const pending = chain.pending_followup?.trim();
|
|
298
|
+
if (!pending)
|
|
299
|
+
return false;
|
|
300
|
+
if (events.length > 0 && automationFollowupPresent(events, pending)) {
|
|
301
|
+
try {
|
|
302
|
+
// Only clear the snapshot needle — a concurrent replace must survive.
|
|
303
|
+
const cleared = this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === pending);
|
|
304
|
+
if (!cleared) {
|
|
305
|
+
// Live pending was replaced (e.g. error-recover claim). Keep blocking
|
|
306
|
+
// so stale confirm_left cannot advance and overwrite the new pending.
|
|
307
|
+
const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
|
|
308
|
+
"";
|
|
309
|
+
if (live &&
|
|
310
|
+
!(events.length > 0 && automationFollowupPresent(events, live))) {
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
/* already delivered — clearing stamp is best-effort */
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
// No transcript_path → unit tests / older ports: fail-open.
|
|
321
|
+
if (!transcriptPath)
|
|
322
|
+
return false;
|
|
323
|
+
// Path provided: undelivered pending always blocks (even if transcript empty).
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
tryRedeliverPending(conversationId, chain, events, transcriptPath) {
|
|
327
|
+
const pending = chain.pending_followup?.trim();
|
|
328
|
+
if (!pending)
|
|
329
|
+
return null;
|
|
330
|
+
// Without transcript_path, skip redelivery (avoid duplicate spam in unit tests).
|
|
331
|
+
if (!transcriptPath)
|
|
332
|
+
return null;
|
|
333
|
+
// Recover claim arms pending_redeliver_at for ~debounceMs so completed-stop
|
|
334
|
+
// cannot inject during claim sleep (would race CAS for a second recover).
|
|
335
|
+
// CAS emit clears that hold → host-drop redelivery is immediate again.
|
|
336
|
+
if (events.length > 0 && automationFollowupPresent(events, pending)) {
|
|
337
|
+
try {
|
|
338
|
+
const cleared = this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === pending);
|
|
339
|
+
// Snapshot looked delivered but live was replaced — fall through to the
|
|
340
|
+
// locked live-row path instead of dropping the new pending.
|
|
341
|
+
if (cleared)
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
/* already delivered — clearing stamp is best-effort */
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Apply even after a delivered-snapshot miss (replaced live pending): do not
|
|
349
|
+
// inject while another harness followup still owns the transcript tip.
|
|
350
|
+
if (events.length > 0 && followupInFlight(events)) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
// Cooldown + touch under one write lock so we never honor a stale
|
|
354
|
+
// chain.pending_redeliver_at snapshot from before a concurrent recover claim.
|
|
355
|
+
try {
|
|
356
|
+
return this.store.exclusiveWrite(() => {
|
|
357
|
+
if (!this.sessionRunnable(conversationId)) {
|
|
358
|
+
return { commit: false, value: null };
|
|
359
|
+
}
|
|
360
|
+
const liveRow = this.store.getReviewChain(conversationId);
|
|
361
|
+
const livePending = liveRow?.pending_followup?.trim() ?? "";
|
|
362
|
+
if (!livePending) {
|
|
363
|
+
return { commit: false, value: null };
|
|
364
|
+
}
|
|
365
|
+
if (!pendingRedeliverAllowed(liveRow?.pending_redeliver_at ?? null)) {
|
|
366
|
+
return { commit: false, value: null };
|
|
367
|
+
}
|
|
368
|
+
// Live row may differ from the outer snapshot — do not re-inject a tip
|
|
369
|
+
// already present on the transcript.
|
|
370
|
+
if (events.length > 0 &&
|
|
371
|
+
automationFollowupPresent(events, livePending)) {
|
|
372
|
+
this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === livePending);
|
|
373
|
+
return { commit: true, value: null };
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
this.store.touchPendingRedeliver(conversationId);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// Still attempt redeliver — cooldown stamp is best-effort.
|
|
380
|
+
}
|
|
381
|
+
// Commit after touch: a nested clear/neutralize (or concurrent-looking
|
|
382
|
+
// mock) must not be rolled back by commit:false, or the outer stop
|
|
383
|
+
// would keep a stale confirm_left and skip-a-lens / re-emit.
|
|
384
|
+
if (!this.sessionRunnable(conversationId)) {
|
|
385
|
+
return { commit: true, value: null };
|
|
386
|
+
}
|
|
387
|
+
const after = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
|
|
388
|
+
"";
|
|
389
|
+
if (!after) {
|
|
390
|
+
return { commit: true, value: null };
|
|
391
|
+
}
|
|
392
|
+
if (events.length > 0 && automationFollowupPresent(events, after)) {
|
|
393
|
+
this.store.clearPendingFollowupIf(conversationId, (m) => m.trim() === after);
|
|
394
|
+
return { commit: true, value: null };
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
commit: true,
|
|
398
|
+
value: {
|
|
399
|
+
kind: this.inferPendingKind(after),
|
|
400
|
+
message: after,
|
|
401
|
+
loop: true,
|
|
402
|
+
meta: { redeliver: true },
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
inferPendingKind(message) {
|
|
412
|
+
const m = message.trim();
|
|
413
|
+
if (this.isFixFollowupMessage(m))
|
|
414
|
+
return "review.fix";
|
|
415
|
+
if ((m.startsWith("Review confirm") || m.startsWith("自审确认")) &&
|
|
416
|
+
(m.includes(`/${this.config.confirmRounds}`) || m.includes("终审") || m.includes("Read-only") || m.includes("只读"))) {
|
|
417
|
+
// Prefer final when n===total appears as N/N and N is last — heuristic via confirmRounds.
|
|
418
|
+
const finalRe = new RegExp(`(?:Review confirm|自审确认)\\s*${this.config.confirmRounds}/${this.config.confirmRounds}`);
|
|
419
|
+
if (finalRe.test(m) || m.includes("read-only") || m.includes("Read-only") || m.includes("只读终审") || m.includes("本轮只读")) {
|
|
420
|
+
return "review.confirm_final";
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (m.startsWith("Review confirm") || m.startsWith("自审确认"))
|
|
424
|
+
return "review.confirm";
|
|
425
|
+
if (m.startsWith("Advance") || m.startsWith("推进"))
|
|
426
|
+
return "advance";
|
|
427
|
+
if (m.startsWith("Review complete") || m.startsWith("自审完成"))
|
|
428
|
+
return "review_complete";
|
|
429
|
+
if (m.startsWith("All checklist") || m.startsWith("全部完成"))
|
|
430
|
+
return "done";
|
|
431
|
+
if (m.startsWith("Recover") || m.startsWith("恢复"))
|
|
432
|
+
return "recover";
|
|
433
|
+
if (m.startsWith("Stuck") || m.startsWith("卡住"))
|
|
434
|
+
return "stuck";
|
|
435
|
+
if (m.startsWith("Verify failed") || m.startsWith("校验失败"))
|
|
436
|
+
return "verify_fix";
|
|
437
|
+
return "review.confirm";
|
|
438
|
+
}
|
|
439
|
+
emit(conversationId, action) {
|
|
440
|
+
try {
|
|
441
|
+
const armChain = action.kind !== "recover" &&
|
|
442
|
+
action.kind !== "recover_planning" &&
|
|
443
|
+
action.kind !== "recover_ambient";
|
|
444
|
+
this.store.savePendingFollowup(conversationId, action.message, { armChain });
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// Hook can still deliver once; pending redelivery is best-effort.
|
|
448
|
+
}
|
|
449
|
+
return action;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Cursor Stop button (and similar host interrupts) arrive as status=aborted.
|
|
453
|
+
* Do not inject recover — that fights the user and loops with loop_limit:null.
|
|
454
|
+
* Drop recover/stuck pending so transcript revert cannot redeliver them later.
|
|
455
|
+
* Clear sticky code_edited so Stop→revert→resend cannot open a phantom fix
|
|
456
|
+
* chain on the next completed stop (disk may already be clean).
|
|
457
|
+
* Leave fix/confirm pending alone (delivery retry still valid if inject raced).
|
|
458
|
+
* Both clears share one exclusiveWrite so a mid-abort failure cannot leave
|
|
459
|
+
* recover gone while code_edited sticky (or the reverse).
|
|
460
|
+
*/
|
|
461
|
+
handleAbortedStop(session) {
|
|
462
|
+
try {
|
|
463
|
+
const cid = session.conversation_id;
|
|
464
|
+
this.store.exclusiveWrite(() => {
|
|
465
|
+
this.store.clearPendingFollowupIf(cid, isRecoverOrStuckFollowupMessage);
|
|
466
|
+
this.store.clearCodeEdited(cid);
|
|
467
|
+
return { commit: true, value: undefined };
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
/* best-effort */
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
handleErrorStop(session, input) {
|
|
476
|
+
const nextCount = session.error_count + 1;
|
|
477
|
+
const maxErrors = this.config.maxErrorsBeforePause;
|
|
478
|
+
// maxErrors <= 0 → unlimited: never pause for repeated_errors
|
|
479
|
+
const shouldPause = maxErrors > 0 && nextCount >= maxErrors;
|
|
480
|
+
try {
|
|
481
|
+
if (shouldPause) {
|
|
482
|
+
this.store.upsertSession({
|
|
483
|
+
conversation_id: session.conversation_id,
|
|
484
|
+
project_root: session.project_root,
|
|
485
|
+
code_root: session.code_root,
|
|
486
|
+
error_count: nextCount,
|
|
487
|
+
last_error: input.status,
|
|
488
|
+
paused: 1,
|
|
489
|
+
paused_reason: "repeated_errors",
|
|
490
|
+
armed: 0,
|
|
491
|
+
});
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
this.store.upsertSession({
|
|
495
|
+
conversation_id: session.conversation_id,
|
|
496
|
+
project_root: session.project_root,
|
|
497
|
+
code_root: session.code_root,
|
|
498
|
+
error_count: nextCount,
|
|
499
|
+
last_error: input.status,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// Count/pause bookkeeping is best-effort.
|
|
504
|
+
if (shouldPause) {
|
|
505
|
+
// Pause row write failed but threshold was hit — do not silently keep
|
|
506
|
+
// looping armed; surface stuck so the hook can halt the agent.
|
|
507
|
+
if (session.armed === 1 &&
|
|
508
|
+
session.phase === "executing" &&
|
|
509
|
+
session.paused === 0) {
|
|
510
|
+
// One-shot halt text — do NOT savePendingFollowup (emit): a later
|
|
511
|
+
// redeliver path always uses loop:true and would spin while still armed.
|
|
512
|
+
// Prefer one IMMEDIATE txn for pause+neutralize+disarm (no partial dirty
|
|
513
|
+
// state). Do NOT catch per-step inside the txn — a failed statement
|
|
514
|
+
// poisons SQLite's open transaction. On any failure, ROLLBACK then
|
|
515
|
+
// fall back to independent best-effort writes.
|
|
516
|
+
try {
|
|
517
|
+
this.store.exclusiveWrite(() => {
|
|
518
|
+
this.store.pauseSessionForRepeatedErrors(session.conversation_id, nextCount, input.status);
|
|
519
|
+
this.store.neutralizeReviewChain(session.conversation_id);
|
|
520
|
+
this.store.disarmSession(session.conversation_id);
|
|
521
|
+
return { commit: true, value: undefined };
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
try {
|
|
526
|
+
this.store.pauseSessionForRepeatedErrors(session.conversation_id, nextCount, input.status);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
/* best-effort */
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
this.store.neutralizeReviewChain(session.conversation_id);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
/* best-effort */
|
|
536
|
+
}
|
|
537
|
+
try {
|
|
538
|
+
this.store.disarmSession(session.conversation_id);
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
/* best-effort */
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
kind: "stuck",
|
|
546
|
+
message: this.render("stuck", {}),
|
|
547
|
+
loop: false,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// Re-read: concurrent halt may have paused after our snapshot was taken.
|
|
554
|
+
const fresh = this.store.getSession(session.conversation_id);
|
|
555
|
+
if (fresh && this.sessionErrorRecoverable(fresh)) {
|
|
556
|
+
return this.debouncedErrorRecover(fresh, input);
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
resolveRecoverDebounceMs() {
|
|
561
|
+
const n = this.config.recoverDebounceMs;
|
|
562
|
+
if (n === undefined)
|
|
563
|
+
return RECOVER_DEBOUNCE_MS;
|
|
564
|
+
if (!Number.isFinite(n) || n < 0)
|
|
565
|
+
return RECOVER_DEBOUNCE_MS;
|
|
566
|
+
return Math.min(Math.floor(n), 60_000);
|
|
567
|
+
}
|
|
568
|
+
sleepRecoverDebounce(ms) {
|
|
569
|
+
try {
|
|
570
|
+
(this.config.sleepSync ?? sleepSyncMs)(ms);
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
// Sleep is best-effort; claim/coalesce still hold. Always continue to finish.
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Error recover: claim under exclusiveWrite → sleep debounce → emit at most
|
|
578
|
+
* once per window. Concurrent error stops in the same window coalesce to null.
|
|
579
|
+
* Outside the window, if still dead, redeliver once (new window).
|
|
580
|
+
* Emit ownership is CAS'd on `pending_followup_at` so claimer+redeliver
|
|
581
|
+
* cannot both return followup_message after overlapping sleeps.
|
|
582
|
+
*/
|
|
583
|
+
debouncedErrorRecover(session, input) {
|
|
584
|
+
const recoverKind = this.recoverKindForPhase(session.phase);
|
|
585
|
+
const message = this.render(recoverKind, {}).trim();
|
|
586
|
+
// Blank/NUL must not claim: ambient soft-reset would wipe the chain with
|
|
587
|
+
// nothing redeliverable (savePendingFollowup no-ops on empty/NUL).
|
|
588
|
+
if (!message || message.includes("\0")) {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
const action = {
|
|
592
|
+
kind: "recover",
|
|
593
|
+
message,
|
|
594
|
+
loop: true,
|
|
595
|
+
};
|
|
596
|
+
const cid = session.conversation_id;
|
|
597
|
+
const ambient = !isChecklistExecuting(session);
|
|
598
|
+
const debounceMs = this.resolveRecoverDebounceMs();
|
|
599
|
+
const transcriptPath = input.transcriptPath?.trim() || undefined;
|
|
600
|
+
// Genuine error stop: always attempt recover claim. A dead non-recover
|
|
601
|
+
// harness tip (fix / confirm / Briefly) at claim time must still inject —
|
|
602
|
+
// suppressing left the session stuck with no followup. Recover tip falls
|
|
603
|
+
// through so coalesce/CAS can dedupe (finish suppresses double-inject when
|
|
604
|
+
// recover is already the unanswered tip).
|
|
605
|
+
let recoverAnsweredAtClaim = false;
|
|
606
|
+
/**
|
|
607
|
+
* Unanswered non-recover harness tip text at claim time (if any). Finish
|
|
608
|
+
* may emit over the *same* dead tip; a *different* harness tip that landed
|
|
609
|
+
* during sleep must not get a stacked recover inject.
|
|
610
|
+
*/
|
|
611
|
+
let deadHarnessTipAtClaim = null;
|
|
612
|
+
/** Claim-time tip was a fix followup (re-arm even if pending was cleared). */
|
|
613
|
+
let fixTipAtClaim = false;
|
|
614
|
+
if (transcriptPath) {
|
|
615
|
+
const tipEvents = readTranscriptTail(transcriptPath);
|
|
616
|
+
// Peek past trailing turn_ended so a killed fix/confirm tip still counts
|
|
617
|
+
// for resumeFix / tip-swap; inFlightUserQuery correctly returns null there.
|
|
618
|
+
const inflightAtClaim = harnessTipBeforeTrailingTurnEnded(tipEvents);
|
|
619
|
+
if (inflightAtClaim &&
|
|
620
|
+
!isRecoverFollowupMessage(inflightAtClaim)) {
|
|
621
|
+
deadHarnessTipAtClaim = inflightAtClaim;
|
|
622
|
+
if (this.isFixFollowupMessage(inflightAtClaim)) {
|
|
623
|
+
fixTipAtClaim = true;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// Snapshot before claim: historical Recover+assistant must not be treated
|
|
627
|
+
// as "became alive during sleep" for a brand-new error (same template text).
|
|
628
|
+
recoverAnsweredAtClaim =
|
|
629
|
+
tipEvents.length > 0 &&
|
|
630
|
+
transcriptTipIsAssistant(tipEvents) &&
|
|
631
|
+
automationFollowupPresent(tipEvents, message);
|
|
632
|
+
}
|
|
633
|
+
const claim = this.tryClaimErrorRecoverWindow(cid, message, debounceMs, ambient, recoverAnsweredAtClaim, fixTipAtClaim);
|
|
634
|
+
if (claim.role === "coalesced") {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
if (claim.role === "failed") {
|
|
638
|
+
// Claim txn failed. Compensate like legacy handleErrorStop, but never wipe
|
|
639
|
+
// an existing recover pending (unlocked neutralize would race a claimer).
|
|
640
|
+
return this.compensateFailedErrorRecoverClaim(cid, message, action, ambient);
|
|
641
|
+
}
|
|
642
|
+
this.sleepRecoverDebounce(debounceMs);
|
|
643
|
+
return this.finishErrorRecoverAfterDebounce(cid, message, transcriptPath, claim.stamp, recoverAnsweredAtClaim, deadHarnessTipAtClaim);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Claim the recover inject slot for this error storm.
|
|
647
|
+
* - coalesced: another stop already owns the window → do not emit
|
|
648
|
+
* - claimer/redeliver: pending written; `stamp` is pending_followup_at for CAS emit
|
|
649
|
+
* - failed: exclusiveWrite threw
|
|
650
|
+
*/
|
|
651
|
+
tryClaimErrorRecoverWindow(conversationId, message, debounceMs, ambient, recoverAnsweredAtClaim = false, fixTipAtClaim = false) {
|
|
652
|
+
try {
|
|
653
|
+
return this.store.exclusiveWrite(() => {
|
|
654
|
+
const session = this.store.getSession(conversationId);
|
|
655
|
+
if (!session || !this.sessionErrorRecoverable(session)) {
|
|
656
|
+
return { commit: false, value: { role: "failed" } };
|
|
657
|
+
}
|
|
658
|
+
this.store.ensureReviewChain(conversationId);
|
|
659
|
+
const chain = this.store.getReviewChain(conversationId);
|
|
660
|
+
let pending = chain?.pending_followup?.trim() ?? "";
|
|
661
|
+
const inWindow = isRecoverFollowupMessage(pending) &&
|
|
662
|
+
inRecoverDebounceWindow(chain?.pending_followup_at, debounceMs);
|
|
663
|
+
// Stale recover pending after the agent already answered that recover:
|
|
664
|
+
// drop only when outside the window, then fall through to fresh-claim
|
|
665
|
+
// for this new error. Never wipe an in-window claimer, and never
|
|
666
|
+
// coalesce-return after the drop (that swallowed the new storm).
|
|
667
|
+
if (recoverAnsweredAtClaim &&
|
|
668
|
+
isRecoverFollowupMessage(pending) &&
|
|
669
|
+
!inWindow) {
|
|
670
|
+
this.store.clearPendingFollowupIf(conversationId, (m) => isRecoverFollowupMessage(m));
|
|
671
|
+
pending = "";
|
|
672
|
+
}
|
|
673
|
+
if (inWindow) {
|
|
674
|
+
return { commit: true, value: { role: "coalesced" } };
|
|
675
|
+
}
|
|
676
|
+
if (isRecoverFollowupMessage(pending)) {
|
|
677
|
+
// Outside window, still dead — refresh stamp so peers coalesce.
|
|
678
|
+
this.store.savePendingFollowup(conversationId, message, {
|
|
679
|
+
armChain: false,
|
|
680
|
+
});
|
|
681
|
+
const stamp = this.store.getReviewChain(conversationId)?.pending_followup_at ?? "";
|
|
682
|
+
if (!stamp) {
|
|
683
|
+
return { commit: false, value: { role: "failed" } };
|
|
684
|
+
}
|
|
685
|
+
// Recover must not keep a leftover fix chain_pending (phantom E3).
|
|
686
|
+
this.store.clearChainPending(conversationId);
|
|
687
|
+
// Hold completed-stop redeliver through claim sleep; CAS clears it.
|
|
688
|
+
this.armRecoverClaimRedeliverHold(conversationId, debounceMs);
|
|
689
|
+
return {
|
|
690
|
+
commit: true,
|
|
691
|
+
value: { role: "redeliver", stamp },
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
if (ambient) {
|
|
695
|
+
this.applySoftResetAmbientChainForErrorRecover(conversationId);
|
|
696
|
+
// Soft-reset already chose code_edited for pending/chain. Only force
|
|
697
|
+
// when the dead transcript tip was fix but pending was empty/desynced —
|
|
698
|
+
// and never during mid-confirm / E5 (would phantom-arm E2 over E4/E5).
|
|
699
|
+
const live = this.store.getReviewChain(conversationId);
|
|
700
|
+
if (fixTipAtClaim &&
|
|
701
|
+
live &&
|
|
702
|
+
!this.isMidConfirmOrE5(live)) {
|
|
703
|
+
this.store.updateReviewChain(conversationId, { code_edited: 1 });
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
// Executing skips ambient soft-reset; re-arm fix for dead fix tip /
|
|
708
|
+
// pending, but not while confirm_left says mid-confirm or E5-ready.
|
|
709
|
+
const resumeFix = !this.isMidConfirmOrE5(chain) &&
|
|
710
|
+
(this.isFixFollowupMessage(pending) ||
|
|
711
|
+
chain?.code_edited === 1 ||
|
|
712
|
+
fixTipAtClaim);
|
|
713
|
+
if (resumeFix) {
|
|
714
|
+
this.store.updateReviewChain(conversationId, { code_edited: 1 });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
this.store.savePendingFollowup(conversationId, message, {
|
|
718
|
+
armChain: false,
|
|
719
|
+
});
|
|
720
|
+
const stamp = this.store.getReviewChain(conversationId)?.pending_followup_at ?? "";
|
|
721
|
+
if (!stamp) {
|
|
722
|
+
return { commit: false, value: { role: "failed" } };
|
|
723
|
+
}
|
|
724
|
+
// Executing claim skips ambient soft-reset — still disarm chain_pending.
|
|
725
|
+
// Ambient soft-reset already cleared chain_pending; clear again is idempotent.
|
|
726
|
+
this.store.clearChainPending(conversationId);
|
|
727
|
+
this.armRecoverClaimRedeliverHold(conversationId, debounceMs);
|
|
728
|
+
return {
|
|
729
|
+
commit: true,
|
|
730
|
+
value: { role: "claimer", stamp },
|
|
731
|
+
};
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
return { role: "failed" };
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* After debounce: skip inject if the session already revived or recover is
|
|
740
|
+
* already in-flight on the transcript; otherwise CAS-emit on claim stamp.
|
|
741
|
+
*/
|
|
742
|
+
finishErrorRecoverAfterDebounce(conversationId, message, transcriptPath, expectedStamp, recoverAnsweredAtClaim = false, deadHarnessTipAtClaim = null) {
|
|
743
|
+
if (!expectedStamp) {
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
const fresh = this.store.getSession(conversationId);
|
|
747
|
+
if (!fresh || !this.sessionErrorRecoverable(fresh)) {
|
|
748
|
+
this.clearRecoverPendingBestEffort(conversationId);
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ?? "";
|
|
752
|
+
if (!isRecoverFollowupMessage(live)) {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
const needle = live || message;
|
|
756
|
+
const events = transcriptPath ? readTranscriptTail(transcriptPath) : [];
|
|
757
|
+
if (events.length > 0) {
|
|
758
|
+
const inflight = inFlightUserQuery(events);
|
|
759
|
+
if (inflight && isRecoverFollowupMessage(inflight)) {
|
|
760
|
+
// Keep recover pending for host-drop / later redeliver, but drop THIS
|
|
761
|
+
// stop's claim hold only (stamp-matched). Sleep may have been no-op;
|
|
762
|
+
// an uncleared hold would block redelivery for the full debounceMs.
|
|
763
|
+
// Must not wipe a peer redeliver's newly armed hold after the window.
|
|
764
|
+
try {
|
|
765
|
+
this.store.clearPendingRedeliverHoldIfStamp(conversationId, expectedStamp);
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
/* best-effort */
|
|
769
|
+
}
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
// Tip race during sleep: a *new* non-recover harness landed after claim.
|
|
773
|
+
// Do not stack a recover inject on top — keep pending for redelivery.
|
|
774
|
+
// Exception: finish tip is still the same dead harness tip from claim
|
|
775
|
+
// (this error killed that turn) → must emit recover or the session stays stuck.
|
|
776
|
+
// Peek past trailing turn_ended so claim/finish compare the same tip surface.
|
|
777
|
+
const tipForSwap = harnessTipBeforeTrailingTurnEnded(events) ?? inflight;
|
|
778
|
+
if (tipForSwap &&
|
|
779
|
+
!isRecoverFollowupMessage(tipForSwap) &&
|
|
780
|
+
!this.isSameDeadHarnessTip(deadHarnessTipAtClaim, tipForSwap)) {
|
|
781
|
+
try {
|
|
782
|
+
this.store.clearPendingRedeliverHoldIfStamp(conversationId, expectedStamp);
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
/* best-effort */
|
|
786
|
+
}
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
if (!recoverAnsweredAtClaim &&
|
|
790
|
+
transcriptTipIsAssistant(events) &&
|
|
791
|
+
automationFollowupPresent(events, needle)) {
|
|
792
|
+
// Became answered during debounce sleep — do not double-emit.
|
|
793
|
+
// If Recover+assistant was already true at claim time, this is a new
|
|
794
|
+
// error after a prior revive (same template); fall through to CAS.
|
|
795
|
+
this.clearRecoverPendingBestEffort(conversationId);
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
// Tip is unanswered user text, same dead harness from claim, or prior
|
|
799
|
+
// assistant without our recover — still dead; emit recover.
|
|
800
|
+
}
|
|
801
|
+
const emitted = this.tryCasRecoverEmit(conversationId, expectedStamp, needle);
|
|
802
|
+
if (!emitted)
|
|
803
|
+
return null;
|
|
804
|
+
// Pause may land after CAS commit — drop inject + clear recover pending.
|
|
805
|
+
return this.recoverActionIfStillRunnable(conversationId, emitted);
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* True when finish-time inflight is the same dead harness tip snapped at claim.
|
|
809
|
+
* Requires the same harness kind first (fix vs confirm must not match), then
|
|
810
|
+
* exact match. Only fix tips allow finish starting with claim's 48-char prefix
|
|
811
|
+
* (round digits appear early in en/zh). Confirm is exact-only: English confirm
|
|
812
|
+
* puts {lensTitle} after ~100 chars, so a 48-prefix would treat different
|
|
813
|
+
* lenses as the same tip. Advance/Briefly stay exact-only for the same
|
|
814
|
+
* shared-lead-in reason.
|
|
815
|
+
*/
|
|
816
|
+
isSameDeadHarnessTip(claimTip, finishTip) {
|
|
817
|
+
if (!claimTip)
|
|
818
|
+
return false;
|
|
819
|
+
// Full substantive bodies — first-line-only would conflate multiline tips that
|
|
820
|
+
// share a lead-in (e.g. confirm lens on line 2). Strip <user_query>/<timestamp>.
|
|
821
|
+
const a = substantivePromptBody(claimTip) || claimTip.trim();
|
|
822
|
+
const b = substantivePromptBody(finishTip) || finishTip.trim();
|
|
823
|
+
if (!a || !b)
|
|
824
|
+
return false;
|
|
825
|
+
const kind = this.harnessFollowupKind(a);
|
|
826
|
+
if (kind !== this.harnessFollowupKind(b))
|
|
827
|
+
return false;
|
|
828
|
+
if (a === b)
|
|
829
|
+
return true;
|
|
830
|
+
// Prefix matching only for fix (round index diverges within 48 chars).
|
|
831
|
+
if (kind !== "fix") {
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
const prefix = a.slice(0, 48);
|
|
835
|
+
if (prefix && b.startsWith(prefix))
|
|
836
|
+
return true;
|
|
837
|
+
// Truncated re-read of the same tip (finish shorter than claim prefix).
|
|
838
|
+
if (b.length < prefix.length && a.startsWith(b))
|
|
839
|
+
return true;
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Classify completed-stop orphan transcript errors from one tail read.
|
|
844
|
+
* - none: no unresolved turn_ended error
|
|
845
|
+
* - skip_recover_tip: unresolved error AND a recover tip after that error
|
|
846
|
+
* (do not re-bump error_count / re-enter handleErrorStop)
|
|
847
|
+
* - salvage: unresolved error with no post-error recover tip yet
|
|
848
|
+
*
|
|
849
|
+
* Invariant: a newer turn_ended error after an old unanswered recover tip must
|
|
850
|
+
* salvage again — the prior tip does not cover the new failure.
|
|
851
|
+
*/
|
|
852
|
+
classifyCompletedOrphan(transcriptPath) {
|
|
853
|
+
const path = transcriptPath?.trim();
|
|
854
|
+
if (!path)
|
|
855
|
+
return "none";
|
|
856
|
+
try {
|
|
857
|
+
const events = readTranscriptTail(path);
|
|
858
|
+
const errIdx = latestUnresolvedTurnEndedErrorIndex(events);
|
|
859
|
+
if (errIdx < 0)
|
|
860
|
+
return "none";
|
|
861
|
+
// Only a recover tip *after* this error owns the orphan (skip). A recover
|
|
862
|
+
// tip left over from an earlier failure must not suppress salvage.
|
|
863
|
+
for (let i = events.length - 1; i > errIdx; i--) {
|
|
864
|
+
const ev = events[i];
|
|
865
|
+
if (ev.role === "assistant")
|
|
866
|
+
return "none";
|
|
867
|
+
if (ev.role !== "user")
|
|
868
|
+
continue;
|
|
869
|
+
const q = userQueryText(ev);
|
|
870
|
+
if (!q)
|
|
871
|
+
continue;
|
|
872
|
+
if (!substantivePromptBody(q))
|
|
873
|
+
continue;
|
|
874
|
+
if (isRecoverFollowupMessage(q))
|
|
875
|
+
return "skip_recover_tip";
|
|
876
|
+
return "salvage";
|
|
877
|
+
}
|
|
878
|
+
return "salvage";
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
return "none";
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/** Coarse kind bucket so fix/confirm/advance tips cannot share a prefix match. */
|
|
885
|
+
harnessFollowupKind(m) {
|
|
886
|
+
const line = firstSubstantiveLine(m) || m.trim();
|
|
887
|
+
if (this.isFixFollowupMessage(line))
|
|
888
|
+
return "fix";
|
|
889
|
+
if (line.startsWith("Review confirm") || line.startsWith("自审确认")) {
|
|
890
|
+
return "confirm";
|
|
891
|
+
}
|
|
892
|
+
if (line.startsWith("Advance checklist") || line.startsWith("推进下一项")) {
|
|
893
|
+
return "advance";
|
|
894
|
+
}
|
|
895
|
+
if (line.startsWith(BRIEFLY_PREFIX))
|
|
896
|
+
return "briefly";
|
|
897
|
+
if (line.startsWith("Stuck:") || line.startsWith("卡住:") || line.startsWith("卡住:")) {
|
|
898
|
+
return "stuck";
|
|
899
|
+
}
|
|
900
|
+
if (line.startsWith("Verify failed") || line.startsWith("校验失败")) {
|
|
901
|
+
return "verify";
|
|
902
|
+
}
|
|
903
|
+
if (line.startsWith("All checklist") ||
|
|
904
|
+
line.startsWith("全部完成") ||
|
|
905
|
+
line.startsWith("Review complete") ||
|
|
906
|
+
line.startsWith("自审完成")) {
|
|
907
|
+
return "terminal";
|
|
908
|
+
}
|
|
909
|
+
return "other";
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Atomically take emit ownership: only the stop whose claim stamp still
|
|
913
|
+
* matches may return followup. Winner bumps pending_followup_at so a racing
|
|
914
|
+
* redeliver lands in-window and coalesces instead of double-injecting.
|
|
915
|
+
*/
|
|
916
|
+
tryCasRecoverEmit(conversationId, expectedStamp, fallbackMessage) {
|
|
917
|
+
try {
|
|
918
|
+
return this.store.exclusiveWrite(() => {
|
|
919
|
+
const chain = this.store.getReviewChain(conversationId);
|
|
920
|
+
const live = chain?.pending_followup?.trim() ?? "";
|
|
921
|
+
if (!isRecoverFollowupMessage(live)) {
|
|
922
|
+
return { commit: false, value: null };
|
|
923
|
+
}
|
|
924
|
+
if (chain?.pending_followup_at !== expectedStamp) {
|
|
925
|
+
return { commit: false, value: null };
|
|
926
|
+
}
|
|
927
|
+
const session = this.store.getSession(conversationId);
|
|
928
|
+
if (!session || !this.sessionErrorRecoverable(session)) {
|
|
929
|
+
this.store.clearPendingFollowupIf(conversationId, (m) => isRecoverFollowupMessage(m));
|
|
930
|
+
return { commit: true, value: null };
|
|
931
|
+
}
|
|
932
|
+
// True CAS: only one stop bumps the stamp; savePending no-op cannot
|
|
933
|
+
// leave two winners with the same expectedStamp.
|
|
934
|
+
if (!this.store.casBumpPendingFollowupAt(conversationId, expectedStamp)) {
|
|
935
|
+
return { commit: false, value: null };
|
|
936
|
+
}
|
|
937
|
+
// Defense: recover emit must not leave chain_pending armed.
|
|
938
|
+
this.store.clearChainPending(conversationId);
|
|
939
|
+
return {
|
|
940
|
+
commit: true,
|
|
941
|
+
value: {
|
|
942
|
+
kind: "recover",
|
|
943
|
+
message: live || fallbackMessage,
|
|
944
|
+
loop: true,
|
|
945
|
+
},
|
|
946
|
+
};
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
catch {
|
|
950
|
+
return null;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
clearRecoverPendingBestEffort(conversationId) {
|
|
954
|
+
try {
|
|
955
|
+
this.store.clearPendingFollowupIf(conversationId, (m) => isRecoverFollowupMessage(m));
|
|
956
|
+
}
|
|
957
|
+
catch {
|
|
958
|
+
/* best-effort */
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Block completed-stop recover redelivery for ~holdMs using pending_redeliver_at
|
|
963
|
+
* against {@link PENDING_REDELIVER_COOLDOWN_MS}. CAS emit nulls the hold so a
|
|
964
|
+
* host-dropped inject can redeliver immediately (unlike gating on
|
|
965
|
+
* pending_followup_at, which CAS refreshes and would extend the block).
|
|
966
|
+
*
|
|
967
|
+
* When holdMs > cooldown, `at` is in the future so
|
|
968
|
+
* {@link pendingRedeliverAllowed} stays false until ~holdMs elapses — do not
|
|
969
|
+
* clamp hold to cooldown (that would unblock mid-sleep for long debounce).
|
|
970
|
+
*/
|
|
971
|
+
armRecoverClaimRedeliverHold(conversationId, holdMs) {
|
|
972
|
+
if (!(holdMs > 0) || !Number.isFinite(holdMs))
|
|
973
|
+
return;
|
|
974
|
+
const cooldown = PENDING_REDELIVER_COOLDOWN_MS;
|
|
975
|
+
// Match resolveRecoverDebounceMs cap — keep hold covering the full sleep.
|
|
976
|
+
const hold = Math.min(Math.floor(holdMs), 60_000);
|
|
977
|
+
if (!(hold > 0))
|
|
978
|
+
return;
|
|
979
|
+
const at = new Date(Date.now() - (cooldown - hold)).toISOString();
|
|
980
|
+
// Column-only: merge updateReviewChain would rewrite confirm_left/pending.
|
|
981
|
+
this.store.setPendingRedeliverHold(conversationId, at);
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Last-resort when tryClaimErrorRecoverWindow's exclusiveWrite failed.
|
|
985
|
+
* Retries ambient soft-reset+pending up to 3×; then column-only disarm +
|
|
986
|
+
* emitRecover (never unlocked neutralize/soft-reset, never full-neutralize
|
|
987
|
+
* after transient lock failures — that wiped mid-confirm confirm_left).
|
|
988
|
+
* Skip wipe/emit when recover pending already exists.
|
|
989
|
+
*/
|
|
990
|
+
compensateFailedErrorRecoverClaim(conversationId, message, action, ambient) {
|
|
991
|
+
const recoverPending = () => {
|
|
992
|
+
try {
|
|
993
|
+
const pending = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
|
|
994
|
+
"";
|
|
995
|
+
return isRecoverFollowupMessage(pending);
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
return false;
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
// Paused/disarmed after claim failure — do not inject recover.
|
|
1002
|
+
if (!this.sessionStillErrorRecoverable(conversationId)) {
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
// Peer already owns recover — coalesce; do not neutralize or double-emit.
|
|
1006
|
+
if (recoverPending()) {
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
if (ambient) {
|
|
1010
|
+
for (let i = 0; i < 3; i++) {
|
|
1011
|
+
const committed = this.tryCommitAmbientErrorRecover(conversationId, message);
|
|
1012
|
+
if (committed === "ok") {
|
|
1013
|
+
return this.recoverActionIfStillRunnable(conversationId, action);
|
|
1014
|
+
}
|
|
1015
|
+
if (committed === "exists")
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
// tryCommit may have observed a concurrent pause — do not unlock-wipe
|
|
1019
|
+
// mid-confirm/fix state on an already-halted session.
|
|
1020
|
+
if (!this.sessionStillErrorRecoverable(conversationId)) {
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
if (recoverPending()) {
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
// Soft-reset txns failed (lock or stamp). Never full-neutralize — that wiped
|
|
1027
|
+
// mid-confirm confirm_left after transient tryCommit lock failures.
|
|
1028
|
+
// Column-only disarm so leftover chain_pending cannot phantom-E3.
|
|
1029
|
+
try {
|
|
1030
|
+
this.store.clearChainPending(conversationId);
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
/* fall through to emit */
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
if (!this.sessionStillErrorRecoverable(conversationId)) {
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
// Peer may have claimed recover during unlocked disarm — do not emit over it.
|
|
1040
|
+
const emitted = this.emitRecoverAfterFailedClaim(conversationId, action, {
|
|
1041
|
+
ambientSoftReset: ambient,
|
|
1042
|
+
});
|
|
1043
|
+
if (!emitted)
|
|
1044
|
+
return null;
|
|
1045
|
+
// Match tryCommit: drop stamped recover if pause won after the write.
|
|
1046
|
+
return this.recoverActionIfStillRunnable(conversationId, emitted);
|
|
1047
|
+
}
|
|
1048
|
+
/** After a successful compensate write, drop recover if session paused mid-flight. */
|
|
1049
|
+
recoverActionIfStillRunnable(conversationId, action) {
|
|
1050
|
+
if (this.sessionStillErrorRecoverable(conversationId)) {
|
|
1051
|
+
return action;
|
|
1052
|
+
}
|
|
1053
|
+
this.clearRecoverPendingBestEffort(conversationId);
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
sessionStillErrorRecoverable(conversationId) {
|
|
1057
|
+
try {
|
|
1058
|
+
const s = this.store.getSession(conversationId);
|
|
1059
|
+
return !!s && this.sessionErrorRecoverable(s);
|
|
1060
|
+
}
|
|
1061
|
+
catch {
|
|
1062
|
+
return false;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Compensating emit after claim failure: never overwrite a peer recover pending.
|
|
1067
|
+
* Ambient: soft-reset under the same lock before stamp (resume mid-fix /
|
|
1068
|
+
* preserve mid-confirm). Never unlocked soft-reset on the legacy fallback.
|
|
1069
|
+
* If exclusiveWrite is unavailable, fall back to legacy {@link emit} only when
|
|
1070
|
+
* no recover pending is visible and a recover row was stamped.
|
|
1071
|
+
*/
|
|
1072
|
+
emitRecoverAfterFailedClaim(conversationId, action, opts) {
|
|
1073
|
+
const ambientSoftReset = opts?.ambientSoftReset === true;
|
|
1074
|
+
try {
|
|
1075
|
+
return this.store.exclusiveWrite(() => {
|
|
1076
|
+
const session = this.store.getSession(conversationId);
|
|
1077
|
+
if (!session || !this.sessionErrorRecoverable(session)) {
|
|
1078
|
+
return { commit: false, value: null };
|
|
1079
|
+
}
|
|
1080
|
+
const pending = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
|
|
1081
|
+
"";
|
|
1082
|
+
if (isRecoverFollowupMessage(pending)) {
|
|
1083
|
+
return { commit: false, value: null };
|
|
1084
|
+
}
|
|
1085
|
+
if (ambientSoftReset) {
|
|
1086
|
+
this.applySoftResetAmbientChainForErrorRecover(conversationId);
|
|
1087
|
+
}
|
|
1088
|
+
this.store.savePendingFollowup(conversationId, action.message, {
|
|
1089
|
+
armChain: false,
|
|
1090
|
+
});
|
|
1091
|
+
const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
|
|
1092
|
+
"";
|
|
1093
|
+
// Under lock, require a stamped recover row — silent savePending no-op
|
|
1094
|
+
// must not return action (two compensators could both "win").
|
|
1095
|
+
if (!isRecoverFollowupMessage(live)) {
|
|
1096
|
+
return { commit: false, value: null };
|
|
1097
|
+
}
|
|
1098
|
+
// armChain=false does not clear a leftover fix chain_pending=1 → phantom E3.
|
|
1099
|
+
this.store.clearChainPending(conversationId);
|
|
1100
|
+
return { commit: true, value: action };
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
catch {
|
|
1104
|
+
if (!this.sessionStillErrorRecoverable(conversationId)) {
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
const pending = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
|
|
1109
|
+
"";
|
|
1110
|
+
if (isRecoverFollowupMessage(pending)) {
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
catch {
|
|
1115
|
+
/* fall through to legacy emit */
|
|
1116
|
+
}
|
|
1117
|
+
// Legacy path: never unlocked soft-reset (confirm/fix pending desync).
|
|
1118
|
+
this.emit(conversationId, action);
|
|
1119
|
+
try {
|
|
1120
|
+
this.store.clearChainPending(conversationId);
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
/* best-effort */
|
|
1124
|
+
}
|
|
1125
|
+
// Must own a stamped recover row. Returning action after a failed
|
|
1126
|
+
// savePending (lock storm) double-injects with a claimer that still
|
|
1127
|
+
// holds the window and will CAS-emit after debounce.
|
|
1128
|
+
try {
|
|
1129
|
+
const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
|
|
1130
|
+
"";
|
|
1131
|
+
if (isRecoverFollowupMessage(live)) {
|
|
1132
|
+
return action;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
catch {
|
|
1136
|
+
/* ignore */
|
|
1137
|
+
}
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Ambient error recover write: soft-reset + recover pending under one
|
|
1143
|
+
* exclusiveWrite. "exists" = peer already claimed recover (do not overwrite).
|
|
1144
|
+
*/
|
|
1145
|
+
tryCommitAmbientErrorRecover(conversationId, message) {
|
|
1146
|
+
try {
|
|
1147
|
+
return this.store.exclusiveWrite(() => {
|
|
1148
|
+
const session = this.store.getSession(conversationId);
|
|
1149
|
+
if (!session || !this.sessionErrorRecoverable(session)) {
|
|
1150
|
+
return { commit: false, value: "failed" };
|
|
1151
|
+
}
|
|
1152
|
+
const pending = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
|
|
1153
|
+
"";
|
|
1154
|
+
if (isRecoverFollowupMessage(pending)) {
|
|
1155
|
+
return { commit: false, value: "exists" };
|
|
1156
|
+
}
|
|
1157
|
+
this.applySoftResetAmbientChainForErrorRecover(conversationId);
|
|
1158
|
+
this.store.savePendingFollowup(conversationId, message, {
|
|
1159
|
+
armChain: false,
|
|
1160
|
+
});
|
|
1161
|
+
// savePending may no-op (missing session / blank). Do not report ok or
|
|
1162
|
+
// commit a soft-reset that left nothing redeliverable.
|
|
1163
|
+
const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ??
|
|
1164
|
+
"";
|
|
1165
|
+
const stamp = this.store.getReviewChain(conversationId)?.pending_followup_at ?? "";
|
|
1166
|
+
if (!stamp || !isRecoverFollowupMessage(live)) {
|
|
1167
|
+
return { commit: false, value: "failed" };
|
|
1168
|
+
}
|
|
1169
|
+
return { commit: true, value: "ok" };
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
catch {
|
|
1173
|
+
return "failed";
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Ambient/planning error recover: drop confirm/pending arming so recover
|
|
1178
|
+
* (armChain=false) cannot leave chain_pending=1 → phantom E3.
|
|
1179
|
+
*
|
|
1180
|
+
* Call only under exclusiveWrite (with savePendingFollowup). Compensate must
|
|
1181
|
+
* not call this unlocked: clearing undelivered confirm/fix pending while
|
|
1182
|
+
* confirm_left is already post-decrement desyncs the next E4 lens.
|
|
1183
|
+
*
|
|
1184
|
+
* Preserve mid-confirm / E5-ready counters so the completed stop after
|
|
1185
|
+
* recover continues E4/E5. Ready-for-E3 (fix done, chain_pending only)
|
|
1186
|
+
* advances into confirm via confirm_left=confirmRounds (E4 emits 1/N) —
|
|
1187
|
+
* do not regress to another fix. Ready-for-E3 requires empty pending (not
|
|
1188
|
+
* merely !fixPending). Force code_edited only for an active fix
|
|
1189
|
+
* (code_edited / undelivered fix pending / chain_pending with pending still
|
|
1190
|
+
* set). Bare leftover fix_round is ignored (ambient phantom residue).
|
|
1191
|
+
*/
|
|
1192
|
+
applySoftResetAmbientChainForErrorRecover(conversationId) {
|
|
1193
|
+
this.store.ensureReviewChain(conversationId);
|
|
1194
|
+
const chain = this.store.getReviewChain(conversationId);
|
|
1195
|
+
if (!chain)
|
|
1196
|
+
return;
|
|
1197
|
+
// Never clear a live recover claim (unlocked compensate / TOCTOU defense).
|
|
1198
|
+
if (isRecoverFollowupMessage(chain.pending_followup?.trim() ?? "")) {
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
const atE5 = chain.confirm_left === 0 ||
|
|
1202
|
+
(chain.item_confirm_complete === 1 && chain.confirm_left === null);
|
|
1203
|
+
const midConfirm = chain.confirm_left !== null && chain.confirm_left > 0;
|
|
1204
|
+
const pending = chain.pending_followup?.trim() ?? "";
|
|
1205
|
+
const fixPending = this.isFixFollowupMessage(pending);
|
|
1206
|
+
// Ready-for-E3 only when there is no undelivered followup left. Using
|
|
1207
|
+
// !fixPending alone is too wide: a non-prefix/custom pending would look
|
|
1208
|
+
// "ready" and skip an in-flight fix into confirm.
|
|
1209
|
+
const readyForE3 = !atE5 &&
|
|
1210
|
+
!midConfirm &&
|
|
1211
|
+
chain.chain_pending === 1 &&
|
|
1212
|
+
chain.code_edited === 0 &&
|
|
1213
|
+
pending.length === 0;
|
|
1214
|
+
const resumeFix = !atE5 &&
|
|
1215
|
+
!midConfirm &&
|
|
1216
|
+
!readyForE3 &&
|
|
1217
|
+
(chain.chain_pending === 1 ||
|
|
1218
|
+
chain.code_edited === 1 ||
|
|
1219
|
+
fixPending);
|
|
1220
|
+
const rounds = this.config.confirmRounds;
|
|
1221
|
+
this.store.softResetAmbientChainUnlessRecover(conversationId, {
|
|
1222
|
+
confirm_left: atE5 || midConfirm
|
|
1223
|
+
? chain.confirm_left
|
|
1224
|
+
: readyForE3 && rounds > 0
|
|
1225
|
+
? rounds
|
|
1226
|
+
: null,
|
|
1227
|
+
item_confirm_complete: atE5 ? chain.item_confirm_complete : 0,
|
|
1228
|
+
chain_pending: 0,
|
|
1229
|
+
// Preserve an in-flight edit marker (E2 wins over E4); else force only for mid-fix.
|
|
1230
|
+
code_edited: resumeFix || chain.code_edited === 1 ? 1 : 0,
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
/** Shared with inferPendingKind — locale templates must keep these prefixes. */
|
|
1234
|
+
isFixFollowupMessage(message) {
|
|
1235
|
+
const line = firstSubstantiveLine(message) || message.trim();
|
|
1236
|
+
return line.startsWith("Review fix") || line.startsWith("自审修复");
|
|
1237
|
+
}
|
|
1238
|
+
/** Mid-confirm or E5-ready — must not phantom-arm code_edited over E4/E5. */
|
|
1239
|
+
isMidConfirmOrE5(chain) {
|
|
1240
|
+
if (!chain)
|
|
1241
|
+
return false;
|
|
1242
|
+
if (chain.confirm_left !== null && chain.confirm_left > 0)
|
|
1243
|
+
return true;
|
|
1244
|
+
return (chain.confirm_left === 0 ||
|
|
1245
|
+
(chain.item_confirm_complete === 1 && chain.confirm_left === null));
|
|
1246
|
+
}
|
|
1247
|
+
/** True when a stop may still advance the review chain (re-check under write lock). */
|
|
1248
|
+
sessionRunnable(conversationId) {
|
|
1249
|
+
const s = this.store.getSession(conversationId);
|
|
1250
|
+
return !!s && sessionReviewRunnable(s, this.config.reviewScope);
|
|
1251
|
+
}
|
|
1252
|
+
/** Genuine error stop may inject recover (planning, project ambient, or armed executing). */
|
|
1253
|
+
sessionErrorRecoverable(session) {
|
|
1254
|
+
if (session.paused !== 0)
|
|
1255
|
+
return false;
|
|
1256
|
+
if (session.phase === "planning")
|
|
1257
|
+
return true;
|
|
1258
|
+
if (session.phase === "idle" &&
|
|
1259
|
+
session.armed === 1 &&
|
|
1260
|
+
this.config.reviewScope === "project") {
|
|
1261
|
+
return true;
|
|
1262
|
+
}
|
|
1263
|
+
return session.phase === "executing" && session.armed === 1;
|
|
1264
|
+
}
|
|
1265
|
+
recoverKindForPhase(phase) {
|
|
1266
|
+
if (phase === "planning")
|
|
1267
|
+
return "recover_planning";
|
|
1268
|
+
if (phase === "idle")
|
|
1269
|
+
return "recover_ambient";
|
|
1270
|
+
return "recover";
|
|
1271
|
+
}
|
|
1272
|
+
/** completed stop → reset error_count */
|
|
1273
|
+
noteCompletedOk(session) {
|
|
1274
|
+
if (session.error_count > 0) {
|
|
1275
|
+
this.store.upsertSession({
|
|
1276
|
+
conversation_id: session.conversation_id,
|
|
1277
|
+
project_root: session.project_root,
|
|
1278
|
+
code_root: session.code_root,
|
|
1279
|
+
error_count: 0,
|
|
1280
|
+
last_error: null,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
maybeResetErrorCountOnItemChange(_session) {
|
|
1285
|
+
// error_count reset on item id change is handled by callers tracking last item;
|
|
1286
|
+
// E5b always zeroes error_count on advance. Do not existsSync untrusted
|
|
1287
|
+
// checklist_path here (symlink / outside probe).
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* E0': checklist executing, not in fix/confirm — skip lenses when there is no
|
|
1291
|
+
* product code edit. Soft/verified success advances in one IMMEDIATE txn with
|
|
1292
|
+
* evidence re-check (never arm at-E5 first). Only required-verify *fail*
|
|
1293
|
+
* arms at-E5 to reuse E5c verify_fix / stuck.
|
|
1294
|
+
*/
|
|
1295
|
+
e0NoCodeContinue(session, chain) {
|
|
1296
|
+
if (!isChecklistExecuting(session))
|
|
1297
|
+
return null;
|
|
1298
|
+
if (chain.code_edited === 1 ||
|
|
1299
|
+
chain.confirm_left !== null ||
|
|
1300
|
+
chain.item_confirm_complete === 1 ||
|
|
1301
|
+
chain.chain_pending === 1) {
|
|
1302
|
+
return null;
|
|
1303
|
+
}
|
|
1304
|
+
const parsed = this.parseSessionChecklist(session);
|
|
1305
|
+
if (!parsed?.checklist)
|
|
1306
|
+
return null;
|
|
1307
|
+
const reviewingId = this.resolveReviewingItemId(chain, parsed.checklist);
|
|
1308
|
+
const currentItem = (reviewingId &&
|
|
1309
|
+
parsed.checklist.items.find((i) => i.id === reviewingId)) ||
|
|
1310
|
+
parsed.currentItem;
|
|
1311
|
+
if (!currentItem)
|
|
1312
|
+
return null;
|
|
1313
|
+
const trustRoot = this.trustedProjectRoot();
|
|
1314
|
+
const reportPath = this.config.verifyReportPath ??
|
|
1315
|
+
defaultVerifyReportPath(trustRoot ?? "");
|
|
1316
|
+
const checklistPath = session.checklist_path || "";
|
|
1317
|
+
const evalResult = evaluateVerifyReport({
|
|
1318
|
+
enabled: this.config.verifyEnabled,
|
|
1319
|
+
commands: this.config.verifyCommands,
|
|
1320
|
+
reportPath,
|
|
1321
|
+
currentItem,
|
|
1322
|
+
checklistPath,
|
|
1323
|
+
projectRoot: trustRoot ?? undefined,
|
|
1324
|
+
});
|
|
1325
|
+
if (evalResult.outcome === "skip") {
|
|
1326
|
+
// Soft evidence + advance/done in one write — never arm E5 first.
|
|
1327
|
+
return this.e0DirectAdvance(session, reportPath, currentItem.id, {
|
|
1328
|
+
kind: "soft",
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
if (evalResult.outcome === "pass") {
|
|
1332
|
+
// Required verify already passed — advance in one write. Do NOT arm at-E5
|
|
1333
|
+
// first: a crash between arm and e5Gate would leave stranded at-E5, and a
|
|
1334
|
+
// later skip-configured stop could advance without soft evidence.
|
|
1335
|
+
return this.e0DirectAdvance(session, reportPath, currentItem.id, {
|
|
1336
|
+
kind: "verified",
|
|
1337
|
+
checklistPath,
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
// fail (required verify armed): arm at-E5 and reuse E5c verify_fix / stuck.
|
|
1341
|
+
if (!this.armAtE5ForNoCode(session.conversation_id))
|
|
1342
|
+
return null;
|
|
1343
|
+
const fresh = this.store.getReviewChain(session.conversation_id);
|
|
1344
|
+
const sess = this.store.getSession(session.conversation_id);
|
|
1345
|
+
if (!fresh || !sess) {
|
|
1346
|
+
this.disarmE5NoCode(session.conversation_id);
|
|
1347
|
+
return null;
|
|
1348
|
+
}
|
|
1349
|
+
const action = this.e5Gate(sess, fresh);
|
|
1350
|
+
if (!action) {
|
|
1351
|
+
// Do not leave a stranded at-E5 state that would skip soft evidence next stop.
|
|
1352
|
+
this.disarmE5NoCode(session.conversation_id);
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
1355
|
+
// E5b advance keeps chain_pending=0; clear stray pending if any (fail→fixed→pass).
|
|
1356
|
+
if (action.kind === "advance") {
|
|
1357
|
+
const cid = session.conversation_id;
|
|
1358
|
+
try {
|
|
1359
|
+
this.store.exclusiveWrite(() => {
|
|
1360
|
+
const live = this.store.getReviewChain(cid);
|
|
1361
|
+
if (!live ||
|
|
1362
|
+
live.chain_pending !== 1 ||
|
|
1363
|
+
live.code_edited === 1 ||
|
|
1364
|
+
live.confirm_left !== null ||
|
|
1365
|
+
live.item_confirm_complete !== 0) {
|
|
1366
|
+
return { commit: false, value: undefined };
|
|
1367
|
+
}
|
|
1368
|
+
this.store.updateReviewChain(cid, { chain_pending: 0 });
|
|
1369
|
+
return { commit: true, value: undefined };
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
catch {
|
|
1373
|
+
/* best-effort */
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
return action;
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* E0 direct advance/done: re-check soft or verified evidence under one
|
|
1380
|
+
* IMMEDIATE write — never enters the confirm/E5 state machine.
|
|
1381
|
+
*/
|
|
1382
|
+
e0DirectAdvance(session, reportPath, expectedItemId, evidence) {
|
|
1383
|
+
const cid = session.conversation_id;
|
|
1384
|
+
const trustRoot = this.trustedProjectRoot();
|
|
1385
|
+
const action = this.store.exclusiveWrite(() => {
|
|
1386
|
+
if (!this.sessionRunnable(cid)) {
|
|
1387
|
+
return { commit: false, value: null };
|
|
1388
|
+
}
|
|
1389
|
+
const lockedSession = this.store.getSession(cid);
|
|
1390
|
+
if (!lockedSession || !isChecklistExecuting(lockedSession)) {
|
|
1391
|
+
return { commit: false, value: null };
|
|
1392
|
+
}
|
|
1393
|
+
const fresh = this.store.getReviewChain(cid);
|
|
1394
|
+
if (!fresh ||
|
|
1395
|
+
fresh.code_edited === 1 ||
|
|
1396
|
+
fresh.confirm_left !== null ||
|
|
1397
|
+
fresh.item_confirm_complete === 1 ||
|
|
1398
|
+
fresh.chain_pending === 1) {
|
|
1399
|
+
return { commit: false, value: null };
|
|
1400
|
+
}
|
|
1401
|
+
const refreshed = this.parseSessionChecklist(lockedSession);
|
|
1402
|
+
if (!refreshed?.checklist) {
|
|
1403
|
+
return { commit: false, value: null };
|
|
1404
|
+
}
|
|
1405
|
+
const reviewingId = this.resolveReviewingItemId(fresh, refreshed.checklist, expectedItemId);
|
|
1406
|
+
const targets = resolveAdvanceTargets(refreshed.checklist, reviewingId);
|
|
1407
|
+
// Soft/verified E0 still requires the expected item to be the one we advance.
|
|
1408
|
+
if (!targets.current || targets.current.id !== expectedItemId) {
|
|
1409
|
+
return { commit: false, value: null };
|
|
1410
|
+
}
|
|
1411
|
+
if (evidence.kind === "soft") {
|
|
1412
|
+
if (!hasNoCodeCompletionEvidence({
|
|
1413
|
+
reportPath,
|
|
1414
|
+
currentItemId: targets.current.id,
|
|
1415
|
+
projectRoot: trustRoot ?? undefined,
|
|
1416
|
+
})) {
|
|
1417
|
+
return { commit: false, value: null };
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
else {
|
|
1421
|
+
// Prefer checklist path from the locked session row (not the pre-lock snapshot).
|
|
1422
|
+
const lockedChecklistPath = lockedSession.checklist_path?.trim() || evidence.checklistPath;
|
|
1423
|
+
const lockedEval = evaluateVerifyReport({
|
|
1424
|
+
enabled: this.config.verifyEnabled,
|
|
1425
|
+
commands: this.config.verifyCommands,
|
|
1426
|
+
reportPath,
|
|
1427
|
+
currentItem: targets.current,
|
|
1428
|
+
checklistPath: lockedChecklistPath,
|
|
1429
|
+
projectRoot: trustRoot ?? undefined,
|
|
1430
|
+
});
|
|
1431
|
+
if (lockedEval.outcome !== "pass") {
|
|
1432
|
+
return { commit: false, value: null };
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
if (targets.unchecked <= 0 && !targets.current) {
|
|
1436
|
+
return { commit: false, value: null };
|
|
1437
|
+
}
|
|
1438
|
+
const out = this.renderAdvanceOrDone(targets);
|
|
1439
|
+
const isAdvance = out.kind === "advance";
|
|
1440
|
+
const chainReset = this.reviewChainResetFields();
|
|
1441
|
+
if (isAdvance) {
|
|
1442
|
+
this.store.upsertSession({
|
|
1443
|
+
conversation_id: cid,
|
|
1444
|
+
project_root: lockedSession.project_root,
|
|
1445
|
+
code_root: lockedSession.code_root,
|
|
1446
|
+
error_count: 0,
|
|
1447
|
+
idle_stop_count: 0,
|
|
1448
|
+
last_error: null,
|
|
1449
|
+
});
|
|
1450
|
+
// chain_pending stays 0 (same as E5b advance). Soft/verified E0
|
|
1451
|
+
// advance has no product diff to confirm; leaving pending=1 would force
|
|
1452
|
+
// E3 on the next no-code item and skip E0 soft evidence.
|
|
1453
|
+
this.store.updateReviewChain(cid, {
|
|
1454
|
+
...chainReset,
|
|
1455
|
+
// Stick the *next* item immediately so premature `[x]` before the
|
|
1456
|
+
// first product edit cannot retarget via firstUnchecked.
|
|
1457
|
+
reviewing_item_id: targets.next?.id ?? null,
|
|
1458
|
+
pending_followup: out.message,
|
|
1459
|
+
pending_followup_at: new Date().toISOString(),
|
|
1460
|
+
pending_redeliver_at: null,
|
|
1461
|
+
chain_pending: 0,
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
else {
|
|
1465
|
+
this.store.upsertSession({
|
|
1466
|
+
conversation_id: cid,
|
|
1467
|
+
project_root: lockedSession.project_root,
|
|
1468
|
+
code_root: lockedSession.code_root,
|
|
1469
|
+
phase: "done",
|
|
1470
|
+
armed: 0,
|
|
1471
|
+
error_count: 0,
|
|
1472
|
+
idle_stop_count: 0,
|
|
1473
|
+
last_error: null,
|
|
1474
|
+
});
|
|
1475
|
+
this.store.updateReviewChain(cid, {
|
|
1476
|
+
...chainReset,
|
|
1477
|
+
pending_followup: out.message,
|
|
1478
|
+
pending_followup_at: new Date().toISOString(),
|
|
1479
|
+
pending_redeliver_at: null,
|
|
1480
|
+
chain_pending: 0,
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
return { commit: true, value: out };
|
|
1484
|
+
});
|
|
1485
|
+
if (action) {
|
|
1486
|
+
this.afterFollowupCommitted(session, {});
|
|
1487
|
+
}
|
|
1488
|
+
return action;
|
|
1489
|
+
}
|
|
1490
|
+
/** Arm confirm_left=0 + ICC=1 only when still idle on the no-code path. */
|
|
1491
|
+
armAtE5ForNoCode(conversationId) {
|
|
1492
|
+
return (this.store.exclusiveWrite(() => {
|
|
1493
|
+
if (!this.sessionRunnable(conversationId)) {
|
|
1494
|
+
return { commit: false, value: false };
|
|
1495
|
+
}
|
|
1496
|
+
const sess = this.store.getSession(conversationId);
|
|
1497
|
+
if (!sess || !isChecklistExecuting(sess)) {
|
|
1498
|
+
return { commit: false, value: false };
|
|
1499
|
+
}
|
|
1500
|
+
const fresh = this.store.getReviewChain(conversationId);
|
|
1501
|
+
if (!fresh ||
|
|
1502
|
+
fresh.code_edited === 1 ||
|
|
1503
|
+
fresh.confirm_left !== null ||
|
|
1504
|
+
fresh.item_confirm_complete === 1 ||
|
|
1505
|
+
fresh.chain_pending === 1) {
|
|
1506
|
+
return { commit: false, value: false };
|
|
1507
|
+
}
|
|
1508
|
+
this.store.updateReviewChain(conversationId, {
|
|
1509
|
+
confirm_left: 0,
|
|
1510
|
+
item_confirm_complete: 1,
|
|
1511
|
+
chain_pending: 0,
|
|
1512
|
+
});
|
|
1513
|
+
return { commit: true, value: true };
|
|
1514
|
+
}) === true);
|
|
1515
|
+
}
|
|
1516
|
+
/** Undo a failed arm so the next stop cannot skip soft evidence via E5. */
|
|
1517
|
+
disarmE5NoCode(conversationId) {
|
|
1518
|
+
try {
|
|
1519
|
+
this.store.exclusiveWrite(() => {
|
|
1520
|
+
const fresh = this.store.getReviewChain(conversationId);
|
|
1521
|
+
if (!fresh ||
|
|
1522
|
+
fresh.code_edited === 1 ||
|
|
1523
|
+
fresh.confirm_left !== 0 ||
|
|
1524
|
+
fresh.item_confirm_complete !== 1) {
|
|
1525
|
+
return { commit: false, value: undefined };
|
|
1526
|
+
}
|
|
1527
|
+
// Only clear a pristine post-arm state (no pending followup yet).
|
|
1528
|
+
if (fresh.pending_followup?.trim()) {
|
|
1529
|
+
return { commit: false, value: undefined };
|
|
1530
|
+
}
|
|
1531
|
+
this.store.updateReviewChain(conversationId, {
|
|
1532
|
+
confirm_left: null,
|
|
1533
|
+
item_confirm_complete: 0,
|
|
1534
|
+
chain_pending: 0,
|
|
1535
|
+
});
|
|
1536
|
+
return { commit: true, value: undefined };
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
catch {
|
|
1540
|
+
/* best-effort */
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
/** Session-monotonic round counter (fix + confirm share fix_round; no hard cap). */
|
|
1544
|
+
nextSessionRound(chain) {
|
|
1545
|
+
return chain.fix_round + 1;
|
|
1546
|
+
}
|
|
1547
|
+
e2Fix(session, _chain) {
|
|
1548
|
+
const rounds = this.config.confirmRounds;
|
|
1549
|
+
const cid = session.conversation_id;
|
|
1550
|
+
// Chain counters + pending in one IMMEDIATE txn — crash must not leave
|
|
1551
|
+
// "code_edited cleared / round bumped" without the fix message to redeliver.
|
|
1552
|
+
const action = this.store.exclusiveWrite(() => {
|
|
1553
|
+
// Concurrent error-halt may have paused/disarmed after the outer check.
|
|
1554
|
+
if (!this.sessionRunnable(cid)) {
|
|
1555
|
+
return { commit: false, value: null };
|
|
1556
|
+
}
|
|
1557
|
+
const fresh = this.store.getReviewChain(cid);
|
|
1558
|
+
if (!fresh || fresh.code_edited !== 1) {
|
|
1559
|
+
return { commit: false, value: null };
|
|
1560
|
+
}
|
|
1561
|
+
const fixRound = this.nextSessionRound(fresh);
|
|
1562
|
+
const message = this.render("review.fix", { round: fixRound, total: rounds });
|
|
1563
|
+
const out = {
|
|
1564
|
+
kind: "review.fix",
|
|
1565
|
+
message,
|
|
1566
|
+
loop: true,
|
|
1567
|
+
meta: { fixRound },
|
|
1568
|
+
};
|
|
1569
|
+
this.store.updateReviewChain(cid, {
|
|
1570
|
+
fix_round: fixRound,
|
|
1571
|
+
code_edited: 0,
|
|
1572
|
+
confirm_left: null,
|
|
1573
|
+
chain_pending: 1,
|
|
1574
|
+
// item_confirm_complete preserved (E2 path)
|
|
1575
|
+
pending_followup: message,
|
|
1576
|
+
pending_followup_at: new Date().toISOString(),
|
|
1577
|
+
pending_redeliver_at: null,
|
|
1578
|
+
});
|
|
1579
|
+
return { commit: true, value: out };
|
|
1580
|
+
});
|
|
1581
|
+
if (action) {
|
|
1582
|
+
this.afterFollowupCommitted(session, {});
|
|
1583
|
+
}
|
|
1584
|
+
return action;
|
|
1585
|
+
}
|
|
1586
|
+
e3ArmConfirm(session, _chain) {
|
|
1587
|
+
const rounds = this.config.confirmRounds;
|
|
1588
|
+
const lens = this.lens(1);
|
|
1589
|
+
const left = rounds - 1; // inject 1st then set left = rounds-1
|
|
1590
|
+
const kind = rounds === 1 ? "review.confirm_final" : "review.confirm";
|
|
1591
|
+
const cid = session.conversation_id;
|
|
1592
|
+
const action = this.store.exclusiveWrite(() => {
|
|
1593
|
+
if (!this.sessionRunnable(cid)) {
|
|
1594
|
+
return { commit: false, value: null };
|
|
1595
|
+
}
|
|
1596
|
+
const fresh = this.store.getReviewChain(cid);
|
|
1597
|
+
// Lost the race to another stop (already confirming, at E5, or code edit).
|
|
1598
|
+
if (!fresh ||
|
|
1599
|
+
fresh.code_edited === 1 ||
|
|
1600
|
+
fresh.confirm_left !== null ||
|
|
1601
|
+
fresh.item_confirm_complete === 1) {
|
|
1602
|
+
return { commit: false, value: null };
|
|
1603
|
+
}
|
|
1604
|
+
const sessionRound = this.nextSessionRound(fresh);
|
|
1605
|
+
const message = this.render(kind, {
|
|
1606
|
+
n: 1,
|
|
1607
|
+
total: rounds,
|
|
1608
|
+
sessionRound,
|
|
1609
|
+
lensTitle: lens.title,
|
|
1610
|
+
lensFocus: lens.focus,
|
|
1611
|
+
});
|
|
1612
|
+
const out = {
|
|
1613
|
+
kind,
|
|
1614
|
+
message,
|
|
1615
|
+
loop: true,
|
|
1616
|
+
meta: { n: 1, total: rounds, sessionRound },
|
|
1617
|
+
};
|
|
1618
|
+
this.store.updateReviewChain(cid, {
|
|
1619
|
+
confirm_left: left,
|
|
1620
|
+
chain_pending: 1,
|
|
1621
|
+
fix_round: sessionRound,
|
|
1622
|
+
pending_followup: message,
|
|
1623
|
+
pending_followup_at: new Date().toISOString(),
|
|
1624
|
+
pending_redeliver_at: null,
|
|
1625
|
+
});
|
|
1626
|
+
return { commit: true, value: out };
|
|
1627
|
+
});
|
|
1628
|
+
if (action) {
|
|
1629
|
+
this.afterFollowupCommitted(session, { confirm_left: left });
|
|
1630
|
+
}
|
|
1631
|
+
return action;
|
|
1632
|
+
}
|
|
1633
|
+
e4Confirm(session, chain) {
|
|
1634
|
+
const rounds = this.config.confirmRounds;
|
|
1635
|
+
const expectedLeft = chain.confirm_left;
|
|
1636
|
+
if (expectedLeft === null || expectedLeft <= 0)
|
|
1637
|
+
return null;
|
|
1638
|
+
// lensIndex = rounds - left + 1 (before decrement)
|
|
1639
|
+
const n = rounds - expectedLeft + 1;
|
|
1640
|
+
const lens = this.lens(n);
|
|
1641
|
+
const newLeft = expectedLeft - 1;
|
|
1642
|
+
const isFinal = n === rounds;
|
|
1643
|
+
const kind = isFinal ? "review.confirm_final" : "review.confirm";
|
|
1644
|
+
const cid = session.conversation_id;
|
|
1645
|
+
// MUST return from handleStop after this — never continue to E5 same stop.
|
|
1646
|
+
// Atomic confirm_left decrement + pending avoids skip-a-lens on crash/concurrent stop.
|
|
1647
|
+
const action = this.store.exclusiveWrite(() => {
|
|
1648
|
+
if (!this.sessionRunnable(cid)) {
|
|
1649
|
+
return { commit: false, value: null };
|
|
1650
|
+
}
|
|
1651
|
+
const fresh = this.store.getReviewChain(cid);
|
|
1652
|
+
if (!fresh || fresh.code_edited === 1 || fresh.confirm_left !== expectedLeft) {
|
|
1653
|
+
return { commit: false, value: null };
|
|
1654
|
+
}
|
|
1655
|
+
const sessionRound = this.nextSessionRound(fresh);
|
|
1656
|
+
const message = this.render(kind, {
|
|
1657
|
+
n,
|
|
1658
|
+
total: rounds,
|
|
1659
|
+
sessionRound,
|
|
1660
|
+
lensTitle: lens.title,
|
|
1661
|
+
lensFocus: lens.focus,
|
|
1662
|
+
});
|
|
1663
|
+
const out = {
|
|
1664
|
+
kind,
|
|
1665
|
+
message,
|
|
1666
|
+
loop: true,
|
|
1667
|
+
meta: { n, total: rounds, confirm_left: newLeft, sessionRound },
|
|
1668
|
+
};
|
|
1669
|
+
this.store.updateReviewChain(cid, {
|
|
1670
|
+
confirm_left: newLeft,
|
|
1671
|
+
chain_pending: 1,
|
|
1672
|
+
fix_round: sessionRound,
|
|
1673
|
+
pending_followup: message,
|
|
1674
|
+
pending_followup_at: new Date().toISOString(),
|
|
1675
|
+
pending_redeliver_at: null,
|
|
1676
|
+
});
|
|
1677
|
+
return { commit: true, value: out };
|
|
1678
|
+
});
|
|
1679
|
+
if (action) {
|
|
1680
|
+
this.afterFollowupCommitted(session, { confirm_left: newLeft });
|
|
1681
|
+
}
|
|
1682
|
+
return action;
|
|
1683
|
+
}
|
|
1684
|
+
e5Gate(session, chain) {
|
|
1685
|
+
// Project-scope ambient/planning: never verify/advance checklist — end with review_complete.
|
|
1686
|
+
// Otherwise a leftover checklist_path (e.g. after ON) + verify fail / unreadable path
|
|
1687
|
+
// would return null and stall the chain forever.
|
|
1688
|
+
if (this.config.reviewScope === "project" &&
|
|
1689
|
+
!isChecklistExecuting(session)) {
|
|
1690
|
+
return this.e5bAdvance(session, {
|
|
1691
|
+
unchecked: 0,
|
|
1692
|
+
next: null,
|
|
1693
|
+
verifiedPass: false,
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
// E5c first
|
|
1697
|
+
const checklistPath = session.checklist_path;
|
|
1698
|
+
let currentItem = null;
|
|
1699
|
+
// Single checklist read for verify + advance/done — avoids TOCTOU between
|
|
1700
|
+
// e5Gate and e5bAdvance, and keeps FS I/O outside the write lock.
|
|
1701
|
+
let unchecked = 0;
|
|
1702
|
+
if (checklistPath) {
|
|
1703
|
+
const parsed = this.parseSessionChecklist(session);
|
|
1704
|
+
if (!parsed) {
|
|
1705
|
+
// Unreadable / missing / unsafe checklist — do not advance/done or verify-fail spuriously.
|
|
1706
|
+
return null;
|
|
1707
|
+
}
|
|
1708
|
+
unchecked = parsed.unchecked;
|
|
1709
|
+
const checklistMd = parsed.checklist;
|
|
1710
|
+
const reviewingId = this.resolveReviewingItemId(chain, checklistMd);
|
|
1711
|
+
currentItem =
|
|
1712
|
+
(reviewingId &&
|
|
1713
|
+
checklistMd.items.find((i) => i.id === reviewingId)) ||
|
|
1714
|
+
parsed.currentItem;
|
|
1715
|
+
}
|
|
1716
|
+
// No open unchecked rows: still allow done via sticky reviewing item when present.
|
|
1717
|
+
// Otherwise verifyEnabled + required cmds treat currentItem=null as fail and
|
|
1718
|
+
// loop verify_fix forever instead of emitting done.
|
|
1719
|
+
if (unchecked === 0) {
|
|
1720
|
+
return this.e5bAdvance(session, {
|
|
1721
|
+
unchecked: 0,
|
|
1722
|
+
next: currentItem,
|
|
1723
|
+
verifiedPass: false,
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
// Defensive: countUnchecked>0 must yield a resolvable item (first or sticky).
|
|
1727
|
+
if (!currentItem) {
|
|
1728
|
+
return null;
|
|
1729
|
+
}
|
|
1730
|
+
const trustRoot = this.trustedProjectRoot();
|
|
1731
|
+
const reportPath = this.config.verifyReportPath ??
|
|
1732
|
+
defaultVerifyReportPath(trustRoot ?? "");
|
|
1733
|
+
const evalResult = evaluateVerifyReport({
|
|
1734
|
+
enabled: this.config.verifyEnabled,
|
|
1735
|
+
commands: this.config.verifyCommands,
|
|
1736
|
+
reportPath,
|
|
1737
|
+
currentItem,
|
|
1738
|
+
checklistPath: checklistPath || "",
|
|
1739
|
+
projectRoot: trustRoot ?? undefined,
|
|
1740
|
+
});
|
|
1741
|
+
if (evalResult.outcome === "fail") {
|
|
1742
|
+
// E5c: ICC + confirm_left + pending (+ idle/stuck) in one IMMEDIATE txn so a
|
|
1743
|
+
// crash cannot leave "at E5 with ICC" and no redeliverable verify_fix/stuck.
|
|
1744
|
+
const reason = evalResult.reason ?? "fail";
|
|
1745
|
+
const cid = session.conversation_id;
|
|
1746
|
+
return this.store.exclusiveWrite(() => {
|
|
1747
|
+
const fresh = this.store.getReviewChain(cid);
|
|
1748
|
+
const atE5 = !!fresh &&
|
|
1749
|
+
(fresh.confirm_left === 0 ||
|
|
1750
|
+
(fresh.item_confirm_complete === 1 && fresh.confirm_left === null));
|
|
1751
|
+
if (!atE5) {
|
|
1752
|
+
return { commit: false, value: null };
|
|
1753
|
+
}
|
|
1754
|
+
const sess = this.store.getSession(cid);
|
|
1755
|
+
if (!sess ||
|
|
1756
|
+
!isChecklistExecuting(sess)) {
|
|
1757
|
+
return { commit: false, value: null };
|
|
1758
|
+
}
|
|
1759
|
+
// Refresh checklist + verify under the write lock — concurrent edits or a
|
|
1760
|
+
// flipped report must not ICC against a stale open item.
|
|
1761
|
+
let lockedItem = currentItem;
|
|
1762
|
+
const lockedPath = checklistPath || "";
|
|
1763
|
+
if (checklistPath) {
|
|
1764
|
+
const locked = this.parseSessionChecklist(sess);
|
|
1765
|
+
if (!locked) {
|
|
1766
|
+
return { commit: false, value: null };
|
|
1767
|
+
}
|
|
1768
|
+
if (locked.unchecked === 0) {
|
|
1769
|
+
// Nothing left to verify — let the next stop take the done path.
|
|
1770
|
+
// Do not require empty sticky: orphan/stale reviewing_item_id must
|
|
1771
|
+
// not keep E5c fail looping when the checklist is fully checked.
|
|
1772
|
+
return { commit: false, value: null };
|
|
1773
|
+
}
|
|
1774
|
+
const reviewingId = this.resolveReviewingItemId(fresh, locked.checklist);
|
|
1775
|
+
const nextItem = (reviewingId &&
|
|
1776
|
+
locked.checklist?.items.find((i) => i.id === reviewingId)) ||
|
|
1777
|
+
locked.currentItem;
|
|
1778
|
+
if (!nextItem) {
|
|
1779
|
+
return { commit: false, value: null };
|
|
1780
|
+
}
|
|
1781
|
+
lockedItem = nextItem;
|
|
1782
|
+
}
|
|
1783
|
+
const lockedEval = evaluateVerifyReport({
|
|
1784
|
+
enabled: this.config.verifyEnabled,
|
|
1785
|
+
commands: this.config.verifyCommands,
|
|
1786
|
+
reportPath,
|
|
1787
|
+
currentItem: lockedItem,
|
|
1788
|
+
checklistPath: lockedPath,
|
|
1789
|
+
projectRoot: this.trustedProjectRoot() ?? undefined,
|
|
1790
|
+
});
|
|
1791
|
+
if (lockedEval.outcome !== "fail") {
|
|
1792
|
+
return { commit: false, value: null };
|
|
1793
|
+
}
|
|
1794
|
+
const failReason = lockedEval.reason ?? reason;
|
|
1795
|
+
const nextIdle = sess.idle_stop_count + 1;
|
|
1796
|
+
const nowStuck = nextIdle >= this.config.maxIdleStops;
|
|
1797
|
+
if (nowStuck) {
|
|
1798
|
+
this.store.upsertSession({
|
|
1799
|
+
conversation_id: cid,
|
|
1800
|
+
project_root: sess.project_root,
|
|
1801
|
+
code_root: sess.code_root,
|
|
1802
|
+
idle_stop_count: nextIdle,
|
|
1803
|
+
paused: 1,
|
|
1804
|
+
paused_reason: "stuck",
|
|
1805
|
+
armed: 0,
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
else {
|
|
1809
|
+
this.store.upsertSession({
|
|
1810
|
+
conversation_id: cid,
|
|
1811
|
+
project_root: sess.project_root,
|
|
1812
|
+
code_root: sess.code_root,
|
|
1813
|
+
idle_stop_count: nextIdle,
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
const kind = nowStuck ? "stuck" : "verify_fix";
|
|
1817
|
+
const message = this.render(kind, nowStuck ? {} : { reason: failReason });
|
|
1818
|
+
this.store.updateReviewChain(cid, {
|
|
1819
|
+
confirm_left: 0,
|
|
1820
|
+
item_confirm_complete: 1,
|
|
1821
|
+
chain_pending: 1,
|
|
1822
|
+
pending_followup: message,
|
|
1823
|
+
pending_followup_at: new Date().toISOString(),
|
|
1824
|
+
pending_redeliver_at: null,
|
|
1825
|
+
});
|
|
1826
|
+
const out = {
|
|
1827
|
+
kind,
|
|
1828
|
+
message,
|
|
1829
|
+
loop: true,
|
|
1830
|
+
meta: { reason: failReason },
|
|
1831
|
+
};
|
|
1832
|
+
return { commit: true, value: out };
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
// E5a+E5b: reset chain and persist advance/done pending in one write txn
|
|
1836
|
+
// so a crash cannot leave "reset, no pending" (would re-arm confirm or stall).
|
|
1837
|
+
return this.e5bAdvance(session, {
|
|
1838
|
+
unchecked,
|
|
1839
|
+
next: currentItem,
|
|
1840
|
+
verifiedPass: evalResult.outcome === "pass",
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
e5bAdvance(session, checklist) {
|
|
1844
|
+
const cid = session.conversation_id;
|
|
1845
|
+
const chainReset = this.reviewChainResetFields();
|
|
1846
|
+
return this.store.exclusiveWrite(() => {
|
|
1847
|
+
// Re-check E5 gate under the write lock — a concurrent stop may have
|
|
1848
|
+
// already advanced/reset the chain (confirm_left null, ICC 0).
|
|
1849
|
+
const fresh = this.store.getReviewChain(cid);
|
|
1850
|
+
const atE5 = !!fresh &&
|
|
1851
|
+
(fresh.confirm_left === 0 ||
|
|
1852
|
+
(fresh.item_confirm_complete === 1 && fresh.confirm_left === null));
|
|
1853
|
+
if (!atE5) {
|
|
1854
|
+
return { commit: false, value: null };
|
|
1855
|
+
}
|
|
1856
|
+
const lockedSession = this.store.getSession(cid);
|
|
1857
|
+
if (!lockedSession ||
|
|
1858
|
+
!sessionReviewRunnable(lockedSession, this.config.reviewScope)) {
|
|
1859
|
+
return { commit: false, value: null };
|
|
1860
|
+
}
|
|
1861
|
+
// Refresh checklist under the lock — pass-path callers may hold a stale
|
|
1862
|
+
// unchecked/next from before BEGIN IMMEDIATE (same class as E5c fail).
|
|
1863
|
+
let unchecked = checklist.unchecked;
|
|
1864
|
+
let next = checklist.next;
|
|
1865
|
+
let targets = null;
|
|
1866
|
+
const path = lockedSession.checklist_path?.trim() ?? "";
|
|
1867
|
+
const onChecklistPath = isChecklistExecuting(lockedSession) && path.length > 0;
|
|
1868
|
+
if (onChecklistPath) {
|
|
1869
|
+
const refreshed = this.parseSessionChecklist(lockedSession);
|
|
1870
|
+
if (!refreshed?.checklist) {
|
|
1871
|
+
return { commit: false, value: null };
|
|
1872
|
+
}
|
|
1873
|
+
const reviewingId = this.resolveReviewingItemId(fresh, refreshed.checklist, checklist.next?.id);
|
|
1874
|
+
targets = resolveAdvanceTargets(refreshed.checklist, reviewingId);
|
|
1875
|
+
unchecked = targets.unchecked;
|
|
1876
|
+
next = targets.current;
|
|
1877
|
+
}
|
|
1878
|
+
else {
|
|
1879
|
+
unchecked = 0;
|
|
1880
|
+
next = null;
|
|
1881
|
+
targets = null;
|
|
1882
|
+
}
|
|
1883
|
+
// Project-scope ambient/planning: end chain without checklist advance/done.
|
|
1884
|
+
if (!onChecklistPath) {
|
|
1885
|
+
if (isChecklistExecuting(lockedSession)) {
|
|
1886
|
+
// Executing with empty/missing checklist — legacy done path below.
|
|
1887
|
+
}
|
|
1888
|
+
else if (this.config.reviewScope === "project") {
|
|
1889
|
+
const completeMsg = this.render("review_complete", {
|
|
1890
|
+
total: this.config.confirmRounds,
|
|
1891
|
+
});
|
|
1892
|
+
const completeAction = {
|
|
1893
|
+
kind: "review_complete",
|
|
1894
|
+
message: completeMsg,
|
|
1895
|
+
loop: true,
|
|
1896
|
+
};
|
|
1897
|
+
this.store.upsertSession({
|
|
1898
|
+
conversation_id: cid,
|
|
1899
|
+
project_root: lockedSession.project_root,
|
|
1900
|
+
code_root: lockedSession.code_root,
|
|
1901
|
+
error_count: 0,
|
|
1902
|
+
idle_stop_count: 0,
|
|
1903
|
+
last_error: null,
|
|
1904
|
+
});
|
|
1905
|
+
this.store.updateReviewChain(cid, {
|
|
1906
|
+
...chainReset,
|
|
1907
|
+
pending_followup: completeMsg,
|
|
1908
|
+
pending_followup_at: new Date().toISOString(),
|
|
1909
|
+
pending_redeliver_at: null,
|
|
1910
|
+
chain_pending: 0,
|
|
1911
|
+
});
|
|
1912
|
+
return { commit: true, value: completeAction };
|
|
1913
|
+
}
|
|
1914
|
+
else {
|
|
1915
|
+
return { commit: false, value: null };
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
// When verify is armed (enabled + required cmds):
|
|
1919
|
+
// - foresaw done but items reappeared → abort (must re-enter e5Gate)
|
|
1920
|
+
// - verifiedPass for a specific item that changed/vanished → abort
|
|
1921
|
+
// Skip/disabled verify may freely refresh counts under the lock.
|
|
1922
|
+
const verifyArmed = this.config.verifyEnabled &&
|
|
1923
|
+
this.config.verifyCommands.some((c) => c.required === true);
|
|
1924
|
+
if (verifyArmed) {
|
|
1925
|
+
const foresawDone = checklist.unchecked === 0;
|
|
1926
|
+
if (foresawDone && unchecked > 0) {
|
|
1927
|
+
return { commit: false, value: null };
|
|
1928
|
+
}
|
|
1929
|
+
// verifiedPass foresaw checklist.next (sticky reviewing from e5Gate).
|
|
1930
|
+
const foresawId = checklist.next?.id;
|
|
1931
|
+
const activeId = targets?.current?.id ?? next?.id;
|
|
1932
|
+
if (checklist.verifiedPass &&
|
|
1933
|
+
foresawId &&
|
|
1934
|
+
activeId !== foresawId) {
|
|
1935
|
+
return { commit: false, value: null };
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
const resolved = targets ??
|
|
1939
|
+
{
|
|
1940
|
+
current: next,
|
|
1941
|
+
next: null,
|
|
1942
|
+
unchecked,
|
|
1943
|
+
};
|
|
1944
|
+
// nextId/Title = item after the reviewing item (sticky), not bare secondUnchecked.
|
|
1945
|
+
const action = this.renderAdvanceOrDone(resolved);
|
|
1946
|
+
const isAdvance = action.kind === "advance";
|
|
1947
|
+
if (isAdvance) {
|
|
1948
|
+
this.store.upsertSession({
|
|
1949
|
+
conversation_id: cid,
|
|
1950
|
+
project_root: session.project_root,
|
|
1951
|
+
code_root: session.code_root,
|
|
1952
|
+
error_count: 0,
|
|
1953
|
+
idle_stop_count: 0,
|
|
1954
|
+
last_error: null,
|
|
1955
|
+
});
|
|
1956
|
+
// chain_pending=0 (same as E0 soft advance): product edits on the next
|
|
1957
|
+
// item still arm via afterFileEdit → code_edited. Leaving pending=1
|
|
1958
|
+
// forced E3 confirm on docs-only / ignore-only turns (phantom confirm).
|
|
1959
|
+
this.store.updateReviewChain(cid, {
|
|
1960
|
+
...chainReset,
|
|
1961
|
+
// Stick the *next* item immediately so premature `[x]` before the
|
|
1962
|
+
// first product edit cannot retarget via firstUnchecked.
|
|
1963
|
+
reviewing_item_id: resolved.next?.id ?? null,
|
|
1964
|
+
pending_followup: action.message,
|
|
1965
|
+
pending_followup_at: new Date().toISOString(),
|
|
1966
|
+
pending_redeliver_at: null,
|
|
1967
|
+
chain_pending: 0,
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
else {
|
|
1971
|
+
this.store.upsertSession({
|
|
1972
|
+
conversation_id: cid,
|
|
1973
|
+
project_root: session.project_root,
|
|
1974
|
+
code_root: session.code_root,
|
|
1975
|
+
phase: "done",
|
|
1976
|
+
armed: 0,
|
|
1977
|
+
error_count: 0,
|
|
1978
|
+
idle_stop_count: 0,
|
|
1979
|
+
last_error: null,
|
|
1980
|
+
});
|
|
1981
|
+
// pending kept for first-delivery / diagnostics; chain_pending must stay 0
|
|
1982
|
+
// so a later path cannot treat the chain as still active.
|
|
1983
|
+
this.store.updateReviewChain(cid, {
|
|
1984
|
+
...chainReset,
|
|
1985
|
+
pending_followup: action.message,
|
|
1986
|
+
pending_followup_at: new Date().toISOString(),
|
|
1987
|
+
pending_redeliver_at: null,
|
|
1988
|
+
chain_pending: 0,
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
return { commit: true, value: action };
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
bumpProgress(session, _changed) {
|
|
1995
|
+
if (session.idle_stop_count > 0) {
|
|
1996
|
+
this.store.upsertSession({
|
|
1997
|
+
conversation_id: session.conversation_id,
|
|
1998
|
+
project_root: session.project_root,
|
|
1999
|
+
code_root: session.code_root,
|
|
2000
|
+
idle_stop_count: 0,
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
/**
|
|
2005
|
+
* Best-effort session bookkeeping after a followup was already committed
|
|
2006
|
+
* (pending_followup in exclusiveWrite). Must not throw into handleStop's
|
|
2007
|
+
* soft-fail catch — that would drop the committed action from the hook reply.
|
|
2008
|
+
*/
|
|
2009
|
+
afterFollowupCommitted(session, changed) {
|
|
2010
|
+
try {
|
|
2011
|
+
this.bumpProgress(session, changed);
|
|
2012
|
+
this.noteCompletedOk(session);
|
|
2013
|
+
}
|
|
2014
|
+
catch {
|
|
2015
|
+
/* keep committed followup deliverable */
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
incrementIdle(session) {
|
|
2019
|
+
const next = session.idle_stop_count + 1;
|
|
2020
|
+
if (next >= this.config.maxIdleStops) {
|
|
2021
|
+
this.store.upsertSession({
|
|
2022
|
+
conversation_id: session.conversation_id,
|
|
2023
|
+
project_root: session.project_root,
|
|
2024
|
+
code_root: session.code_root,
|
|
2025
|
+
idle_stop_count: next,
|
|
2026
|
+
paused: 1,
|
|
2027
|
+
paused_reason: "stuck",
|
|
2028
|
+
armed: 0,
|
|
2029
|
+
});
|
|
2030
|
+
// Same stop inject stuck once — caller gets verify_fix first in E5c;
|
|
2031
|
+
// for pure idle stuck, expose via checkStuckAfterStop
|
|
2032
|
+
}
|
|
2033
|
+
else {
|
|
2034
|
+
this.store.upsertSession({
|
|
2035
|
+
conversation_id: session.conversation_id,
|
|
2036
|
+
project_root: session.project_root,
|
|
2037
|
+
code_root: session.code_root,
|
|
2038
|
+
idle_stop_count: next,
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
/** After a no-progress stop that didn't inject, check stuck threshold. */
|
|
2043
|
+
checkStuck(session) {
|
|
2044
|
+
const fresh = this.store.getSession(session.conversation_id);
|
|
2045
|
+
if (!fresh)
|
|
2046
|
+
return null;
|
|
2047
|
+
if (fresh.paused === 1 && fresh.paused_reason === "stuck") {
|
|
2048
|
+
return {
|
|
2049
|
+
kind: "stuck",
|
|
2050
|
+
message: this.render("stuck", {}),
|
|
2051
|
+
loop: true,
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
return null;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
/** Apply OFF trigger side effects (v0.1). */
|
|
2058
|
+
export function applyOff(store, conversationId) {
|
|
2059
|
+
const session = store.getSession(conversationId);
|
|
2060
|
+
if (!session)
|
|
2061
|
+
return null;
|
|
2062
|
+
if (session.phase === "done") {
|
|
2063
|
+
return store.upsertSession({
|
|
2064
|
+
conversation_id: conversationId,
|
|
2065
|
+
project_root: session.project_root,
|
|
2066
|
+
code_root: session.code_root,
|
|
2067
|
+
phase: "idle",
|
|
2068
|
+
armed: 0,
|
|
2069
|
+
paused: 0,
|
|
2070
|
+
paused_reason: null,
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
// planning / executing checklist, or project-scope ambient (idle+armed)
|
|
2074
|
+
const ambientArmed = session.phase === "idle" && session.armed === 1 && session.paused === 0;
|
|
2075
|
+
if (session.phase === "planning" ||
|
|
2076
|
+
session.phase === "executing" ||
|
|
2077
|
+
ambientArmed) {
|
|
2078
|
+
const wasPaused = session.paused === 1;
|
|
2079
|
+
let pausedReason = session.paused_reason;
|
|
2080
|
+
if (!wasPaused) {
|
|
2081
|
+
pausedReason =
|
|
2082
|
+
session.phase === "executing" || session.phase === "idle"
|
|
2083
|
+
? "human_gate"
|
|
2084
|
+
: null;
|
|
2085
|
+
}
|
|
2086
|
+
// already paused → keep original paused_reason
|
|
2087
|
+
return store.upsertSession({
|
|
2088
|
+
conversation_id: conversationId,
|
|
2089
|
+
project_root: session.project_root,
|
|
2090
|
+
code_root: session.code_root,
|
|
2091
|
+
armed: 0,
|
|
2092
|
+
paused: 1,
|
|
2093
|
+
paused_reason: pausedReason,
|
|
2094
|
+
// phase unchanged; review chain untouched
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
return session;
|
|
2098
|
+
}
|
|
2099
|
+
/** Apply ON trigger side effects (v0.1). */
|
|
2100
|
+
export function applyOn(store, conversationId, projectRoot, opts) {
|
|
2101
|
+
const root = normalizeProjectRoot(store.projectRoot) ??
|
|
2102
|
+
normalizeProjectRoot(projectRoot);
|
|
2103
|
+
if (!root) {
|
|
2104
|
+
return { ok: false, userMessage: "Invalid project root." };
|
|
2105
|
+
}
|
|
2106
|
+
projectRoot = root;
|
|
2107
|
+
const session = store.getSession(conversationId);
|
|
2108
|
+
if (session?.phase === "executing") {
|
|
2109
|
+
return {
|
|
2110
|
+
ok: false,
|
|
2111
|
+
userMessage: "Autopilot is executing. Send Autopilot OFF, REPLAN, or RESUME before ON.",
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
// Explicit slug (incl. "") must pass the shared gate — do not use truthiness
|
|
2115
|
+
// (empty string would otherwise skip validation and bind track_id "").
|
|
2116
|
+
if (opts?.slug !== undefined && !isSafeTrackSlug(opts.slug)) {
|
|
2117
|
+
const raw = typeof opts.slug === "string" ? opts.slug : String(opts.slug);
|
|
2118
|
+
return {
|
|
2119
|
+
ok: false,
|
|
2120
|
+
userMessage: `Invalid track slug "${sanitizeSessionDisplayText(raw).slice(0, 64)}".`,
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
const trackId = opts?.slug ?? session?.track_id ?? "_pending";
|
|
2124
|
+
if (session?.phase === "done") {
|
|
2125
|
+
const s = store.upsertSession({
|
|
2126
|
+
conversation_id: conversationId,
|
|
2127
|
+
project_root: projectRoot,
|
|
2128
|
+
code_root: projectRoot,
|
|
2129
|
+
phase: "planning",
|
|
2130
|
+
armed: 0,
|
|
2131
|
+
paused: 0,
|
|
2132
|
+
paused_reason: null,
|
|
2133
|
+
track_id: opts?.slug ?? session.track_id,
|
|
2134
|
+
platform: session.platform,
|
|
2135
|
+
});
|
|
2136
|
+
return { ok: true, session: s };
|
|
2137
|
+
}
|
|
2138
|
+
const s = store.upsertSession({
|
|
2139
|
+
conversation_id: conversationId,
|
|
2140
|
+
project_root: projectRoot,
|
|
2141
|
+
code_root: projectRoot,
|
|
2142
|
+
platform: session?.platform ?? "cursor",
|
|
2143
|
+
phase: "planning",
|
|
2144
|
+
armed: 0,
|
|
2145
|
+
paused: 0,
|
|
2146
|
+
paused_reason: null,
|
|
2147
|
+
track_id: trackId,
|
|
2148
|
+
checklist_path: session?.checklist_path ?? "",
|
|
2149
|
+
});
|
|
2150
|
+
return { ok: true, session: s };
|
|
2151
|
+
}
|
|
2152
|
+
function finishLocalResume(store, conversationId, session) {
|
|
2153
|
+
// Speculative read + checklist I/O outside the write lock (never block other
|
|
2154
|
+
// writers on disk). Re-check live row under IMMEDIATE before applying.
|
|
2155
|
+
const snap = store.getSession(conversationId);
|
|
2156
|
+
if (!snap) {
|
|
2157
|
+
return null;
|
|
2158
|
+
}
|
|
2159
|
+
let armedOnUnpauseExecuting;
|
|
2160
|
+
if (snap.paused === 1 && snap.phase === "executing") {
|
|
2161
|
+
armedOnUnpauseExecuting = 0;
|
|
2162
|
+
if (snap.checklist_path) {
|
|
2163
|
+
const root = normalizeProjectRoot(store.projectRoot);
|
|
2164
|
+
if (root && isRealpathInsideProject(root, snap.checklist_path)) {
|
|
2165
|
+
try {
|
|
2166
|
+
armedOnUnpauseExecuting =
|
|
2167
|
+
countUnchecked(parseChecklist(snap.checklist_path, {
|
|
2168
|
+
projectRoot: root,
|
|
2169
|
+
})) > 0
|
|
2170
|
+
? 1
|
|
2171
|
+
: 0;
|
|
2172
|
+
}
|
|
2173
|
+
catch {
|
|
2174
|
+
armedOnUnpauseExecuting = 0;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
return store.exclusiveWrite(() => {
|
|
2180
|
+
const live = store.getSession(conversationId);
|
|
2181
|
+
if (!live) {
|
|
2182
|
+
return { commit: false, value: null };
|
|
2183
|
+
}
|
|
2184
|
+
const patch = {
|
|
2185
|
+
conversation_id: conversationId,
|
|
2186
|
+
project_root: live.project_root || session.project_root,
|
|
2187
|
+
code_root: live.code_root || session.code_root,
|
|
2188
|
+
};
|
|
2189
|
+
const phase = live.phase;
|
|
2190
|
+
const paused = live.paused;
|
|
2191
|
+
if (paused === 1) {
|
|
2192
|
+
patch.paused = 0;
|
|
2193
|
+
patch.paused_reason = null;
|
|
2194
|
+
patch.error_count = 0;
|
|
2195
|
+
patch.idle_stop_count = 0;
|
|
2196
|
+
if (phase === "executing") {
|
|
2197
|
+
// Use precomputed armed only when phase/checklist still match the snap;
|
|
2198
|
+
// otherwise fail closed (armed=0) rather than parsing under the lock.
|
|
2199
|
+
if (armedOnUnpauseExecuting !== undefined &&
|
|
2200
|
+
snap.phase === "executing" &&
|
|
2201
|
+
snap.checklist_path === live.checklist_path) {
|
|
2202
|
+
patch.armed = armedOnUnpauseExecuting;
|
|
2203
|
+
}
|
|
2204
|
+
else {
|
|
2205
|
+
patch.armed = 0;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
if (phase === "idle") {
|
|
2209
|
+
patch.armed = 1;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
if (phase === "planning") {
|
|
2213
|
+
patch.armed = 0;
|
|
2214
|
+
}
|
|
2215
|
+
const updated = store.upsertSession(patch);
|
|
2216
|
+
try {
|
|
2217
|
+
store.clearPendingFollowupIf(conversationId, isRecoverOrStuckFollowupMessage);
|
|
2218
|
+
}
|
|
2219
|
+
catch {
|
|
2220
|
+
/* best-effort */
|
|
2221
|
+
}
|
|
2222
|
+
return { commit: true, value: updated };
|
|
2223
|
+
});
|
|
2224
|
+
}
|
|
2225
|
+
/**
|
|
2226
|
+
* Apply RESUME: unpause local session, or claim another conversation's track
|
|
2227
|
+
* onto this conversation_id (preserving review_chains).
|
|
2228
|
+
*/
|
|
2229
|
+
export function applyResume(store, conversationId, opts) {
|
|
2230
|
+
if (!store.isConversationIdOk(conversationId)) {
|
|
2231
|
+
return { ok: false, userMessage: "Invalid conversation id." };
|
|
2232
|
+
}
|
|
2233
|
+
let session = store.getSession(conversationId);
|
|
2234
|
+
const slug = opts?.slug?.trim() || undefined;
|
|
2235
|
+
// Fail closed on illegal slug before localMatches / claim (idempotent retry-safe).
|
|
2236
|
+
if (slug && !isSafeTrackSlug(slug)) {
|
|
2237
|
+
return {
|
|
2238
|
+
ok: false,
|
|
2239
|
+
userMessage: `Invalid track slug "${sanitizeSessionDisplayText(slug).slice(0, 64)}".`,
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
// Only planning/executing "own" the conversation for local resume.
|
|
2243
|
+
// idle (incl. ambient revive with a stale track_id) and done go through
|
|
2244
|
+
// claimSessionInto so a foreign executing session for the same slug can win.
|
|
2245
|
+
const localMatches = !!session &&
|
|
2246
|
+
(session.phase === "executing" || session.phase === "planning") &&
|
|
2247
|
+
(!slug || session.track_id === slug);
|
|
2248
|
+
if (!localMatches) {
|
|
2249
|
+
const claimed = store.claimSessionInto(conversationId, { slug });
|
|
2250
|
+
if (!claimed.ok)
|
|
2251
|
+
return claimed;
|
|
2252
|
+
session = claimed.session;
|
|
2253
|
+
}
|
|
2254
|
+
if (!session) {
|
|
2255
|
+
return { ok: true, session: null };
|
|
2256
|
+
}
|
|
2257
|
+
const updated = finishLocalResume(store, conversationId, session);
|
|
2258
|
+
if (!updated) {
|
|
2259
|
+
return {
|
|
2260
|
+
ok: false,
|
|
2261
|
+
userMessage: "Session moved concurrently; retry Autopilot RESUME.",
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
return {
|
|
2265
|
+
ok: true,
|
|
2266
|
+
session: updated,
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
/** resume_review: only chain_pending=1 */
|
|
2270
|
+
export function applyResumeReview(store, conversationId) {
|
|
2271
|
+
store.setChainPending(conversationId);
|
|
2272
|
+
}
|
|
2273
|
+
//# sourceMappingURL=review-engine.js.map
|