session-steward 0.10.0 → 0.10.2
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.
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
import { constants as fsConstants, createReadStream } from "node:fs";
|
|
3
4
|
import { promises as fs } from "node:fs";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import readline from "node:readline";
|
|
7
|
+
import { createZstdDecompress } from "node:zlib";
|
|
6
8
|
|
|
7
9
|
import { measurePath } from "../../storage/files.mjs";
|
|
8
10
|
import {
|
|
@@ -141,7 +143,7 @@ async function* findTranscriptFiles(rootDirectory) {
|
|
|
141
143
|
continue;
|
|
142
144
|
}
|
|
143
145
|
|
|
144
|
-
if (entry.isFile() && /^rollout-.*\.jsonl
|
|
146
|
+
if (entry.isFile() && /^rollout-.*\.jsonl(?:\.zst)?$/u.test(entry.name)) {
|
|
145
147
|
yield resolvedPath;
|
|
146
148
|
}
|
|
147
149
|
}
|
|
@@ -162,13 +164,40 @@ function isContainedPath(rootDirectory, candidatePath) {
|
|
|
162
164
|
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
163
165
|
}
|
|
164
166
|
|
|
167
|
+
function createTranscriptTextStream(filePath) {
|
|
168
|
+
const source = createReadStream(filePath);
|
|
169
|
+
|
|
170
|
+
if (!filePath.endsWith(".zst")) {
|
|
171
|
+
source.setEncoding("utf8");
|
|
172
|
+
return { input: source, source };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const input = createZstdDecompress();
|
|
176
|
+
input.setEncoding("utf8");
|
|
177
|
+
source.on("error", (error) => input.destroy(error));
|
|
178
|
+
source.pipe(input);
|
|
179
|
+
return { input, source };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function rolloutIdFromPath(filePath) {
|
|
183
|
+
return path.basename(filePath).match(
|
|
184
|
+
/([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\.jsonl(?:\.zst)?$/iu,
|
|
185
|
+
)?.[1]?.toLowerCase() ?? null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function rolloutSiblingPath(filePath) {
|
|
189
|
+
return filePath.endsWith(".jsonl.zst")
|
|
190
|
+
? filePath.slice(0, -4)
|
|
191
|
+
: filePath.endsWith(".jsonl")
|
|
192
|
+
? `${filePath}.zst`
|
|
193
|
+
: null;
|
|
194
|
+
}
|
|
195
|
+
|
|
165
196
|
async function readFirstLine(filePath) {
|
|
166
|
-
const
|
|
167
|
-
encoding: "utf8",
|
|
168
|
-
});
|
|
197
|
+
const { input, source } = createTranscriptTextStream(filePath);
|
|
169
198
|
const interfaceHandle = readline.createInterface({
|
|
170
199
|
crlfDelay: Infinity,
|
|
171
|
-
input
|
|
200
|
+
input,
|
|
172
201
|
});
|
|
173
202
|
|
|
174
203
|
try {
|
|
@@ -177,7 +206,8 @@ async function readFirstLine(filePath) {
|
|
|
177
206
|
}
|
|
178
207
|
} finally {
|
|
179
208
|
interfaceHandle.close();
|
|
180
|
-
|
|
209
|
+
input.destroy();
|
|
210
|
+
source.destroy();
|
|
181
211
|
}
|
|
182
212
|
|
|
183
213
|
return "";
|
|
@@ -206,19 +236,21 @@ async function parseTranscriptHeader(filePath) {
|
|
|
206
236
|
cwd: payload.cwd ?? "",
|
|
207
237
|
filePath,
|
|
208
238
|
forkedFromId: payload.forked_from_id ?? null,
|
|
239
|
+
historyBaseId: payload.history_base?.thread_id
|
|
240
|
+
? String(payload.history_base.thread_id)
|
|
241
|
+
: null,
|
|
209
242
|
id: payload.id,
|
|
210
243
|
parentThreadId: subagentParentId,
|
|
244
|
+
rolloutId: rolloutIdFromPath(filePath),
|
|
211
245
|
timestampMs: toTimestampMs(payload.timestamp),
|
|
212
246
|
};
|
|
213
247
|
}
|
|
214
248
|
|
|
215
249
|
async function parseTranscriptFallback(filePath) {
|
|
216
|
-
const
|
|
217
|
-
encoding: "utf8",
|
|
218
|
-
});
|
|
250
|
+
const { input, source } = createTranscriptTextStream(filePath);
|
|
219
251
|
const interfaceHandle = readline.createInterface({
|
|
220
252
|
crlfDelay: Infinity,
|
|
221
|
-
input
|
|
253
|
+
input,
|
|
222
254
|
});
|
|
223
255
|
let firstUserMessage = "";
|
|
224
256
|
let latestThreadName = "";
|
|
@@ -260,7 +292,8 @@ async function parseTranscriptFallback(filePath) {
|
|
|
260
292
|
}
|
|
261
293
|
} finally {
|
|
262
294
|
interfaceHandle.close();
|
|
263
|
-
|
|
295
|
+
input.destroy();
|
|
296
|
+
source.destroy();
|
|
264
297
|
}
|
|
265
298
|
|
|
266
299
|
return {
|
|
@@ -602,7 +635,8 @@ export async function loadSessionStore({ codexHome }) {
|
|
|
602
635
|
}
|
|
603
636
|
const threadRows = [...threadRowsById.values()];
|
|
604
637
|
const spawnEdges = [...spawnEdgesByKey.values()];
|
|
605
|
-
const
|
|
638
|
+
const transcriptIndex = await indexTranscripts(paths.transcriptDirectories);
|
|
639
|
+
const transcriptHeaders = transcriptIndex.headersByThreadId;
|
|
606
640
|
|
|
607
641
|
const discoveryIds = new Set(threadRows.map((threadRow) => String(threadRow.id)));
|
|
608
642
|
|
|
@@ -800,6 +834,9 @@ export async function loadSessionStore({ codexHome }) {
|
|
|
800
834
|
threadHistoryDatabasePaths: paths.threadHistoryDatabasePaths,
|
|
801
835
|
goalsDatabasePaths: paths.goalsDatabasePaths,
|
|
802
836
|
transcriptHeaders,
|
|
837
|
+
transcriptPathsByThreadId: transcriptIndex.pathsByThreadId,
|
|
838
|
+
rolloutIdsByThreadId: transcriptIndex.rolloutIdsByThreadId,
|
|
839
|
+
rolloutReferencesById: transcriptIndex.rolloutReferencesById,
|
|
803
840
|
historyPath: paths.historyPath,
|
|
804
841
|
};
|
|
805
842
|
}
|
|
@@ -1256,17 +1293,40 @@ async function getStoreAvailability(paths) {
|
|
|
1256
1293
|
};
|
|
1257
1294
|
}
|
|
1258
1295
|
|
|
1259
|
-
async function
|
|
1260
|
-
const
|
|
1296
|
+
async function indexTranscripts(transcriptDirectories) {
|
|
1297
|
+
const headersByThreadId = new Map();
|
|
1298
|
+
const pathsByThreadId = new Map();
|
|
1299
|
+
const rolloutIdsByThreadId = new Map();
|
|
1300
|
+
const rolloutReferencesById = new Map();
|
|
1261
1301
|
|
|
1262
1302
|
for (const transcriptDirectory of transcriptDirectories) {
|
|
1263
1303
|
for await (const transcriptFile of findTranscriptFiles(transcriptDirectory)) {
|
|
1264
1304
|
try {
|
|
1265
1305
|
const header = await parseTranscriptHeader(transcriptFile);
|
|
1266
|
-
|
|
1306
|
+
if (!header?.id) continue;
|
|
1307
|
+
const threadId = String(header.id);
|
|
1308
|
+
const current = headersByThreadId.get(threadId);
|
|
1309
|
+
|
|
1310
|
+
if (!current || header.filePath > current.filePath) {
|
|
1311
|
+
headersByThreadId.set(threadId, { ...header, id: threadId });
|
|
1312
|
+
}
|
|
1267
1313
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1314
|
+
const transcriptPaths = pathsByThreadId.get(threadId) ?? new Set();
|
|
1315
|
+
transcriptPaths.add(header.filePath);
|
|
1316
|
+
pathsByThreadId.set(threadId, transcriptPaths);
|
|
1317
|
+
|
|
1318
|
+
if (header.rolloutId) {
|
|
1319
|
+
const rolloutIds = rolloutIdsByThreadId.get(threadId) ?? new Set();
|
|
1320
|
+
rolloutIds.add(header.rolloutId);
|
|
1321
|
+
rolloutIdsByThreadId.set(threadId, rolloutIds);
|
|
1322
|
+
|
|
1323
|
+
const existingReference = rolloutReferencesById.get(header.rolloutId);
|
|
1324
|
+
if (!existingReference || (!existingReference.historyBaseId && header.historyBaseId)) {
|
|
1325
|
+
rolloutReferencesById.set(header.rolloutId, {
|
|
1326
|
+
historyBaseId: header.historyBaseId,
|
|
1327
|
+
threadId,
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1270
1330
|
}
|
|
1271
1331
|
} catch {
|
|
1272
1332
|
continue;
|
|
@@ -1274,7 +1334,12 @@ async function indexTranscriptHeaders(transcriptDirectories) {
|
|
|
1274
1334
|
}
|
|
1275
1335
|
}
|
|
1276
1336
|
|
|
1277
|
-
return
|
|
1337
|
+
return {
|
|
1338
|
+
headersByThreadId,
|
|
1339
|
+
pathsByThreadId,
|
|
1340
|
+
rolloutIdsByThreadId,
|
|
1341
|
+
rolloutReferencesById,
|
|
1342
|
+
};
|
|
1278
1343
|
}
|
|
1279
1344
|
|
|
1280
1345
|
function getTranscriptChildrenByParentId(transcriptHeaders) {
|
|
@@ -1321,10 +1386,11 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
|
|
|
1321
1386
|
throw new Error("Select at least one session.");
|
|
1322
1387
|
}
|
|
1323
1388
|
|
|
1324
|
-
const [availability,
|
|
1389
|
+
const [availability, transcriptIndex] = await Promise.all([
|
|
1325
1390
|
getStoreAvailability(paths),
|
|
1326
|
-
|
|
1391
|
+
indexTranscripts(paths.transcriptDirectories),
|
|
1327
1392
|
]);
|
|
1393
|
+
const transcriptHeaders = transcriptIndex.headersByThreadId;
|
|
1328
1394
|
const transcriptChildrenByParentId = getTranscriptChildrenByParentId(transcriptHeaders);
|
|
1329
1395
|
const pendingIds = [...selectedIds];
|
|
1330
1396
|
|
|
@@ -1526,6 +1592,13 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
|
|
|
1526
1592
|
transcriptHeaders: new Map(
|
|
1527
1593
|
[...transcriptHeaders].filter(([id]) => relevantIds.has(id)),
|
|
1528
1594
|
),
|
|
1595
|
+
transcriptPathsByThreadId: new Map(
|
|
1596
|
+
[...transcriptIndex.pathsByThreadId].filter(([id]) => relevantIds.has(id)),
|
|
1597
|
+
),
|
|
1598
|
+
rolloutIdsByThreadId: new Map(
|
|
1599
|
+
[...transcriptIndex.rolloutIdsByThreadId].filter(([id]) => relevantIds.has(id)),
|
|
1600
|
+
),
|
|
1601
|
+
rolloutReferencesById: transcriptIndex.rolloutReferencesById,
|
|
1529
1602
|
};
|
|
1530
1603
|
}
|
|
1531
1604
|
|
|
@@ -1903,9 +1976,135 @@ function* deleteStatements(tableName, columnName, ids) {
|
|
|
1903
1976
|
}
|
|
1904
1977
|
}
|
|
1905
1978
|
|
|
1979
|
+
const REQUIRED_THREAD_HISTORY_TABLES = Object.freeze([
|
|
1980
|
+
"thread_items",
|
|
1981
|
+
"thread_turns",
|
|
1982
|
+
"thread_history_projection_state",
|
|
1983
|
+
]);
|
|
1984
|
+
const THREAD_HISTORY_TABLES_WITH_REALTIME = Object.freeze([
|
|
1985
|
+
"thread_items",
|
|
1986
|
+
"thread_realtime_items",
|
|
1987
|
+
"thread_turns",
|
|
1988
|
+
"thread_history_projection_state",
|
|
1989
|
+
]);
|
|
1990
|
+
const MEMORY_STAGE1_JOB_KIND = "memory_stage1";
|
|
1991
|
+
const MEMORY_GLOBAL_JOB_KIND = "memory_consolidate_global";
|
|
1992
|
+
const MEMORY_GLOBAL_JOB_KEY = "global";
|
|
1993
|
+
const MEMORY_JOB_RETRY_COUNT = 3;
|
|
1994
|
+
const MEMORY_DELETE_IDS_TABLE = "session_steward_memory_delete_ids";
|
|
1995
|
+
|
|
1996
|
+
function threadHistoryTables(databasePath) {
|
|
1997
|
+
return inspectSqliteTable(databasePath, "thread_realtime_items").exists
|
|
1998
|
+
? THREAD_HISTORY_TABLES_WITH_REALTIME
|
|
1999
|
+
: REQUIRED_THREAD_HISTORY_TABLES;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
function memorySchema(databasePath) {
|
|
2003
|
+
const jobs = inspectSqliteTable(databasePath, "jobs");
|
|
2004
|
+
const stage1Outputs = inspectSqliteTable(databasePath, "stage1_outputs");
|
|
2005
|
+
|
|
2006
|
+
return {
|
|
2007
|
+
hasJobs: jobs.exists,
|
|
2008
|
+
hasSelectedForPhase2: stage1Outputs.columns?.includes("selected_for_phase2") ?? false,
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
function countMemoryJobsForIds(databasePath, ids) {
|
|
2013
|
+
if (!memorySchema(databasePath).hasJobs) return 0;
|
|
2014
|
+
return [...batches(ids)].reduce((total, idBatch) => total + Number(queryRows(
|
|
2015
|
+
databasePath,
|
|
2016
|
+
`select count(*) as count from jobs where kind = ? and job_key in (${placeholders(idBatch)})`,
|
|
2017
|
+
[MEMORY_STAGE1_JOB_KIND, ...idBatch],
|
|
2018
|
+
)[0]?.count ?? 0), 0);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
function findMemoryJobsForIds(databasePath, ids) {
|
|
2022
|
+
if (!memorySchema(databasePath).hasJobs) return [];
|
|
2023
|
+
return [...batches(ids)].flatMap((idBatch) => queryRows(
|
|
2024
|
+
databasePath,
|
|
2025
|
+
`select * from jobs where kind = ? and job_key in (${placeholders(idBatch)})`,
|
|
2026
|
+
[MEMORY_STAGE1_JOB_KIND, ...idBatch],
|
|
2027
|
+
));
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function memoryConsolidationWatermark(databasePath) {
|
|
2031
|
+
if (!memorySchema(databasePath).hasJobs) return null;
|
|
2032
|
+
const row = queryRows(
|
|
2033
|
+
databasePath,
|
|
2034
|
+
"select input_watermark from jobs where kind = ? and job_key = ? limit 1",
|
|
2035
|
+
[MEMORY_GLOBAL_JOB_KIND, MEMORY_GLOBAL_JOB_KEY],
|
|
2036
|
+
)[0];
|
|
2037
|
+
return row ? Number(row.input_watermark ?? 0) : null;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
function needsMemoryConsolidation(databasePath, ids) {
|
|
2041
|
+
const schema = memorySchema(databasePath);
|
|
2042
|
+
if (!schema.hasJobs || !schema.hasSelectedForPhase2) return false;
|
|
2043
|
+
|
|
2044
|
+
return [...batches(ids)].some((idBatch) => Number(queryRows(
|
|
2045
|
+
databasePath,
|
|
2046
|
+
`select count(*) as count from stage1_outputs where selected_for_phase2 != 0 and thread_id in (${placeholders(idBatch)})`,
|
|
2047
|
+
idBatch,
|
|
2048
|
+
)[0]?.count ?? 0) > 0);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function memoryDeletionStatements(databasePath, ids, inputWatermark) {
|
|
2052
|
+
const schema = memorySchema(databasePath);
|
|
2053
|
+
const statements = [
|
|
2054
|
+
{ sql: `create temp table ${MEMORY_DELETE_IDS_TABLE} (thread_id text primary key) without rowid` },
|
|
2055
|
+
...[...batches(ids)].map((idBatch) => ({
|
|
2056
|
+
parameters: idBatch,
|
|
2057
|
+
sql: `insert into ${MEMORY_DELETE_IDS_TABLE} (thread_id) values ${idBatch.map(() => "(?)").join(", ")}`,
|
|
2058
|
+
})),
|
|
2059
|
+
];
|
|
2060
|
+
|
|
2061
|
+
if (schema.hasJobs && schema.hasSelectedForPhase2) {
|
|
2062
|
+
statements.push({
|
|
2063
|
+
parameters: [
|
|
2064
|
+
MEMORY_GLOBAL_JOB_KIND,
|
|
2065
|
+
MEMORY_GLOBAL_JOB_KEY,
|
|
2066
|
+
MEMORY_JOB_RETRY_COUNT,
|
|
2067
|
+
inputWatermark,
|
|
2068
|
+
],
|
|
2069
|
+
sql: `insert into jobs (
|
|
2070
|
+
kind, job_key, status, worker_id, ownership_token, started_at, finished_at,
|
|
2071
|
+
lease_until, retry_at, retry_remaining, last_error, input_watermark,
|
|
2072
|
+
last_success_watermark
|
|
2073
|
+
)
|
|
2074
|
+
select ?, ?, 'pending', null, null, null, null, null, null, ?, null, ?, 0
|
|
2075
|
+
where exists (
|
|
2076
|
+
select 1 from stage1_outputs
|
|
2077
|
+
where selected_for_phase2 != 0
|
|
2078
|
+
and thread_id in (select thread_id from ${MEMORY_DELETE_IDS_TABLE})
|
|
2079
|
+
)
|
|
2080
|
+
on conflict(kind, job_key) do update set
|
|
2081
|
+
status = case when jobs.status = 'running' then 'running' else 'pending' end,
|
|
2082
|
+
retry_at = case when jobs.status = 'running' then jobs.retry_at else null end,
|
|
2083
|
+
retry_remaining = max(jobs.retry_remaining, excluded.retry_remaining),
|
|
2084
|
+
input_watermark = case
|
|
2085
|
+
when excluded.input_watermark > coalesce(jobs.input_watermark, 0)
|
|
2086
|
+
then excluded.input_watermark
|
|
2087
|
+
else coalesce(jobs.input_watermark, 0) + 1
|
|
2088
|
+
end`,
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
statements.push({
|
|
2093
|
+
sql: `delete from stage1_outputs where thread_id in (select thread_id from ${MEMORY_DELETE_IDS_TABLE})`,
|
|
2094
|
+
});
|
|
2095
|
+
if (schema.hasJobs) {
|
|
2096
|
+
statements.push({
|
|
2097
|
+
parameters: [MEMORY_STAGE1_JOB_KIND],
|
|
2098
|
+
sql: `delete from jobs where kind = ? and job_key in (select thread_id from ${MEMORY_DELETE_IDS_TABLE})`,
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
statements.push({ sql: `drop table ${MEMORY_DELETE_IDS_TABLE}` });
|
|
2102
|
+
return statements;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
1906
2105
|
export async function planSessionDeletion({ recordIds, store }) {
|
|
1907
2106
|
const idsToDelete = new Set();
|
|
1908
|
-
const pendingIds =
|
|
2107
|
+
const pendingIds = recordIds.map(String);
|
|
1909
2108
|
|
|
1910
2109
|
while (pendingIds.length > 0) {
|
|
1911
2110
|
const currentId = pendingIds.shift();
|
|
@@ -1926,9 +2125,55 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
1926
2125
|
.map((id) => store.recordsById.get(id))
|
|
1927
2126
|
.filter(Boolean)
|
|
1928
2127
|
.sort((left, right) => left.displayName.localeCompare(right.displayName));
|
|
1929
|
-
const
|
|
1930
|
-
|
|
1931
|
-
|
|
2128
|
+
const deletionRolloutIds = new Set();
|
|
2129
|
+
|
|
2130
|
+
for (const id of deletionIds) {
|
|
2131
|
+
for (const rolloutId of store.rolloutIdsByThreadId?.get(id) ?? []) {
|
|
2132
|
+
deletionRolloutIds.add(rolloutId);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
const threadHistoryIds = [...new Set([...deletionIds, ...deletionRolloutIds])];
|
|
2136
|
+
|
|
2137
|
+
const externalHistoryReferences = [];
|
|
2138
|
+
|
|
2139
|
+
for (const [rolloutId, reference] of store.rolloutReferencesById ?? []) {
|
|
2140
|
+
if (
|
|
2141
|
+
reference.historyBaseId &&
|
|
2142
|
+
deletionRolloutIds.has(reference.historyBaseId) &&
|
|
2143
|
+
!deletionRolloutIds.has(rolloutId)
|
|
2144
|
+
) {
|
|
2145
|
+
externalHistoryReferences.push({ rolloutId, ...reference });
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
if (externalHistoryReferences.length > 0) {
|
|
2150
|
+
const error = new Error(
|
|
2151
|
+
"Another Codex session still depends on the selected session history. Select the related session too, or keep both.",
|
|
2152
|
+
);
|
|
2153
|
+
error.code = "CODEX_EXTERNAL_ROLLOUT_REFERENCE";
|
|
2154
|
+
error.references = externalHistoryReferences;
|
|
2155
|
+
throw error;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
const transcriptPathSet = new Set(deletionIds.flatMap((id) => [
|
|
2159
|
+
...(store.transcriptPathsByThreadId?.get(id) ?? []),
|
|
2160
|
+
store.recordsById.get(id)?.rolloutPath,
|
|
2161
|
+
]).filter(Boolean));
|
|
2162
|
+
const transcriptDirectories = getCodexPaths(store.codexHome).transcriptDirectories;
|
|
2163
|
+
|
|
2164
|
+
for (const transcriptPath of [...transcriptPathSet]) {
|
|
2165
|
+
const siblingPath = rolloutSiblingPath(transcriptPath);
|
|
2166
|
+
if (
|
|
2167
|
+
siblingPath &&
|
|
2168
|
+
rolloutIdFromPath(transcriptPath) &&
|
|
2169
|
+
transcriptDirectories.some((directory) => isContainedPath(directory, transcriptPath)) &&
|
|
2170
|
+
await pathExists(siblingPath)
|
|
2171
|
+
) {
|
|
2172
|
+
transcriptPathSet.add(siblingPath);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
const transcriptPaths = [...transcriptPathSet].sort();
|
|
1932
2177
|
const missingTranscriptPaths = selectedRecords
|
|
1933
2178
|
.filter((record) => record.rolloutMissing)
|
|
1934
2179
|
.map((record) => record.rolloutPath)
|
|
@@ -1989,8 +2234,27 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
1989
2234
|
: 0), 0);
|
|
1990
2235
|
const logRowCount = (store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean))
|
|
1991
2236
|
.reduce((total, databasePath) => total + countRowsForIds(databasePath, "logs", "thread_id", deletionIds), 0);
|
|
1992
|
-
const
|
|
1993
|
-
|
|
2237
|
+
const memoryDatabasePaths = store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean);
|
|
2238
|
+
const memoryOutputRowCount = memoryDatabasePaths.reduce(
|
|
2239
|
+
(total, databasePath) => total + countRowsForIds(
|
|
2240
|
+
databasePath,
|
|
2241
|
+
"stage1_outputs",
|
|
2242
|
+
"thread_id",
|
|
2243
|
+
deletionIds,
|
|
2244
|
+
),
|
|
2245
|
+
0,
|
|
2246
|
+
);
|
|
2247
|
+
const memoryJobRowCount = memoryDatabasePaths.reduce(
|
|
2248
|
+
(total, databasePath) => total + countMemoryJobsForIds(databasePath, deletionIds),
|
|
2249
|
+
0,
|
|
2250
|
+
);
|
|
2251
|
+
const memoryConsolidations = memoryDatabasePaths
|
|
2252
|
+
.filter((databasePath) => needsMemoryConsolidation(databasePath, deletionIds))
|
|
2253
|
+
.map((databasePath) => ({
|
|
2254
|
+
databasePath,
|
|
2255
|
+
inputWatermark: memoryConsolidationWatermark(databasePath),
|
|
2256
|
+
}));
|
|
2257
|
+
const memoryRowCount = memoryOutputRowCount + memoryJobRowCount;
|
|
1994
2258
|
const goalRowCount = (store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean))
|
|
1995
2259
|
.reduce((total, databasePath) => total + countRowsForIds(databasePath, "thread_goals", "thread_id", deletionIds), 0);
|
|
1996
2260
|
const queueRowCount = (store.queueDatabasePaths ?? [store.queueDatabasePath].filter(Boolean))
|
|
@@ -2000,12 +2264,8 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
2000
2264
|
? countRowsForIds(databasePath, "queued_thread_revisions", "thread_id", deletionIds)
|
|
2001
2265
|
: 0), 0);
|
|
2002
2266
|
const threadHistoryRowCount = (store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath].filter(Boolean))
|
|
2003
|
-
.reduce((total, databasePath) => total +
|
|
2004
|
-
"
|
|
2005
|
-
"thread_turns",
|
|
2006
|
-
"thread_history_projection_state",
|
|
2007
|
-
].reduce((databaseTotal, tableName) =>
|
|
2008
|
-
databaseTotal + countRowsForIds(databasePath, tableName, "thread_id", deletionIds), 0), 0);
|
|
2267
|
+
.reduce((total, databasePath) => total + threadHistoryTables(databasePath).reduce((databaseTotal, tableName) =>
|
|
2268
|
+
databaseTotal + countRowsForIds(databasePath, tableName, "thread_id", threadHistoryIds), 0), 0);
|
|
2009
2269
|
const stateTargets = (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({
|
|
2010
2270
|
database,
|
|
2011
2271
|
ids: findRowsForIds(database.path, "threads", "id", deletionIds).map((row) => String(row.id)),
|
|
@@ -2023,6 +2283,9 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
2023
2283
|
historyMatchCount: historyMatches.count,
|
|
2024
2284
|
ids: deletionIds,
|
|
2025
2285
|
logRowCount,
|
|
2286
|
+
memoryConsolidations,
|
|
2287
|
+
memoryJobRowCount,
|
|
2288
|
+
memoryOutputRowCount,
|
|
2026
2289
|
memoryRowCount,
|
|
2027
2290
|
queueRowCount,
|
|
2028
2291
|
queueRevisionRowCount,
|
|
@@ -2034,6 +2297,7 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
2034
2297
|
stateTargets,
|
|
2035
2298
|
transcriptBytes,
|
|
2036
2299
|
transcriptFileCount,
|
|
2300
|
+
threadHistoryIds,
|
|
2037
2301
|
threadHistoryRowCount,
|
|
2038
2302
|
transcriptPaths,
|
|
2039
2303
|
};
|
|
@@ -2148,13 +2412,32 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
|
2148
2412
|
}
|
|
2149
2413
|
}
|
|
2150
2414
|
for (const databasePath of store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath].filter(Boolean)) {
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2415
|
+
for (const tableName of threadHistoryTables(databasePath)) {
|
|
2416
|
+
fingerprintRowsForIds(
|
|
2417
|
+
hash,
|
|
2418
|
+
databasePath,
|
|
2419
|
+
tableName,
|
|
2420
|
+
"thread_id",
|
|
2421
|
+
plan.threadHistoryIds ?? plan.ids,
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2154
2424
|
}
|
|
2155
2425
|
if (scope === "deep") {
|
|
2156
2426
|
for (const databasePath of store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean)) {
|
|
2157
2427
|
fingerprintRowsForIds(hash, databasePath, "stage1_outputs", "thread_id", plan.ids);
|
|
2428
|
+
if (memorySchema(databasePath).hasJobs) {
|
|
2429
|
+
const jobKeys = (plan.memoryConsolidations ?? [])
|
|
2430
|
+
.some((consolidation) => consolidation.databasePath === databasePath)
|
|
2431
|
+
? [...plan.ids, MEMORY_GLOBAL_JOB_KEY]
|
|
2432
|
+
: plan.ids;
|
|
2433
|
+
fingerprintRowsForIds(
|
|
2434
|
+
hash,
|
|
2435
|
+
databasePath,
|
|
2436
|
+
"jobs",
|
|
2437
|
+
"job_key",
|
|
2438
|
+
jobKeys,
|
|
2439
|
+
);
|
|
2440
|
+
}
|
|
2158
2441
|
}
|
|
2159
2442
|
for (const databasePath of store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean)) {
|
|
2160
2443
|
fingerprintRowsForIds(hash, databasePath, "thread_goals", "thread_id", plan.ids);
|
|
@@ -2220,6 +2503,108 @@ async function getAvailableDiskBytes(directoryPath) {
|
|
|
2220
2503
|
return stats.bavail * stats.bsize;
|
|
2221
2504
|
}
|
|
2222
2505
|
|
|
2506
|
+
const LOCK_PROBE_CONFLICT_EXIT_CODE = 75;
|
|
2507
|
+
const LOCK_PROBE_CONCURRENCY = 8;
|
|
2508
|
+
const LOCK_PROBE_TIMEOUT_MS = 5_000;
|
|
2509
|
+
const WINDOWS_LOCK_PROBE_SCRIPT = String.raw`
|
|
2510
|
+
$ErrorActionPreference = "Stop"
|
|
2511
|
+
$stream = $null
|
|
2512
|
+
try {
|
|
2513
|
+
$stream = [System.IO.File]::Open(
|
|
2514
|
+
$env:SESSION_STEWARD_WRITER_LOCK_PATH,
|
|
2515
|
+
[System.IO.FileMode]::Open,
|
|
2516
|
+
[System.IO.FileAccess]::ReadWrite,
|
|
2517
|
+
([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete)
|
|
2518
|
+
)
|
|
2519
|
+
try {
|
|
2520
|
+
$stream.Lock(0, 1)
|
|
2521
|
+
$stream.Unlock(0, 1)
|
|
2522
|
+
exit 0
|
|
2523
|
+
} catch [System.IO.IOException] {
|
|
2524
|
+
if (($_.Exception.HResult -band 0xffff) -eq 33) { exit 75 }
|
|
2525
|
+
exit 70
|
|
2526
|
+
}
|
|
2527
|
+
} catch [System.IO.FileNotFoundException] {
|
|
2528
|
+
exit 66
|
|
2529
|
+
} catch [System.IO.DirectoryNotFoundException] {
|
|
2530
|
+
exit 66
|
|
2531
|
+
} catch {
|
|
2532
|
+
exit 70
|
|
2533
|
+
} finally {
|
|
2534
|
+
if ($null -ne $stream) { $stream.Dispose() }
|
|
2535
|
+
}
|
|
2536
|
+
`;
|
|
2537
|
+
|
|
2538
|
+
function runLockProbe(command, args, options = {}) {
|
|
2539
|
+
return new Promise((resolve) => {
|
|
2540
|
+
let settled = false;
|
|
2541
|
+
let child;
|
|
2542
|
+
const finish = (result) => {
|
|
2543
|
+
if (settled) return;
|
|
2544
|
+
settled = true;
|
|
2545
|
+
clearTimeout(timeout);
|
|
2546
|
+
resolve(result);
|
|
2547
|
+
};
|
|
2548
|
+
const timeout = setTimeout(() => {
|
|
2549
|
+
child?.kill();
|
|
2550
|
+
finish({ error: new Error("The writer-lock probe timed out."), exitCode: null });
|
|
2551
|
+
}, LOCK_PROBE_TIMEOUT_MS);
|
|
2552
|
+
|
|
2553
|
+
try {
|
|
2554
|
+
child = spawn(command, args, {
|
|
2555
|
+
stdio: "ignore",
|
|
2556
|
+
windowsHide: true,
|
|
2557
|
+
...options,
|
|
2558
|
+
});
|
|
2559
|
+
} catch (error) {
|
|
2560
|
+
finish({ error, exitCode: null });
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
child.once("error", (error) => finish({ error, exitCode: null }));
|
|
2565
|
+
child.once("exit", (exitCode) => finish({ error: null, exitCode }));
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
async function probeWriterLock(lockPath) {
|
|
2570
|
+
if (process.platform === "win32") {
|
|
2571
|
+
const result = await runLockProbe(
|
|
2572
|
+
"powershell.exe",
|
|
2573
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_LOCK_PROBE_SCRIPT],
|
|
2574
|
+
{
|
|
2575
|
+
env: { ...process.env, SESSION_STEWARD_WRITER_LOCK_PATH: lockPath },
|
|
2576
|
+
},
|
|
2577
|
+
);
|
|
2578
|
+
if (result.exitCode === 0) return "free";
|
|
2579
|
+
if (result.exitCode === LOCK_PROBE_CONFLICT_EXIT_CODE) return "held";
|
|
2580
|
+
if (result.exitCode === 66) return "missing";
|
|
2581
|
+
return "unknown";
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
let handle;
|
|
2585
|
+
try {
|
|
2586
|
+
handle = await fs.open(lockPath, "r+");
|
|
2587
|
+
} catch (error) {
|
|
2588
|
+
if (error?.code === "ENOENT") return "missing";
|
|
2589
|
+
return "unknown";
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
try {
|
|
2593
|
+
const command = process.platform === "darwin" ? "/usr/bin/lockf" : "flock";
|
|
2594
|
+
const args = process.platform === "darwin"
|
|
2595
|
+
? ["-s", "-t", "0", "3"]
|
|
2596
|
+
: ["-n", "-E", String(LOCK_PROBE_CONFLICT_EXIT_CODE), "3"];
|
|
2597
|
+
const result = await runLockProbe(command, args, {
|
|
2598
|
+
stdio: ["ignore", "ignore", "ignore", handle.fd],
|
|
2599
|
+
});
|
|
2600
|
+
if (result.exitCode === 0) return "free";
|
|
2601
|
+
if (result.exitCode === LOCK_PROBE_CONFLICT_EXIT_CODE) return "held";
|
|
2602
|
+
return "unknown";
|
|
2603
|
+
} finally {
|
|
2604
|
+
await handle.close();
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2223
2608
|
async function findActiveThreadIds(store, ids) {
|
|
2224
2609
|
if (!store.threadWriterLocksDirectory) return { available: false, ids: [] };
|
|
2225
2610
|
|
|
@@ -2231,20 +2616,48 @@ async function findActiveThreadIds(store, ids) {
|
|
|
2231
2616
|
throw error;
|
|
2232
2617
|
}
|
|
2233
2618
|
|
|
2234
|
-
const
|
|
2619
|
+
const lockPathsById = new Map(entries
|
|
2235
2620
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".lock"))
|
|
2236
|
-
.map((entry) =>
|
|
2621
|
+
.map((entry) => {
|
|
2622
|
+
const id = entry.name.slice(0, -5);
|
|
2623
|
+
return [id, path.join(store.threadWriterLocksDirectory, entry.name)];
|
|
2624
|
+
}));
|
|
2625
|
+
const candidates = ids.filter((id) => lockPathsById.has(id));
|
|
2626
|
+
const states = new Array(candidates.length);
|
|
2627
|
+
let nextIndex = 0;
|
|
2628
|
+
await Promise.all(Array.from(
|
|
2629
|
+
{ length: Math.min(LOCK_PROBE_CONCURRENCY, candidates.length) },
|
|
2630
|
+
async () => {
|
|
2631
|
+
while (nextIndex < candidates.length) {
|
|
2632
|
+
const index = nextIndex;
|
|
2633
|
+
nextIndex += 1;
|
|
2634
|
+
const id = candidates[index];
|
|
2635
|
+
states[index] = { id, state: await probeWriterLock(lockPathsById.get(id)) };
|
|
2636
|
+
}
|
|
2637
|
+
},
|
|
2638
|
+
));
|
|
2237
2639
|
return {
|
|
2238
2640
|
available: true,
|
|
2239
|
-
ids:
|
|
2641
|
+
ids: states.filter(({ state }) => state === "held").map(({ id }) => id),
|
|
2642
|
+
unverifiedIds: states.filter(({ state }) => state === "unknown").map(({ id }) => id),
|
|
2240
2643
|
};
|
|
2241
2644
|
}
|
|
2242
2645
|
|
|
2243
2646
|
function activeThreadError(ids) {
|
|
2244
2647
|
const error = new Error(
|
|
2245
2648
|
ids.length === 1
|
|
2246
|
-
? "
|
|
2247
|
-
: `
|
|
2649
|
+
? "Quit ChatGPT or Codex completely before cleaning up the selected Codex session."
|
|
2650
|
+
: `Quit ChatGPT or Codex completely before cleaning up the ${ids.length} selected Codex sessions that are still open.`,
|
|
2651
|
+
);
|
|
2652
|
+
error.activeThreadIds = ids;
|
|
2653
|
+
return error;
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
function unverifiedThreadError(ids) {
|
|
2657
|
+
const error = new Error(
|
|
2658
|
+
ids.length === 1
|
|
2659
|
+
? "Session Steward could not verify whether the selected Codex session is still open. Try cleanup again."
|
|
2660
|
+
: `Session Steward could not verify whether ${ids.length} selected Codex sessions are still open. Try cleanup again.`,
|
|
2248
2661
|
);
|
|
2249
2662
|
error.activeThreadIds = ids;
|
|
2250
2663
|
return error;
|
|
@@ -2287,6 +2700,7 @@ export async function preflightSessionDeletion({ availableDiskBytes, plan, scope
|
|
|
2287
2700
|
|
|
2288
2701
|
const activeThreads = await findActiveThreadIds(store, plan.ids);
|
|
2289
2702
|
if (activeThreads.ids.length > 0) throw activeThreadError(activeThreads.ids);
|
|
2703
|
+
if (activeThreads.unverifiedIds?.length > 0) throw unverifiedThreadError(activeThreads.unverifiedIds);
|
|
2290
2704
|
|
|
2291
2705
|
const deletedIdSet = new Set(plan.ids);
|
|
2292
2706
|
const desktopStates = await Promise.all([
|
|
@@ -2515,6 +2929,9 @@ export async function executeSessionDeletion({
|
|
|
2515
2929
|
if (activeThreads.ids.length > 0) {
|
|
2516
2930
|
throw cleanupErrorWithBackup(activeThreadError(activeThreads.ids), backupDirectory);
|
|
2517
2931
|
}
|
|
2932
|
+
if (activeThreads.unverifiedIds?.length > 0) {
|
|
2933
|
+
throw cleanupErrorWithBackup(unverifiedThreadError(activeThreads.unverifiedIds), backupDirectory);
|
|
2934
|
+
}
|
|
2518
2935
|
|
|
2519
2936
|
await reportProgress(onProgress, {
|
|
2520
2937
|
canCancel: false,
|
|
@@ -2526,7 +2943,10 @@ export async function executeSessionDeletion({
|
|
|
2526
2943
|
try {
|
|
2527
2944
|
if (scope === "deep" && store.hasMemoryDatabase) {
|
|
2528
2945
|
for (const databasePath of store.memoryDatabasePaths ?? [store.memoryDatabasePath]) {
|
|
2529
|
-
executeTransaction(
|
|
2946
|
+
executeTransaction(
|
|
2947
|
+
databasePath,
|
|
2948
|
+
memoryDeletionStatements(databasePath, plan.ids, Math.floor(Date.now() / 1_000)),
|
|
2949
|
+
);
|
|
2530
2950
|
}
|
|
2531
2951
|
}
|
|
2532
2952
|
if (scope === "deep" && store.hasGoalsDatabase) {
|
|
@@ -2555,11 +2975,8 @@ export async function executeSessionDeletion({
|
|
|
2555
2975
|
}
|
|
2556
2976
|
if (store.hasThreadHistoryDatabase) {
|
|
2557
2977
|
for (const databasePath of store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath]) {
|
|
2558
|
-
executeTransaction(databasePath,
|
|
2559
|
-
...deleteStatements(
|
|
2560
|
-
...deleteStatements("thread_turns", "thread_id", plan.ids),
|
|
2561
|
-
...deleteStatements("thread_history_projection_state", "thread_id", plan.ids),
|
|
2562
|
-
]);
|
|
2978
|
+
executeTransaction(databasePath, threadHistoryTables(databasePath).flatMap((tableName) =>
|
|
2979
|
+
[...deleteStatements(tableName, "thread_id", plan.threadHistoryIds ?? plan.ids)]));
|
|
2563
2980
|
}
|
|
2564
2981
|
}
|
|
2565
2982
|
for (const { database, ids } of plan.stateTargets ?? (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({ database, ids: plan.ids }))) {
|
|
@@ -2910,7 +3327,16 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
|
2910
3327
|
: []);
|
|
2911
3328
|
const remainingMemoryRecords = scope === "deep" && store.hasMemoryDatabase
|
|
2912
3329
|
? (store.memoryDatabasePaths ?? [store.memoryDatabasePath]).flatMap((databasePath) =>
|
|
2913
|
-
|
|
3330
|
+
[
|
|
3331
|
+
...findRowsForIds(databasePath, "stage1_outputs", "thread_id", plan.ids),
|
|
3332
|
+
...findMemoryJobsForIds(databasePath, plan.ids),
|
|
3333
|
+
])
|
|
3334
|
+
: [];
|
|
3335
|
+
const missingMemoryConsolidations = scope === "deep"
|
|
3336
|
+
? (plan.memoryConsolidations ?? []).filter(({ databasePath, inputWatermark }) => {
|
|
3337
|
+
const currentWatermark = memoryConsolidationWatermark(databasePath);
|
|
3338
|
+
return currentWatermark === null || currentWatermark <= (inputWatermark ?? 0);
|
|
3339
|
+
})
|
|
2914
3340
|
: [];
|
|
2915
3341
|
const remainingGoalRecords = scope === "deep" && store.hasGoalsDatabase
|
|
2916
3342
|
? (store.goalsDatabasePaths ?? [store.goalsDatabasePath]).flatMap((databasePath) =>
|
|
@@ -2932,11 +3358,15 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
|
2932
3358
|
])
|
|
2933
3359
|
: [];
|
|
2934
3360
|
const remainingThreadHistoryRecords = store.hasThreadHistoryDatabase
|
|
2935
|
-
? (store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath]).flatMap((databasePath) =>
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
3361
|
+
? (store.threadHistoryDatabasePaths ?? [store.threadHistoryDatabasePath]).flatMap((databasePath) =>
|
|
3362
|
+
threadHistoryTables(databasePath).flatMap((tableName) =>
|
|
3363
|
+
findRowsForIds(
|
|
3364
|
+
databasePath,
|
|
3365
|
+
tableName,
|
|
3366
|
+
"thread_id",
|
|
3367
|
+
plan.threadHistoryIds ?? plan.ids,
|
|
3368
|
+
{ limitOne: true },
|
|
3369
|
+
)))
|
|
2940
3370
|
: [];
|
|
2941
3371
|
const [sessionIndexMatches, historyMatches] = await Promise.all([
|
|
2942
3372
|
inspectJsonlMatches(
|
|
@@ -2980,6 +3410,7 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
|
2980
3410
|
remainingThreads.length === 0 &&
|
|
2981
3411
|
remainingDynamicTools.length === 0 &&
|
|
2982
3412
|
remainingMemoryRecords.length === 0 &&
|
|
3413
|
+
missingMemoryConsolidations.length === 0 &&
|
|
2983
3414
|
remainingGoalRecords.length === 0 &&
|
|
2984
3415
|
remainingLogRecords.length === 0 &&
|
|
2985
3416
|
remainingQueueRecords.length === 0 &&
|
|
@@ -2994,6 +3425,7 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
|
2994
3425
|
remainingHistoryEntries,
|
|
2995
3426
|
remainingHistoryEntryCount: historyMatches.count,
|
|
2996
3427
|
remainingLogRecords,
|
|
3428
|
+
missingMemoryConsolidations,
|
|
2997
3429
|
remainingMemoryRecords,
|
|
2998
3430
|
remainingQueueRecords,
|
|
2999
3431
|
remainingThreadHistoryRecords,
|