@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,1091 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { migrate, getLatestSchemaVersion, parseSchemaVersionValue } from "./migrate.js";
|
|
4
|
+
import { isLexicallyInsideProject, isRealpathInsideProject, normalizeProjectRoot, } from "./project-path.js";
|
|
5
|
+
import { openDatabase } from "./sqlite.js";
|
|
6
|
+
import { isSafeTrackSlug } from "./track-slug.js";
|
|
7
|
+
function nowIso() {
|
|
8
|
+
return new Date().toISOString();
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Next `pending_followup_at` value. When `nowIso()` collides with `previousAt`,
|
|
12
|
+
* advance ≥1ms so claim/CAS ownership stamps stay unique (same-ms refresh must
|
|
13
|
+
* not look like the prior claim).
|
|
14
|
+
*/
|
|
15
|
+
function nextFollowupTimestamp(previousAt) {
|
|
16
|
+
let ts = nowIso();
|
|
17
|
+
const prev = previousAt ?? "";
|
|
18
|
+
if (prev && ts === prev) {
|
|
19
|
+
const t = Date.parse(prev);
|
|
20
|
+
ts = new Date((Number.isNaN(t) ? Date.now() : t) + 1).toISOString();
|
|
21
|
+
}
|
|
22
|
+
return ts;
|
|
23
|
+
}
|
|
24
|
+
/** Stable short id for CLI tables (first 8 chars of dehyphenated conversation_id). */
|
|
25
|
+
export function shortConversationId(conversationId) {
|
|
26
|
+
return conversationId.replace(/-/g, "").slice(0, 8);
|
|
27
|
+
}
|
|
28
|
+
/** Max length for user/platform session titles (characters). */
|
|
29
|
+
export const SESSION_TITLE_MAX_LENGTH = 200;
|
|
30
|
+
const TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
31
|
+
/** Normalize and validate a session title; throws on empty/too long/controls. */
|
|
32
|
+
export function normalizeSessionTitle(title) {
|
|
33
|
+
if (typeof title !== "string") {
|
|
34
|
+
throw new Error("Title must be a non-empty string");
|
|
35
|
+
}
|
|
36
|
+
const trimmed = title.trim();
|
|
37
|
+
if (!trimmed) {
|
|
38
|
+
throw new Error("Title must be a non-empty string");
|
|
39
|
+
}
|
|
40
|
+
if (TITLE_CONTROL_CHARS.test(trimmed)) {
|
|
41
|
+
throw new Error("Title must not contain control characters");
|
|
42
|
+
}
|
|
43
|
+
if (trimmed.length > SESSION_TITLE_MAX_LENGTH) {
|
|
44
|
+
throw new Error(`Title exceeds ${SESSION_TITLE_MAX_LENGTH} characters`);
|
|
45
|
+
}
|
|
46
|
+
return trimmed;
|
|
47
|
+
}
|
|
48
|
+
/** Collapse controls for single-line CLI table cells (platform titles may be dirty). */
|
|
49
|
+
export function sanitizeSessionDisplayText(text) {
|
|
50
|
+
return text.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/ +/g, " ").trim();
|
|
51
|
+
}
|
|
52
|
+
export class StateStore {
|
|
53
|
+
db;
|
|
54
|
+
projectRoot;
|
|
55
|
+
writeDepth = 0;
|
|
56
|
+
constructor(projectRoot, dbPath) {
|
|
57
|
+
// Trim before resolve — padded absolute roots become cwd-relative otherwise.
|
|
58
|
+
const root = normalizeProjectRoot(projectRoot);
|
|
59
|
+
if (!root) {
|
|
60
|
+
throw new Error("Invalid project root");
|
|
61
|
+
}
|
|
62
|
+
this.projectRoot = path.resolve(root);
|
|
63
|
+
const resolved = dbPath ?? path.join(this.projectRoot, ".autopilot", "state.db");
|
|
64
|
+
this.db = openDatabase(resolved);
|
|
65
|
+
try {
|
|
66
|
+
// Wait on locks so concurrent BEGIN IMMEDIATE (one_executor) can serialize
|
|
67
|
+
// instead of failing immediately with SQLITE_BUSY.
|
|
68
|
+
this.db.pragma("busy_timeout = 5000");
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* node:sqlite may ignore */
|
|
72
|
+
}
|
|
73
|
+
if (resolved !== ":memory:") {
|
|
74
|
+
try {
|
|
75
|
+
this.db.pragma("journal_mode = WAL");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* node:sqlite may ignore */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
migrate(this.db);
|
|
82
|
+
}
|
|
83
|
+
close() {
|
|
84
|
+
this.db.close();
|
|
85
|
+
}
|
|
86
|
+
getSchemaVersion() {
|
|
87
|
+
const row = this.db
|
|
88
|
+
.prepare("SELECT value FROM _schema_meta WHERE key = 'schema_version'")
|
|
89
|
+
.get();
|
|
90
|
+
return parseSchemaVersionValue(row?.value);
|
|
91
|
+
}
|
|
92
|
+
ensureReviewChain(conversationId) {
|
|
93
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
94
|
+
throw new Error("Invalid conversation id");
|
|
95
|
+
}
|
|
96
|
+
const ts = nowIso();
|
|
97
|
+
// INSERT OR IGNORE + EXISTS: refuse orphans without a session; concurrent
|
|
98
|
+
// creators are idempotent (plain INSERT would UNIQUE-fail on the PK).
|
|
99
|
+
this.insertReviewChainIfSession(conversationId, ts);
|
|
100
|
+
// Covers: no session, purge race after insert, and stale orphan chain rows.
|
|
101
|
+
if (!this.getSession(conversationId)) {
|
|
102
|
+
// Only delete while still session-less (avoids wiping a concurrent recreate).
|
|
103
|
+
this.db
|
|
104
|
+
.prepare(`DELETE FROM review_chains
|
|
105
|
+
WHERE conversation_id = ?
|
|
106
|
+
AND NOT EXISTS (SELECT 1 FROM sessions WHERE conversation_id = ?)`)
|
|
107
|
+
.run(conversationId, conversationId);
|
|
108
|
+
throw new Error("No session for conversation");
|
|
109
|
+
}
|
|
110
|
+
// Re-read after session check so a concurrent INSERT is not missed.
|
|
111
|
+
let ensured = this.getReviewChain(conversationId);
|
|
112
|
+
if (!ensured) {
|
|
113
|
+
// Session present but chain gone (e.g. purge deleted chain first) — retry once.
|
|
114
|
+
this.insertReviewChainIfSession(conversationId, ts);
|
|
115
|
+
ensured = this.getReviewChain(conversationId);
|
|
116
|
+
}
|
|
117
|
+
if (!ensured) {
|
|
118
|
+
throw new Error("No session for conversation");
|
|
119
|
+
}
|
|
120
|
+
return ensured;
|
|
121
|
+
}
|
|
122
|
+
/** Reject blank, padded, or control-bearing conversation ids (align resolveSessionId). */
|
|
123
|
+
isInvalidConversationId(conversationId) {
|
|
124
|
+
return (typeof conversationId !== "string" ||
|
|
125
|
+
!conversationId.trim() ||
|
|
126
|
+
conversationId !== conversationId.trim() ||
|
|
127
|
+
/[\u0000-\u001f\u007f]/.test(conversationId));
|
|
128
|
+
}
|
|
129
|
+
/** Public gate for hooks / stop handlers (fail-soft before any mutation). */
|
|
130
|
+
isConversationIdOk(conversationId) {
|
|
131
|
+
return !this.isInvalidConversationId(conversationId);
|
|
132
|
+
}
|
|
133
|
+
insertReviewChainIfSession(conversationId, ts) {
|
|
134
|
+
this.db
|
|
135
|
+
.prepare(`INSERT OR IGNORE INTO review_chains (conversation_id, fix_round, confirm_left, chain_pending, code_edited, item_confirm_complete, updated_at)
|
|
136
|
+
SELECT ?, 0, NULL, 0, 0, 0, ?
|
|
137
|
+
WHERE EXISTS (SELECT 1 FROM sessions WHERE conversation_id = ?)`)
|
|
138
|
+
.run(conversationId, ts, conversationId);
|
|
139
|
+
}
|
|
140
|
+
getReviewChain(conversationId) {
|
|
141
|
+
const row = this.db
|
|
142
|
+
.prepare("SELECT * FROM review_chains WHERE conversation_id = ?")
|
|
143
|
+
.get(conversationId);
|
|
144
|
+
if (!row)
|
|
145
|
+
return null;
|
|
146
|
+
return {
|
|
147
|
+
...row,
|
|
148
|
+
reviewing_item_id: row.reviewing_item_id ?? null,
|
|
149
|
+
pending_followup: row.pending_followup ?? null,
|
|
150
|
+
pending_followup_at: row.pending_followup_at ?? null,
|
|
151
|
+
pending_redeliver_at: row.pending_redeliver_at ?? null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
getSession(conversationId) {
|
|
155
|
+
return (this.db
|
|
156
|
+
.prepare("SELECT * FROM sessions WHERE conversation_id = ?")
|
|
157
|
+
.get(conversationId) ?? null);
|
|
158
|
+
}
|
|
159
|
+
listSessions() {
|
|
160
|
+
return this.db
|
|
161
|
+
.prepare("SELECT * FROM sessions ORDER BY last_active_at DESC, conversation_id ASC")
|
|
162
|
+
.all();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Resolve a full conversation_id or a unique prefix / short id (first 8 hex-ish chars).
|
|
166
|
+
*/
|
|
167
|
+
resolveSessionId(query) {
|
|
168
|
+
const q = query.trim();
|
|
169
|
+
if (!q) {
|
|
170
|
+
return { ok: false, error: "Session id required" };
|
|
171
|
+
}
|
|
172
|
+
if (/[\u0000-\u001f\u007f]/.test(q)) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error: "Session id must not contain control characters",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (this.getSession(q)) {
|
|
179
|
+
return { ok: true, id: q };
|
|
180
|
+
}
|
|
181
|
+
const matches = this.listSessions().filter((s) => {
|
|
182
|
+
const id = s.conversation_id;
|
|
183
|
+
return id.startsWith(q) || shortConversationId(id) === q;
|
|
184
|
+
});
|
|
185
|
+
if (matches.length === 1) {
|
|
186
|
+
return { ok: true, id: matches[0].conversation_id };
|
|
187
|
+
}
|
|
188
|
+
if (matches.length === 0) {
|
|
189
|
+
return { ok: false, error: `No session matching "${q}"` };
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
error: `Ambiguous id "${q}" matches ${matches.length} sessions; use a longer prefix`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
renameSession(conversationId, title) {
|
|
197
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const trimmed = normalizeSessionTitle(title);
|
|
201
|
+
return this.exclusiveWrite(() => {
|
|
202
|
+
if (!this.getSession(conversationId)) {
|
|
203
|
+
return { commit: false, value: null };
|
|
204
|
+
}
|
|
205
|
+
const ts = nowIso();
|
|
206
|
+
this.db
|
|
207
|
+
.prepare(`UPDATE sessions SET
|
|
208
|
+
session_title = ?, session_title_source = 'user', title_updated_at = ?,
|
|
209
|
+
updated_at = ?
|
|
210
|
+
WHERE conversation_id = ?`)
|
|
211
|
+
.run(trimmed, ts, ts, conversationId);
|
|
212
|
+
return { commit: true, value: this.getSession(conversationId) };
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/** Delete session row and its review_chains row (atomic). */
|
|
216
|
+
purgeSession(conversationId,
|
|
217
|
+
/**
|
|
218
|
+
* Optional guard evaluated inside the write transaction (re-check after races).
|
|
219
|
+
* Return false to skip delete without error.
|
|
220
|
+
*/
|
|
221
|
+
ifRow) {
|
|
222
|
+
return this.exclusiveWrite(() => {
|
|
223
|
+
const row = this.getSession(conversationId);
|
|
224
|
+
if (!row) {
|
|
225
|
+
return { commit: false, value: false };
|
|
226
|
+
}
|
|
227
|
+
if (ifRow && !ifRow(row)) {
|
|
228
|
+
return { commit: false, value: false };
|
|
229
|
+
}
|
|
230
|
+
this.db
|
|
231
|
+
.prepare("DELETE FROM review_chains WHERE conversation_id = ?")
|
|
232
|
+
.run(conversationId);
|
|
233
|
+
this.db
|
|
234
|
+
.prepare("DELETE FROM sessions WHERE conversation_id = ?")
|
|
235
|
+
.run(conversationId);
|
|
236
|
+
return { commit: true, value: true };
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Reset review chain fields (same as REPLAN / fresh applyRun review reset).
|
|
241
|
+
* Clears pending_followup* so a later stop cannot redeliver a stale prompt and
|
|
242
|
+
* resurrect chain_pending after the caller believed the chain was wiped.
|
|
243
|
+
* Session row kept. Atomic: refuses orphan review_chains if session was purged.
|
|
244
|
+
*/
|
|
245
|
+
resetReviewChain(conversationId) {
|
|
246
|
+
return this.exclusiveWrite(() => {
|
|
247
|
+
if (!this.getSession(conversationId)) {
|
|
248
|
+
return { commit: false, value: false };
|
|
249
|
+
}
|
|
250
|
+
this.updateReviewChain(conversationId, {
|
|
251
|
+
fix_round: 0,
|
|
252
|
+
confirm_left: null,
|
|
253
|
+
chain_pending: 0,
|
|
254
|
+
code_edited: 0,
|
|
255
|
+
item_confirm_complete: 0,
|
|
256
|
+
reviewing_item_id: null,
|
|
257
|
+
pending_followup: null,
|
|
258
|
+
pending_followup_at: null,
|
|
259
|
+
pending_redeliver_at: null,
|
|
260
|
+
});
|
|
261
|
+
return { commit: true, value: true };
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Pin untrusted session roots to this store's projectRoot.
|
|
266
|
+
* Otherwise a caller could write project_root=/evil and later containment
|
|
267
|
+
* checks that trust session.project_root would pass for outside files.
|
|
268
|
+
* code_root may be a descendant (future worktree); project_root must match.
|
|
269
|
+
* Relative paths resolve against the store root (not process.cwd()).
|
|
270
|
+
*/
|
|
271
|
+
sanitizeSessionRoot(raw, opts) {
|
|
272
|
+
const n = normalizeProjectRoot(raw);
|
|
273
|
+
if (!n)
|
|
274
|
+
return this.projectRoot;
|
|
275
|
+
const resolved = path.isAbsolute(n)
|
|
276
|
+
? path.resolve(n)
|
|
277
|
+
: path.resolve(this.projectRoot, n);
|
|
278
|
+
if (resolved === this.projectRoot)
|
|
279
|
+
return this.projectRoot;
|
|
280
|
+
if (opts?.allowDescendant &&
|
|
281
|
+
isLexicallyInsideProject(this.projectRoot, resolved)) {
|
|
282
|
+
return resolved;
|
|
283
|
+
}
|
|
284
|
+
return this.projectRoot;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Refuse checklist_path that escapes the store project (absolute outside or
|
|
288
|
+
* relative that resolves outside). Missing paths kept only if lexically inside.
|
|
289
|
+
* Relative inputs stay relative when allowed (callers/tests rely on that form).
|
|
290
|
+
*/
|
|
291
|
+
sanitizeChecklistPath(raw) {
|
|
292
|
+
if (typeof raw !== "string" || !raw || raw.includes("\0"))
|
|
293
|
+
return "";
|
|
294
|
+
if (path.isAbsolute(raw)) {
|
|
295
|
+
const abs = path.resolve(raw);
|
|
296
|
+
try {
|
|
297
|
+
fs.lstatSync(abs);
|
|
298
|
+
return isRealpathInsideProject(this.projectRoot, abs) ? abs : "";
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return isLexicallyInsideProject(this.projectRoot, abs) ? abs : "";
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const abs = path.resolve(this.projectRoot, raw);
|
|
305
|
+
try {
|
|
306
|
+
fs.lstatSync(abs);
|
|
307
|
+
return isRealpathInsideProject(this.projectRoot, abs) ? raw : "";
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return isLexicallyInsideProject(this.projectRoot, abs) ? raw : "";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
upsertSession(partial) {
|
|
314
|
+
if (this.isInvalidConversationId(partial.conversation_id)) {
|
|
315
|
+
throw new Error("Invalid conversation id");
|
|
316
|
+
}
|
|
317
|
+
const ts = nowIso();
|
|
318
|
+
// Heal padded/NUL + refuse roots that escape this store's project.
|
|
319
|
+
const projectRoot = this.sanitizeSessionRoot(partial.project_root);
|
|
320
|
+
const codeRoot = this.sanitizeSessionRoot(partial.code_root, {
|
|
321
|
+
allowDescendant: true,
|
|
322
|
+
});
|
|
323
|
+
const checklistPath = partial.checklist_path !== undefined
|
|
324
|
+
? this.sanitizeChecklistPath(partial.checklist_path)
|
|
325
|
+
: undefined;
|
|
326
|
+
const existing = this.getSession(partial.conversation_id);
|
|
327
|
+
if (!existing) {
|
|
328
|
+
// Only renameSession may grant source=user (validated title). Upsert must not.
|
|
329
|
+
const insertSource = partial.session_title_source === "user"
|
|
330
|
+
? "platform"
|
|
331
|
+
: (partial.session_title_source ?? null);
|
|
332
|
+
this.db
|
|
333
|
+
.prepare(`INSERT INTO sessions (
|
|
334
|
+
conversation_id, platform, session_title, session_title_source, title_updated_at,
|
|
335
|
+
track_id, track_title, checklist_path, phase, armed, paused,
|
|
336
|
+
paused_reason, pending_action, track_candidates_json,
|
|
337
|
+
project_root, code_root, last_active_at, updated_at
|
|
338
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
339
|
+
.run(partial.conversation_id, partial.platform ?? "cursor", partial.session_title ?? null, insertSource, partial.title_updated_at ?? null, partial.track_id ?? "_pending", partial.track_title ?? null, checklistPath ?? "", partial.phase ?? "idle", partial.armed ?? 0, partial.paused ?? 0, partial.paused_reason ?? null, partial.pending_action ?? null, partial.track_candidates_json ?? null, projectRoot, codeRoot, ts, ts);
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const merged = { ...existing, ...partial, updated_at: ts, last_active_at: ts };
|
|
343
|
+
// Preserve user titles at UPDATE time (not via a stale in-memory read):
|
|
344
|
+
// concurrent renameSession can flip source to 'user' between getSession and
|
|
345
|
+
// this write; CASE keeps the row's current user title fields atomically.
|
|
346
|
+
// Upsert must not elevate source to 'user' either (ELSE bind coerced).
|
|
347
|
+
const upsertSource = merged.session_title_source === "user"
|
|
348
|
+
? "platform"
|
|
349
|
+
: merged.session_title_source;
|
|
350
|
+
const nextProjectRoot = this.sanitizeSessionRoot(merged.project_root);
|
|
351
|
+
const nextCodeRoot = this.sanitizeSessionRoot(merged.code_root, {
|
|
352
|
+
allowDescendant: true,
|
|
353
|
+
});
|
|
354
|
+
const nextChecklistPath = this.sanitizeChecklistPath(merged.checklist_path);
|
|
355
|
+
this.db
|
|
356
|
+
.prepare(`UPDATE sessions SET
|
|
357
|
+
platform = ?,
|
|
358
|
+
session_title = CASE WHEN session_title_source = 'user' THEN session_title ELSE ? END,
|
|
359
|
+
session_title_source = CASE WHEN session_title_source = 'user' THEN session_title_source ELSE ? END,
|
|
360
|
+
title_updated_at = CASE WHEN session_title_source = 'user' THEN title_updated_at ELSE ? END,
|
|
361
|
+
track_id = ?, track_title = ?, checklist_path = ?,
|
|
362
|
+
phase = ?, armed = ?, paused = ?, paused_reason = ?, pending_action = ?,
|
|
363
|
+
track_candidates_json = ?, project_root = ?, code_root = ?,
|
|
364
|
+
error_count = ?, idle_stop_count = ?, last_active_at = ?, updated_at = ?
|
|
365
|
+
WHERE conversation_id = ?`)
|
|
366
|
+
.run(merged.platform, merged.session_title, upsertSource, merged.title_updated_at, merged.track_id, merged.track_title, nextChecklistPath, merged.phase, merged.armed, merged.paused, merged.paused_reason, merged.pending_action, merged.track_candidates_json, nextProjectRoot, nextCodeRoot, merged.error_count, merged.idle_stop_count, merged.last_active_at, merged.updated_at, partial.conversation_id);
|
|
367
|
+
}
|
|
368
|
+
return this.getSession(partial.conversation_id);
|
|
369
|
+
}
|
|
370
|
+
updateReviewChain(conversationId, patch) {
|
|
371
|
+
// Use ensure's return — avoids a second read that can race to null mid-purge.
|
|
372
|
+
const current = this.ensureReviewChain(conversationId);
|
|
373
|
+
const merged = { ...current, ...patch, updated_at: nowIso() };
|
|
374
|
+
// Align with savePendingFollowup: blank/NUL must not stay redeliverable.
|
|
375
|
+
// Also drop chain_pending so a rejected pending cannot leave the review loop armed.
|
|
376
|
+
if (typeof merged.pending_followup === "string" &&
|
|
377
|
+
(!merged.pending_followup.trim() || merged.pending_followup.includes("\0"))) {
|
|
378
|
+
merged.pending_followup = null;
|
|
379
|
+
merged.pending_followup_at = null;
|
|
380
|
+
merged.pending_redeliver_at = null;
|
|
381
|
+
merged.chain_pending = 0;
|
|
382
|
+
}
|
|
383
|
+
if (typeof merged.reviewing_item_id === "string" &&
|
|
384
|
+
(!merged.reviewing_item_id.trim() || merged.reviewing_item_id.includes("\0"))) {
|
|
385
|
+
merged.reviewing_item_id = null;
|
|
386
|
+
}
|
|
387
|
+
const result = this.db
|
|
388
|
+
.prepare(`UPDATE review_chains SET
|
|
389
|
+
fix_round = ?, confirm_left = ?, chain_pending = ?, code_edited = ?,
|
|
390
|
+
item_confirm_complete = ?, reviewing_item_id = ?,
|
|
391
|
+
pending_followup = ?, pending_followup_at = ?, pending_redeliver_at = ?,
|
|
392
|
+
updated_at = ?
|
|
393
|
+
WHERE conversation_id = ?
|
|
394
|
+
AND EXISTS (SELECT 1 FROM sessions WHERE conversation_id = ?)`)
|
|
395
|
+
.run(merged.fix_round, merged.confirm_left, merged.chain_pending, merged.code_edited, merged.item_confirm_complete, merged.reviewing_item_id, merged.pending_followup, merged.pending_followup_at, merged.pending_redeliver_at, merged.updated_at, conversationId, conversationId);
|
|
396
|
+
// Purge race after ensure: do not leave a silently-updated orphan chain.
|
|
397
|
+
if (result.changes === 0) {
|
|
398
|
+
throw new Error("No session for conversation");
|
|
399
|
+
}
|
|
400
|
+
const updated = this.getReviewChain(conversationId);
|
|
401
|
+
if (!updated) {
|
|
402
|
+
throw new Error("No session for conversation");
|
|
403
|
+
}
|
|
404
|
+
return updated;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* E1: product edit → code_edited=1.
|
|
408
|
+
* Sticky `reviewing_item_id`: set only when empty so a premature checklist `[x]`
|
|
409
|
+
* cannot retarget the item under review mid-chain.
|
|
410
|
+
*
|
|
411
|
+
* When `reviewingItemId` is a function, it runs **inside** the write lock against
|
|
412
|
+
* the live chain row so concurrent advance/neutralize cannot TOCTOU a stale
|
|
413
|
+
* pending/firstUnchecked snapshot from outside the lock.
|
|
414
|
+
*/
|
|
415
|
+
markCodeEdited(conversationId, reviewingItemId) {
|
|
416
|
+
this.withSessionChainWrite(conversationId, () => {
|
|
417
|
+
this.ensureReviewChain(conversationId);
|
|
418
|
+
let raw = "";
|
|
419
|
+
if (typeof reviewingItemId === "function") {
|
|
420
|
+
try {
|
|
421
|
+
const live = this.getReviewChain(conversationId);
|
|
422
|
+
if (live) {
|
|
423
|
+
const resolved = reviewingItemId(live);
|
|
424
|
+
raw = typeof resolved === "string" ? resolved.trim() : "";
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// Resolver must not skip arming code_edited (partial-failure safe).
|
|
429
|
+
raw = "";
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else if (typeof reviewingItemId === "string") {
|
|
433
|
+
raw = reviewingItemId.trim();
|
|
434
|
+
}
|
|
435
|
+
const itemId = raw && !raw.includes("\0") ? raw : "";
|
|
436
|
+
// Column-only update — avoid read-merge-write clobbering concurrent E4/pending.
|
|
437
|
+
this.db
|
|
438
|
+
.prepare(`UPDATE review_chains SET
|
|
439
|
+
code_edited = 1,
|
|
440
|
+
reviewing_item_id = CASE
|
|
441
|
+
WHEN (reviewing_item_id IS NULL OR trim(reviewing_item_id) = '')
|
|
442
|
+
AND ? != ''
|
|
443
|
+
THEN ?
|
|
444
|
+
ELSE reviewing_item_id
|
|
445
|
+
END,
|
|
446
|
+
updated_at = ?
|
|
447
|
+
WHERE conversation_id = ?
|
|
448
|
+
AND EXISTS (SELECT 1 FROM sessions WHERE conversation_id = ?)`)
|
|
449
|
+
.run(itemId, itemId, nowIso(), conversationId, conversationId);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Drop sticky code_edited without touching pending/confirm (Stop/abort path).
|
|
454
|
+
* Column-only like clearChainPending — no ensure/merge; missing chain → no-op.
|
|
455
|
+
*/
|
|
456
|
+
clearCodeEdited(conversationId) {
|
|
457
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
this.db
|
|
461
|
+
.prepare(`UPDATE review_chains SET code_edited = 0, updated_at = ?
|
|
462
|
+
WHERE conversation_id = ?`)
|
|
463
|
+
.run(nowIso(), conversationId);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* E8: user ordinary chat clears the in-chain flag only.
|
|
467
|
+
* Do NOT wipe pending_followup* — undelivered automation must still redeliver;
|
|
468
|
+
* clearing pending here would let the next stop advance confirm_left (skip a lens).
|
|
469
|
+
* Column-only UPDATE (no ensure/merge): missing chain → no-op; concurrent stop
|
|
470
|
+
* cannot lose confirm_left/pending via stale read-merge-write.
|
|
471
|
+
*/
|
|
472
|
+
clearChainPending(conversationId) {
|
|
473
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
this.db
|
|
477
|
+
.prepare(`UPDATE review_chains SET chain_pending = 0, updated_at = ? WHERE conversation_id = ?`)
|
|
478
|
+
.run(nowIso(), conversationId);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Column-only redeliver hold (recover claim sleep). Also clears chain_pending so
|
|
482
|
+
* armChain=false recover cannot leave a phantom E3 arm. Avoids updateReviewChain
|
|
483
|
+
* read-merge-write clobbering confirm_left / pending under concurrency.
|
|
484
|
+
*/
|
|
485
|
+
setPendingRedeliverHold(conversationId, at) {
|
|
486
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const stamp = typeof at === "string" ? at.trim() : "";
|
|
490
|
+
if (!stamp || stamp.includes("\0")) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
this.db
|
|
494
|
+
.prepare(`UPDATE review_chains SET
|
|
495
|
+
pending_redeliver_at = ?, chain_pending = 0, updated_at = ?
|
|
496
|
+
WHERE conversation_id = ?`)
|
|
497
|
+
.run(stamp, nowIso(), conversationId);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Column-only: drop claim redeliver hold without touching pending / confirm.
|
|
501
|
+
* Used when finish skips CAS (e.g. another followup in flight) but sleep was
|
|
502
|
+
* no-op — leaving the hold would block host-drop redelivery for debounceMs.
|
|
503
|
+
*/
|
|
504
|
+
clearPendingRedeliverHold(conversationId) {
|
|
505
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
this.db
|
|
509
|
+
.prepare(`UPDATE review_chains SET pending_redeliver_at = NULL, updated_at = ?
|
|
510
|
+
WHERE conversation_id = ?`)
|
|
511
|
+
.run(nowIso(), conversationId);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Drop redeliver hold only while `pending_followup_at` still equals this stop's
|
|
515
|
+
* claim stamp (and pending is recover). Prevents wiping a peer redeliver's
|
|
516
|
+
* newly armed hold after the debounce window.
|
|
517
|
+
*/
|
|
518
|
+
clearPendingRedeliverHoldIfStamp(conversationId, expectedAt) {
|
|
519
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
if (!expectedAt || expectedAt.includes("\0")) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
const result = this.db
|
|
526
|
+
.prepare(`UPDATE review_chains SET pending_redeliver_at = NULL, updated_at = ?
|
|
527
|
+
WHERE conversation_id = ?
|
|
528
|
+
AND pending_followup_at = ?
|
|
529
|
+
AND pending_followup IS NOT NULL
|
|
530
|
+
AND trim(pending_followup) != ''
|
|
531
|
+
AND ${StateStore.SQL_PENDING_IS_RECOVER}`)
|
|
532
|
+
.run(nowIso(), conversationId, expectedAt);
|
|
533
|
+
return result.changes > 0;
|
|
534
|
+
}
|
|
535
|
+
setChainPending(conversationId) {
|
|
536
|
+
this.withSessionChainWrite(conversationId, () => {
|
|
537
|
+
this.ensureReviewChain(conversationId);
|
|
538
|
+
this.db
|
|
539
|
+
.prepare(`UPDATE review_chains SET chain_pending = 1, updated_at = ?
|
|
540
|
+
WHERE conversation_id = ?
|
|
541
|
+
AND EXISTS (SELECT 1 FROM sessions WHERE conversation_id = ?)`)
|
|
542
|
+
.run(nowIso(), conversationId, conversationId);
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
savePendingFollowup(conversationId, message, opts) {
|
|
546
|
+
const msg = typeof message === "string" ? message.trim() : "";
|
|
547
|
+
// Blank or NUL-poisoned text must not become a redeliverable pending.
|
|
548
|
+
if (!msg || msg.includes("\0"))
|
|
549
|
+
return;
|
|
550
|
+
const armChain = opts?.armChain !== false;
|
|
551
|
+
this.withSessionChainWrite(conversationId, () => {
|
|
552
|
+
this.ensureReviewChain(conversationId);
|
|
553
|
+
// Recover claim/CAS ownership keys off pending_followup_at — keep unique.
|
|
554
|
+
const ts = nextFollowupTimestamp(this.getReviewChain(conversationId)?.pending_followup_at);
|
|
555
|
+
const sql = armChain
|
|
556
|
+
? `UPDATE review_chains SET
|
|
557
|
+
pending_followup = ?, pending_followup_at = ?, pending_redeliver_at = NULL,
|
|
558
|
+
chain_pending = 1, updated_at = ?
|
|
559
|
+
WHERE conversation_id = ?
|
|
560
|
+
AND EXISTS (SELECT 1 FROM sessions WHERE conversation_id = ?)`
|
|
561
|
+
: `UPDATE review_chains SET
|
|
562
|
+
pending_followup = ?, pending_followup_at = ?, pending_redeliver_at = NULL,
|
|
563
|
+
updated_at = ?
|
|
564
|
+
WHERE conversation_id = ?
|
|
565
|
+
AND EXISTS (SELECT 1 FROM sessions WHERE conversation_id = ?)`;
|
|
566
|
+
this.db.prepare(sql).run(msg, ts, ts, conversationId, conversationId);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* SQL: pending looks like a recover automation prompt.
|
|
571
|
+
* Keep in sync with `isRecoverFollowupMessage` prefixes.
|
|
572
|
+
*/
|
|
573
|
+
static SQL_PENDING_IS_RECOVER = `(
|
|
574
|
+
trim(pending_followup) GLOB 'Recover:*'
|
|
575
|
+
OR trim(pending_followup) GLOB '恢复:*'
|
|
576
|
+
OR trim(pending_followup) GLOB '恢复:*'
|
|
577
|
+
)`;
|
|
578
|
+
/**
|
|
579
|
+
* SQL: pending kinds that must stay chain_pending=0 on redeliver.
|
|
580
|
+
* Matches recover/stuck (armChain=false), terminal done/review_complete, and
|
|
581
|
+
* E5/E0 Advance (intentionally disarmed — next product edit arms via
|
|
582
|
+
* afterFileEdit → code_edited). Re-arming Advance on host-drop redelivery
|
|
583
|
+
* would force phantom E3 after the tip is answered. Keep prefixes in sync
|
|
584
|
+
* with emit/updateReviewChain sites and kindFromPendingMessage.
|
|
585
|
+
*/
|
|
586
|
+
static SQL_PENDING_REDELIVER_KEEP_DISARMED = `(
|
|
587
|
+
trim(pending_followup) GLOB 'Recover:*'
|
|
588
|
+
OR trim(pending_followup) GLOB '恢复:*'
|
|
589
|
+
OR trim(pending_followup) GLOB '恢复:*'
|
|
590
|
+
OR trim(pending_followup) GLOB 'Stuck:*'
|
|
591
|
+
OR trim(pending_followup) GLOB '卡住:*'
|
|
592
|
+
OR trim(pending_followup) GLOB '卡住:*'
|
|
593
|
+
OR trim(pending_followup) GLOB 'All checklist*'
|
|
594
|
+
OR trim(pending_followup) GLOB '全部完成*'
|
|
595
|
+
OR trim(pending_followup) GLOB 'Review complete*'
|
|
596
|
+
OR trim(pending_followup) GLOB '自审完成*'
|
|
597
|
+
OR trim(pending_followup) GLOB 'Advance*'
|
|
598
|
+
OR trim(pending_followup) GLOB '推进*'
|
|
599
|
+
)`;
|
|
600
|
+
/**
|
|
601
|
+
* SQL predicate: pending is absent or not a recover automation prompt.
|
|
602
|
+
* Keep in sync with `isRecoverFollowupMessage` prefixes.
|
|
603
|
+
*/
|
|
604
|
+
static SQL_PENDING_NOT_RECOVER = `(
|
|
605
|
+
pending_followup IS NULL
|
|
606
|
+
OR (
|
|
607
|
+
trim(pending_followup) NOT GLOB 'Recover:*'
|
|
608
|
+
AND trim(pending_followup) NOT GLOB '恢复:*'
|
|
609
|
+
AND trim(pending_followup) NOT GLOB '恢复:*'
|
|
610
|
+
)
|
|
611
|
+
)`;
|
|
612
|
+
/**
|
|
613
|
+
* Atomic emit-ownership bump: succeeds only when `pending_followup_at` still
|
|
614
|
+
* equals `expectedAt` and pending is a recover prompt. Returns the new stamp,
|
|
615
|
+
* or null if the race was lost / row missing. Advances by ≥1ms when `nowIso()`
|
|
616
|
+
* collides with `expectedAt`.
|
|
617
|
+
*/
|
|
618
|
+
casBumpPendingFollowupAt(conversationId, expectedAt) {
|
|
619
|
+
if (this.isInvalidConversationId(conversationId))
|
|
620
|
+
return null;
|
|
621
|
+
if (!expectedAt || expectedAt.includes("\0"))
|
|
622
|
+
return null;
|
|
623
|
+
const ts = nextFollowupTimestamp(expectedAt);
|
|
624
|
+
const result = this.db
|
|
625
|
+
.prepare(`UPDATE review_chains SET
|
|
626
|
+
pending_followup_at = ?, pending_redeliver_at = NULL, updated_at = ?
|
|
627
|
+
WHERE conversation_id = ?
|
|
628
|
+
AND pending_followup_at = ?
|
|
629
|
+
AND pending_followup IS NOT NULL
|
|
630
|
+
AND trim(pending_followup) != ''
|
|
631
|
+
AND ${StateStore.SQL_PENDING_IS_RECOVER}`)
|
|
632
|
+
.run(ts, ts, conversationId, expectedAt);
|
|
633
|
+
return result.changes > 0 ? ts : null;
|
|
634
|
+
}
|
|
635
|
+
clearPendingFollowup(conversationId) {
|
|
636
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
this.db
|
|
640
|
+
.prepare(`UPDATE review_chains SET
|
|
641
|
+
pending_followup = NULL, pending_followup_at = NULL, pending_redeliver_at = NULL,
|
|
642
|
+
updated_at = ?
|
|
643
|
+
WHERE conversation_id = ?`)
|
|
644
|
+
.run(nowIso(), conversationId);
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Atomically clear pending only when the live row still matches `pred`.
|
|
648
|
+
* Avoids read-then-clear TOCTOU: a concurrent stop may replace recover with
|
|
649
|
+
* fix/confirm between getReviewChain and clearPendingFollowup.
|
|
650
|
+
* Safe inside an open exclusiveWrite (runs inline; no nest).
|
|
651
|
+
*/
|
|
652
|
+
clearPendingFollowupIf(conversationId, pred) {
|
|
653
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
if (typeof pred !== "function") {
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
const run = () => {
|
|
660
|
+
const pending = this.getReviewChain(conversationId)?.pending_followup?.trim() ?? "";
|
|
661
|
+
if (!pending) {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
let matched = false;
|
|
665
|
+
try {
|
|
666
|
+
const raw = pred(pending);
|
|
667
|
+
// Refuse thenables — async preds must not clear under a write lock.
|
|
668
|
+
if (raw !== null &&
|
|
669
|
+
typeof raw === "object" &&
|
|
670
|
+
typeof raw.then === "function") {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
matched = Boolean(raw);
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
if (!matched) {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
this.db
|
|
682
|
+
.prepare(`UPDATE review_chains SET
|
|
683
|
+
pending_followup = NULL, pending_followup_at = NULL, pending_redeliver_at = NULL,
|
|
684
|
+
updated_at = ?
|
|
685
|
+
WHERE conversation_id = ?`)
|
|
686
|
+
.run(nowIso(), conversationId);
|
|
687
|
+
return true;
|
|
688
|
+
};
|
|
689
|
+
if (this.writeDepth > 0) {
|
|
690
|
+
return run();
|
|
691
|
+
}
|
|
692
|
+
return this.exclusiveWrite(() => {
|
|
693
|
+
const ok = run();
|
|
694
|
+
return { commit: ok, value: ok };
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Column-only: neutralize fix/confirm/pending re-entry without ensure/session.
|
|
699
|
+
* Used when pause-threshold upsert failed but the session is still armed — a
|
|
700
|
+
* later completed stop must not resume the review loop via code_edited/pending
|
|
701
|
+
* or loopCount>0→E3 (fix_round cleared so bare loopCount cannot re-arm).
|
|
702
|
+
*/
|
|
703
|
+
neutralizeReviewChain(conversationId) {
|
|
704
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
this.db
|
|
708
|
+
.prepare(`UPDATE review_chains SET
|
|
709
|
+
code_edited = 0,
|
|
710
|
+
confirm_left = NULL,
|
|
711
|
+
chain_pending = 0,
|
|
712
|
+
item_confirm_complete = 0,
|
|
713
|
+
fix_round = 0,
|
|
714
|
+
reviewing_item_id = NULL,
|
|
715
|
+
pending_followup = NULL,
|
|
716
|
+
pending_followup_at = NULL,
|
|
717
|
+
pending_redeliver_at = NULL,
|
|
718
|
+
updated_at = ?
|
|
719
|
+
WHERE conversation_id = ?`)
|
|
720
|
+
.run(nowIso(), conversationId);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Like {@link neutralizeReviewChain}, but refuse to wipe an in-flight recover
|
|
724
|
+
* pending (error-storm claimer). Returns true when a row was updated.
|
|
725
|
+
*/
|
|
726
|
+
neutralizeReviewChainUnlessRecover(conversationId) {
|
|
727
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
const result = this.db
|
|
731
|
+
.prepare(`UPDATE review_chains SET
|
|
732
|
+
code_edited = 0,
|
|
733
|
+
confirm_left = NULL,
|
|
734
|
+
chain_pending = 0,
|
|
735
|
+
item_confirm_complete = 0,
|
|
736
|
+
fix_round = 0,
|
|
737
|
+
reviewing_item_id = NULL,
|
|
738
|
+
pending_followup = NULL,
|
|
739
|
+
pending_followup_at = NULL,
|
|
740
|
+
pending_redeliver_at = NULL,
|
|
741
|
+
updated_at = ?
|
|
742
|
+
WHERE conversation_id = ?
|
|
743
|
+
AND ${StateStore.SQL_PENDING_NOT_RECOVER}`)
|
|
744
|
+
.run(nowIso(), conversationId);
|
|
745
|
+
return result.changes > 0;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Ambient error-recover soft-reset columns + clear pending, but refuse when
|
|
749
|
+
* pending is already recover (unlocked compensate TOCTOU defense).
|
|
750
|
+
* Preserves `reviewing_item_id` so a premature checklist `[x]` cannot retarget
|
|
751
|
+
* after recover (unlike neutralize/reset, which intentionally clear sticky).
|
|
752
|
+
* Returns true when a row was updated.
|
|
753
|
+
*/
|
|
754
|
+
softResetAmbientChainUnlessRecover(conversationId, fields) {
|
|
755
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
const result = this.db
|
|
759
|
+
.prepare(`UPDATE review_chains SET
|
|
760
|
+
confirm_left = ?,
|
|
761
|
+
item_confirm_complete = ?,
|
|
762
|
+
chain_pending = ?,
|
|
763
|
+
code_edited = ?,
|
|
764
|
+
pending_followup = NULL,
|
|
765
|
+
pending_followup_at = NULL,
|
|
766
|
+
pending_redeliver_at = NULL,
|
|
767
|
+
updated_at = ?
|
|
768
|
+
WHERE conversation_id = ?
|
|
769
|
+
AND EXISTS (SELECT 1 FROM sessions WHERE conversation_id = ?)
|
|
770
|
+
AND ${StateStore.SQL_PENDING_NOT_RECOVER}`)
|
|
771
|
+
.run(fields.confirm_left, fields.item_confirm_complete, fields.chain_pending, fields.code_edited, nowIso(), conversationId, conversationId);
|
|
772
|
+
return result.changes > 0;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Column-only pause/disarm when the full upsertSession pause write failed.
|
|
776
|
+
* Without this, loopCount>0 completed stops can still hit E3 while armed.
|
|
777
|
+
*/
|
|
778
|
+
pauseSessionForRepeatedErrors(conversationId, errorCount, lastError) {
|
|
779
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const count = typeof errorCount === "number" && Number.isFinite(errorCount)
|
|
783
|
+
? Math.max(0, Math.floor(errorCount))
|
|
784
|
+
: 0;
|
|
785
|
+
const err = typeof lastError === "string" && !lastError.includes("\0")
|
|
786
|
+
? lastError
|
|
787
|
+
: null;
|
|
788
|
+
const ts = nowIso();
|
|
789
|
+
this.db
|
|
790
|
+
.prepare(`UPDATE sessions SET
|
|
791
|
+
armed = 0,
|
|
792
|
+
paused = 1,
|
|
793
|
+
paused_reason = 'repeated_errors',
|
|
794
|
+
error_count = ?,
|
|
795
|
+
last_error = ?,
|
|
796
|
+
last_active_at = ?,
|
|
797
|
+
updated_at = ?
|
|
798
|
+
WHERE conversation_id = ?`)
|
|
799
|
+
.run(count, err, ts, ts, conversationId);
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Fallback halt when richer pause UPDATE threw/no-op'd.
|
|
803
|
+
* Always drops armed; ensures paused=1. Preserves an existing paused_reason
|
|
804
|
+
* (e.g. concurrent stuck/human_gate, or richer pause already wrote) via
|
|
805
|
+
* COALESCE — only fills repeated_errors when reason was null.
|
|
806
|
+
* Leaves error_count/last_error to the richer pause path.
|
|
807
|
+
*/
|
|
808
|
+
disarmSession(conversationId) {
|
|
809
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
this.db
|
|
813
|
+
.prepare(`UPDATE sessions SET
|
|
814
|
+
armed = 0,
|
|
815
|
+
paused = 1,
|
|
816
|
+
paused_reason = COALESCE(paused_reason, 'repeated_errors'),
|
|
817
|
+
updated_at = ?
|
|
818
|
+
WHERE conversation_id = ?`)
|
|
819
|
+
.run(nowIso(), conversationId);
|
|
820
|
+
}
|
|
821
|
+
touchPendingRedeliver(conversationId) {
|
|
822
|
+
this.withSessionChainWrite(conversationId, () => {
|
|
823
|
+
this.ensureReviewChain(conversationId);
|
|
824
|
+
const ts = nowIso();
|
|
825
|
+
// Require live pending — after neutralize/clear, must not resurrect
|
|
826
|
+
// chain_pending=1 (would E3 on RESUME with no undelivered message).
|
|
827
|
+
// Recover/stuck, terminal done/review_complete, and Advance must stay
|
|
828
|
+
// disarmed; arming them here would open a phantom confirm after the tip
|
|
829
|
+
// is answered.
|
|
830
|
+
this.db
|
|
831
|
+
.prepare(`UPDATE review_chains SET
|
|
832
|
+
pending_redeliver_at = ?,
|
|
833
|
+
chain_pending = CASE
|
|
834
|
+
WHEN ${StateStore.SQL_PENDING_REDELIVER_KEEP_DISARMED} THEN 0
|
|
835
|
+
ELSE 1
|
|
836
|
+
END,
|
|
837
|
+
updated_at = ?
|
|
838
|
+
WHERE conversation_id = ?
|
|
839
|
+
AND pending_followup IS NOT NULL
|
|
840
|
+
AND trim(pending_followup) != ''
|
|
841
|
+
AND EXISTS (SELECT 1 FROM sessions WHERE conversation_id = ?)`)
|
|
842
|
+
.run(ts, ts, conversationId, conversationId);
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Run fn only when session exists. Uses exclusiveWrite when not already in one
|
|
847
|
+
* (serialize vs purge); if already nested in a write txn, runs inline — nesting
|
|
848
|
+
* exclusiveWrite would throw.
|
|
849
|
+
*/
|
|
850
|
+
withSessionChainWrite(conversationId, fn) {
|
|
851
|
+
if (this.isInvalidConversationId(conversationId)) {
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
const run = () => {
|
|
855
|
+
if (!this.getSession(conversationId))
|
|
856
|
+
return false;
|
|
857
|
+
fn();
|
|
858
|
+
return true;
|
|
859
|
+
};
|
|
860
|
+
if (this.writeDepth > 0) {
|
|
861
|
+
run();
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
this.exclusiveWrite(() => {
|
|
865
|
+
const ok = run();
|
|
866
|
+
return { commit: ok, value: undefined };
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
findExecutingSession(excludeConversationId) {
|
|
870
|
+
return (this.db
|
|
871
|
+
.prepare(`SELECT * FROM sessions
|
|
872
|
+
WHERE phase = 'executing' AND armed = 1 AND paused = 0
|
|
873
|
+
AND conversation_id != ?
|
|
874
|
+
LIMIT 1`)
|
|
875
|
+
.get(excludeConversationId) ?? null);
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Move session + review_chain rows from `fromId` → `toId` (PK rebind).
|
|
879
|
+
* Replaces disposable destinations (idle — incl. stale track_id — or done).
|
|
880
|
+
* Refuses planning or executing destinations (paused or armed).
|
|
881
|
+
*/
|
|
882
|
+
rebindConversation(fromId, toId) {
|
|
883
|
+
if (this.isInvalidConversationId(fromId) ||
|
|
884
|
+
this.isInvalidConversationId(toId)) {
|
|
885
|
+
return { ok: false, reason: "invalid_id" };
|
|
886
|
+
}
|
|
887
|
+
if (fromId === toId) {
|
|
888
|
+
const self = this.getSession(toId);
|
|
889
|
+
if (!self)
|
|
890
|
+
return { ok: false, reason: "missing_source" };
|
|
891
|
+
return { ok: true, session: self };
|
|
892
|
+
}
|
|
893
|
+
if (this.writeDepth > 0) {
|
|
894
|
+
return this.rebindConversationInTxn(fromId, toId);
|
|
895
|
+
}
|
|
896
|
+
return this.exclusiveWrite(() => {
|
|
897
|
+
const result = this.rebindConversationInTxn(fromId, toId);
|
|
898
|
+
return { commit: result.ok, value: result };
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
isDisposableResumeDest(dest) {
|
|
902
|
+
// All idle (incl. ambient revive that kept a stale track_id) and done may be
|
|
903
|
+
// replaced. Never replace planning / executing.
|
|
904
|
+
return dest.phase === "done" || dest.phase === "idle";
|
|
905
|
+
}
|
|
906
|
+
rebindConversationInTxn(fromId, toId) {
|
|
907
|
+
const source = this.getSession(fromId);
|
|
908
|
+
if (!source) {
|
|
909
|
+
return { ok: false, reason: "missing_source" };
|
|
910
|
+
}
|
|
911
|
+
const dest = this.getSession(toId);
|
|
912
|
+
if (dest) {
|
|
913
|
+
if (!this.isDisposableResumeDest(dest)) {
|
|
914
|
+
return { ok: false, reason: "dest_busy" };
|
|
915
|
+
}
|
|
916
|
+
this.db
|
|
917
|
+
.prepare("DELETE FROM review_chains WHERE conversation_id = ?")
|
|
918
|
+
.run(toId);
|
|
919
|
+
this.db
|
|
920
|
+
.prepare("DELETE FROM sessions WHERE conversation_id = ?")
|
|
921
|
+
.run(toId);
|
|
922
|
+
}
|
|
923
|
+
const ts = nowIso();
|
|
924
|
+
this.db
|
|
925
|
+
.prepare(`UPDATE sessions SET conversation_id = ?, updated_at = ?, last_active_at = ?
|
|
926
|
+
WHERE conversation_id = ?`)
|
|
927
|
+
.run(toId, ts, ts, fromId);
|
|
928
|
+
this.db
|
|
929
|
+
.prepare(`UPDATE review_chains SET conversation_id = ?, updated_at = ?
|
|
930
|
+
WHERE conversation_id = ?`)
|
|
931
|
+
.run(toId, ts, fromId);
|
|
932
|
+
const moved = this.getSession(toId);
|
|
933
|
+
if (!moved) {
|
|
934
|
+
return { ok: false, reason: "missing_source" };
|
|
935
|
+
}
|
|
936
|
+
return { ok: true, session: moved };
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Atomically pick a foreign session (by slug or unique executing) and rebind
|
|
940
|
+
* it onto `toId`. No-op (ok + current dest) when nothing to claim.
|
|
941
|
+
*/
|
|
942
|
+
claimSessionInto(toId, opts) {
|
|
943
|
+
if (this.isInvalidConversationId(toId)) {
|
|
944
|
+
return { ok: false, userMessage: "Invalid conversation id." };
|
|
945
|
+
}
|
|
946
|
+
const slug = opts?.slug?.trim() || undefined;
|
|
947
|
+
if (slug && !isSafeTrackSlug(slug)) {
|
|
948
|
+
return {
|
|
949
|
+
ok: false,
|
|
950
|
+
userMessage: `Invalid track slug "${sanitizeSessionDisplayText(slug).slice(0, 64)}".`,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
return this.exclusiveWrite(() => {
|
|
954
|
+
const dest = this.getSession(toId);
|
|
955
|
+
if (dest && !this.isDisposableResumeDest(dest)) {
|
|
956
|
+
return {
|
|
957
|
+
commit: false,
|
|
958
|
+
value: {
|
|
959
|
+
ok: false,
|
|
960
|
+
userMessage: `This conversation already has track "${sanitizeSessionDisplayText(dest.track_id)}" (${sanitizeSessionDisplayText(String(dest.phase))}). Claim from a new chat with /autopilot-resume <slug> — planning/executing here cannot be replaced (OFF only pauses).`,
|
|
961
|
+
},
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
const others = this.listSessions().filter((s) => s.conversation_id !== toId);
|
|
965
|
+
// OFF only pauses — phase stays "executing". Prefer unpaused workers so
|
|
966
|
+
// "OFF extras, then retry" actually reduces ambiguity; fall back to a
|
|
967
|
+
// single paused executing (dead-chat recovery).
|
|
968
|
+
const pickExecuting = (pool) => {
|
|
969
|
+
const active = pool.filter((s) => s.phase === "executing" && s.paused === 0);
|
|
970
|
+
if (active.length > 1) {
|
|
971
|
+
const allSame = active.every((s) => s.track_id === active[0].track_id);
|
|
972
|
+
return {
|
|
973
|
+
ok: false,
|
|
974
|
+
userMessage: allSame
|
|
975
|
+
? `Multiple executing sessions for "${sanitizeSessionDisplayText(active[0].track_id)}". Send Autopilot OFF on extras, then retry.`
|
|
976
|
+
: `Multiple executing sessions (${active
|
|
977
|
+
.map((s) => sanitizeSessionDisplayText(s.track_id))
|
|
978
|
+
.join(", ")}). Send Autopilot OFF on extras, then retry — or use /autopilot-resume <slug>.`,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
if (active.length === 1) {
|
|
982
|
+
return { ok: true, session: active[0] };
|
|
983
|
+
}
|
|
984
|
+
const paused = pool.filter((s) => s.phase === "executing" && s.paused === 1);
|
|
985
|
+
if (paused.length > 1) {
|
|
986
|
+
const allSame = paused.every((s) => s.track_id === paused[0].track_id);
|
|
987
|
+
return {
|
|
988
|
+
ok: false,
|
|
989
|
+
userMessage: allSame
|
|
990
|
+
? `Multiple paused executing sessions for "${sanitizeSessionDisplayText(paused[0].track_id)}". Leave only one source, then retry.`
|
|
991
|
+
: `Multiple paused executing sessions (${paused
|
|
992
|
+
.map((s) => sanitizeSessionDisplayText(s.track_id))
|
|
993
|
+
.join(", ")}). Use /autopilot-resume <slug>.`,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
return { ok: true, session: paused[0] ?? null };
|
|
997
|
+
};
|
|
998
|
+
let source = null;
|
|
999
|
+
if (slug) {
|
|
1000
|
+
const hits = others.filter((s) => s.track_id === slug);
|
|
1001
|
+
const picked = pickExecuting(hits);
|
|
1002
|
+
if (!picked.ok) {
|
|
1003
|
+
return { commit: false, value: picked };
|
|
1004
|
+
}
|
|
1005
|
+
const preferred = picked.session ?? hits.find((s) => s.phase === "planning") ?? null;
|
|
1006
|
+
if (!preferred) {
|
|
1007
|
+
const onlyDone = hits.some((s) => s.phase === "done");
|
|
1008
|
+
return {
|
|
1009
|
+
commit: false,
|
|
1010
|
+
value: {
|
|
1011
|
+
ok: false,
|
|
1012
|
+
userMessage: onlyDone
|
|
1013
|
+
? `Track "${slug}" is already done. Use /autopilot-run ${slug} to start again.`
|
|
1014
|
+
: `No session found for track "${slug}".`,
|
|
1015
|
+
},
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
source = preferred;
|
|
1019
|
+
}
|
|
1020
|
+
else {
|
|
1021
|
+
const picked = pickExecuting(others);
|
|
1022
|
+
if (!picked.ok) {
|
|
1023
|
+
return { commit: false, value: picked };
|
|
1024
|
+
}
|
|
1025
|
+
source = picked.session;
|
|
1026
|
+
}
|
|
1027
|
+
if (!source) {
|
|
1028
|
+
return {
|
|
1029
|
+
commit: true,
|
|
1030
|
+
value: { ok: true, session: dest ?? null },
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
const rebound = this.rebindConversationInTxn(source.conversation_id, toId);
|
|
1034
|
+
if (!rebound.ok) {
|
|
1035
|
+
if (rebound.reason === "dest_busy") {
|
|
1036
|
+
return {
|
|
1037
|
+
commit: false,
|
|
1038
|
+
value: {
|
|
1039
|
+
ok: false,
|
|
1040
|
+
userMessage: "This conversation already has an active session. Claim from a new chat with /autopilot-resume <slug> — planning/executing here cannot be replaced (OFF only pauses).",
|
|
1041
|
+
},
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
return {
|
|
1045
|
+
commit: false,
|
|
1046
|
+
value: {
|
|
1047
|
+
ok: false,
|
|
1048
|
+
userMessage: `Failed to claim session for track "${sanitizeSessionDisplayText(source.track_id)}".`,
|
|
1049
|
+
},
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
return {
|
|
1053
|
+
commit: true,
|
|
1054
|
+
value: { ok: true, session: rebound.session },
|
|
1055
|
+
};
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Serialize writers with BEGIN IMMEDIATE so check-then-act (e.g. one_executor)
|
|
1060
|
+
* and multi-statement enters stay atomic. Callback chooses commit vs rollback.
|
|
1061
|
+
*/
|
|
1062
|
+
exclusiveWrite(fn) {
|
|
1063
|
+
if (this.writeDepth > 0) {
|
|
1064
|
+
throw new Error("exclusiveWrite does not support nesting");
|
|
1065
|
+
}
|
|
1066
|
+
this.writeDepth += 1;
|
|
1067
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1068
|
+
try {
|
|
1069
|
+
const { commit, value } = fn();
|
|
1070
|
+
this.db.exec(commit ? "COMMIT" : "ROLLBACK");
|
|
1071
|
+
return value;
|
|
1072
|
+
}
|
|
1073
|
+
catch (err) {
|
|
1074
|
+
try {
|
|
1075
|
+
this.db.exec("ROLLBACK");
|
|
1076
|
+
}
|
|
1077
|
+
catch {
|
|
1078
|
+
/* ignore rollback errors after primary failure */
|
|
1079
|
+
}
|
|
1080
|
+
throw err;
|
|
1081
|
+
}
|
|
1082
|
+
finally {
|
|
1083
|
+
this.writeDepth -= 1;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
static openMemory(projectRoot) {
|
|
1087
|
+
return new StateStore(projectRoot, ":memory:");
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
export { getLatestSchemaVersion };
|
|
1091
|
+
//# sourceMappingURL=state-store.js.map
|