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$/u.test(entry.name)) {
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 stream = createReadStream(filePath, {
167
- encoding: "utf8",
168
- });
197
+ const { input, source } = createTranscriptTextStream(filePath);
169
198
  const interfaceHandle = readline.createInterface({
170
199
  crlfDelay: Infinity,
171
- input: stream,
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
- stream.destroy();
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 stream = createReadStream(filePath, {
217
- encoding: "utf8",
218
- });
250
+ const { input, source } = createTranscriptTextStream(filePath);
219
251
  const interfaceHandle = readline.createInterface({
220
252
  crlfDelay: Infinity,
221
- input: stream,
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
- stream.destroy();
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 transcriptHeaders = await indexTranscriptHeaders(paths.transcriptDirectories);
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 indexTranscriptHeaders(transcriptDirectories) {
1260
- const headersById = new Map();
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
- const current = header?.id ? headersById.get(String(header.id)) : null;
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
- if (header?.id && (!current || header.filePath > current.filePath)) {
1269
- headersById.set(String(header.id), { ...header, id: String(header.id) });
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 headersById;
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, transcriptHeaders] = await Promise.all([
1389
+ const [availability, transcriptIndex] = await Promise.all([
1325
1390
  getStoreAvailability(paths),
1326
- indexTranscriptHeaders(paths.transcriptDirectories),
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 = [...recordIds];
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 transcriptPaths = selectedRecords
1930
- .map((record) => record.rolloutPath)
1931
- .filter(Boolean);
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 memoryRowCount = (store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean))
1993
- .reduce((total, databasePath) => total + countRowsForIds(databasePath, "stage1_outputs", "thread_id", deletionIds), 0);
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
- "thread_items",
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
- fingerprintRowsForIds(hash, databasePath, "thread_items", "thread_id", plan.ids);
2152
- fingerprintRowsForIds(hash, databasePath, "thread_turns", "thread_id", plan.ids);
2153
- fingerprintRowsForIds(hash, databasePath, "thread_history_projection_state", "thread_id", plan.ids);
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 lockedIds = new Set(entries
2619
+ const lockPathsById = new Map(entries
2235
2620
  .filter((entry) => entry.isFile() && entry.name.endsWith(".lock"))
2236
- .map((entry) => entry.name.slice(0, -5)));
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: ids.filter((id) => lockedIds.has(id)),
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
- ? "Close the selected Codex session before cleanup."
2247
- : `Close the ${ids.length} selected Codex sessions that are still open before cleanup.`,
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(databasePath, deleteStatements("stage1_outputs", "thread_id", plan.ids));
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("thread_items", "thread_id", plan.ids),
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
- findRowsForIds(databasePath, "stage1_outputs", "thread_id", plan.ids))
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
- ...findRowsForIds(databasePath, "thread_items", "thread_id", plan.ids, { limitOne: true }),
2937
- ...findRowsForIds(databasePath, "thread_turns", "thread_id", plan.ids, { limitOne: true }),
2938
- ...findRowsForIds(databasePath, "thread_history_projection_state", "thread_id", plan.ids, { limitOne: true }),
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,