pi-hermes-memory 0.7.22 → 0.8.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.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Markdown memory sync command — /memory-sync-markdown imports existing
3
- * Markdown-backed memories into the SQLite search store.
2
+ * Markdown memory sync command — /memory-sync-markdown reconciles the SQLite
3
+ * search mirror with authoritative Markdown memory files.
4
4
  */
5
5
 
6
6
  import fs from 'node:fs';
@@ -8,20 +8,30 @@ import path from 'node:path';
8
8
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
9
  import { DatabaseManager } from '../store/db.js';
10
10
  import {
11
- parseMarkdownMemoryEntry,
12
- syncMemoryEntry,
11
+ reconcileMarkdownFailureScopes,
12
+ reconcileMarkdownMemoryScope,
13
13
  } from '../store/sqlite-memory-store.js';
14
14
  import { ENTRY_DELIMITER, MEMORY_FILE, USER_FILE } from '../constants.js';
15
15
  import { AGENT_ROOT } from '../paths.js';
16
+ import {
17
+ migrateExtensionRoot,
18
+ type ExtensionRootMigrationOptions,
19
+ } from '../extension-root-migration.js';
20
+ import { withMarkdownMutationLock } from '../store/markdown-mutation-lock.js';
16
21
 
17
22
  export interface BackfillCounters {
18
23
  filesScanned: number;
19
24
  entriesScanned: number;
20
25
  imported: number;
21
26
  skipped: number;
27
+ removed: number;
22
28
  warnings: string[];
23
29
  }
24
30
 
31
+ export interface MigrationSyncOptions extends ExtensionRootMigrationOptions {
32
+ onMigrationSucceeded?: () => void;
33
+ }
34
+
25
35
  function readEntries(filePath: string): string[] {
26
36
  if (!fs.existsSync(filePath)) return [];
27
37
  const raw = fs.readFileSync(filePath, 'utf-8').trim();
@@ -29,37 +39,15 @@ function readEntries(filePath: string): string[] {
29
39
  return raw.split(ENTRY_DELIMITER).map((entry) => entry.trim()).filter(Boolean);
30
40
  }
31
41
 
32
- function importEntries(
33
- dbManager: DatabaseManager,
34
- counters: BackfillCounters,
35
- entries: string[],
36
- target: 'memory' | 'user' | 'failure',
37
- project: string | null = null,
38
- ): void {
39
- for (const rawEntry of entries) {
40
- counters.entriesScanned++;
41
- try {
42
- const parsed = parseMarkdownMemoryEntry(rawEntry, target, project);
43
- const result = syncMemoryEntry(dbManager, parsed);
44
- if (result.action === 'inserted') counters.imported++;
45
- else counters.skipped++;
46
- } catch (err) {
47
- counters.warnings.push(
48
- `${path.basename(project ?? 'global')}/${target}: ${err instanceof Error ? err.message : String(err)}`,
49
- );
50
- }
51
- }
52
- }
53
-
54
42
  function scanProjectDirs(agentRoot: string, globalDir: string, projectsMemoryDir = "projects-memory"): Array<{ name: string; memoryFile: string }> {
55
- const projectsRoot = path.join(agentRoot, projectsMemoryDir);
43
+ const projectsRoot = path.resolve(agentRoot, projectsMemoryDir);
56
44
  const projects = new Map<string, string>();
57
45
 
58
46
  if (fs.existsSync(projectsRoot)) {
59
47
  for (const name of fs.readdirSync(projectsRoot)) {
60
- const dir = path.join(projectsRoot, name);
61
- const memoryFile = path.join(dir, MEMORY_FILE);
62
- if (fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.existsSync(memoryFile)) {
48
+ if (!isSafeProjectName(name, projectsRoot)) continue;
49
+ const memoryFile = resolveAuthoritativeMemoryFile(projectsRoot, name);
50
+ if (memoryFile) {
63
51
  projects.set(name, memoryFile);
64
52
  }
65
53
  }
@@ -74,9 +62,9 @@ function scanProjectDirs(agentRoot: string, globalDir: string, projectsMemoryDir
74
62
  for (const name of fs.readdirSync(agentRoot)) {
75
63
  if ((globalDirName && name === globalDirName) || name === projectsMemoryDir || name === 'skills' || name.startsWith('.')) continue;
76
64
  if (projects.has(name)) continue;
77
- const dir = path.join(agentRoot, name);
78
- const memoryFile = path.join(dir, MEMORY_FILE);
79
- if (fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.existsSync(memoryFile)) {
65
+ if (!isSafeProjectName(name, resolvedAgentRoot)) continue;
66
+ const memoryFile = resolveAuthoritativeMemoryFile(resolvedAgentRoot, name);
67
+ if (memoryFile) {
80
68
  projects.set(name, memoryFile);
81
69
  }
82
70
  }
@@ -87,17 +75,73 @@ function scanProjectDirs(agentRoot: string, globalDir: string, projectsMemoryDir
87
75
  .filter(({ memoryFile }) => fs.existsSync(memoryFile));
88
76
  }
89
77
 
90
- export function syncMarkdownMemoriesToSqlite(
78
+ function realpathIfPresent(filePath: string): string {
79
+ try {
80
+ return fs.realpathSync(filePath);
81
+ } catch (error) {
82
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return path.resolve(filePath);
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ function resolveAuthoritativeMemoryFile(root: string, projectName: string): string | null {
88
+ const canonicalRoot = realpathIfPresent(root);
89
+ if (!isSafeProjectName(projectName, path.resolve(root))) return null;
90
+
91
+ const projectDir = path.join(root, projectName);
92
+ let projectStat: fs.Stats;
93
+ try {
94
+ projectStat = fs.lstatSync(projectDir);
95
+ } catch (error) {
96
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
97
+ return path.join(canonicalRoot, projectName, MEMORY_FILE);
98
+ }
99
+ throw error;
100
+ }
101
+ if (projectStat.isSymbolicLink() || !projectStat.isDirectory()) return null;
102
+
103
+ const canonicalProjectDir = fs.realpathSync(projectDir);
104
+ if (path.dirname(canonicalProjectDir) !== canonicalRoot) return null;
105
+
106
+ const memoryFile = path.join(projectDir, MEMORY_FILE);
107
+ let memoryStat: fs.Stats;
108
+ try {
109
+ memoryStat = fs.lstatSync(memoryFile);
110
+ } catch (error) {
111
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
112
+ return path.join(canonicalProjectDir, MEMORY_FILE);
113
+ }
114
+ throw error;
115
+ }
116
+ if (memoryStat.isSymbolicLink() || !memoryStat.isFile()) return null;
117
+
118
+ const canonicalMemoryFile = fs.realpathSync(memoryFile);
119
+ if (path.dirname(canonicalMemoryFile) !== canonicalProjectDir || path.basename(canonicalMemoryFile) !== MEMORY_FILE) {
120
+ return null;
121
+ }
122
+ return canonicalMemoryFile;
123
+ }
124
+
125
+ function isSafeProjectName(name: string, projectsRoot: string): boolean {
126
+ if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\') || path.isAbsolute(name)) {
127
+ return false;
128
+ }
129
+ const projectDir = path.resolve(projectsRoot, name);
130
+ return path.dirname(projectDir) === projectsRoot && path.basename(projectDir) === name;
131
+ }
132
+
133
+ export async function syncMarkdownMemoriesToSqlite(
91
134
  dbManager: DatabaseManager,
92
135
  globalDir: string,
93
136
  projectsMemoryDir?: string,
94
137
  agentRoot = AGENT_ROOT,
95
- ): BackfillCounters & { projectCount: number } {
138
+ ): Promise<BackfillCounters & { projectCount: number }> {
96
139
  const counters: BackfillCounters = {
97
140
  filesScanned: 0,
98
141
  entriesScanned: 0,
99
142
  imported: 0,
100
143
  skipped: 0,
144
+ removed: 0,
101
145
  warnings: [],
102
146
  };
103
147
 
@@ -105,27 +149,74 @@ export function syncMarkdownMemoriesToSqlite(
105
149
  const globalUserFile = path.join(globalDir, USER_FILE);
106
150
  const globalFailureFile = path.join(globalDir, 'failures.md');
107
151
 
108
- const importFile = (
109
- filePath: string,
152
+ const reconcileFile = async (
153
+ filePath: string | null,
110
154
  target: 'memory' | 'user' | 'failure',
111
155
  project: string | null = null,
112
156
  ) => {
113
- if (!fs.existsSync(filePath)) return;
114
- counters.filesScanned++;
115
- const entries = readEntries(filePath);
116
- importEntries(dbManager, counters, entries, target, project);
157
+ const reconcile = () => {
158
+ if (filePath && fs.existsSync(filePath)) counters.filesScanned++;
159
+ const entries = filePath ? readEntries(filePath) : [];
160
+ counters.entriesScanned += entries.length;
161
+ try {
162
+ const result = target === 'failure'
163
+ ? reconcileMarkdownFailureScopes(dbManager, entries)
164
+ : reconcileMarkdownMemoryScope(dbManager, entries, target, project);
165
+ counters.imported += result.inserted;
166
+ counters.skipped += result.existing;
167
+ counters.removed += result.removed;
168
+ } catch (err) {
169
+ counters.warnings.push(
170
+ `${path.basename(project ?? 'global')}/${target}: ${err instanceof Error ? err.message : String(err)}`,
171
+ );
172
+ }
173
+ };
174
+ if (filePath) await withMarkdownMutationLock(filePath, reconcile);
175
+ else reconcile();
117
176
  };
118
177
 
119
- importFile(globalMemoryFile, 'memory');
120
- importFile(globalUserFile, 'user');
121
- importFile(globalFailureFile, 'failure');
178
+ await reconcileFile(globalMemoryFile, 'memory');
179
+ await reconcileFile(globalUserFile, 'user');
180
+ await reconcileFile(globalFailureFile, 'failure');
122
181
 
123
182
  const projects = scanProjectDirs(agentRoot, globalDir, projectsMemoryDir);
124
- for (const project of projects) {
125
- importFile(project.memoryFile, 'memory', project.name);
183
+ const projectFiles = new Map(projects.map((project) => [project.name, project.memoryFile]));
184
+ const mirroredProjects = dbManager.getDb().prepare(`
185
+ SELECT DISTINCT project
186
+ FROM memories
187
+ WHERE project IS NOT NULL AND target = 'memory'
188
+ `).all() as Array<{ project: string }>;
189
+ const projectNames = new Set([
190
+ ...projectFiles.keys(),
191
+ ...mirroredProjects.map(({ project }) => project),
192
+ ]);
193
+ const projectsRoot = path.resolve(agentRoot, projectsMemoryDir ?? 'projects-memory');
194
+ for (const projectName of projectNames) {
195
+ const memoryFile = projectFiles.get(projectName)
196
+ ?? resolveAuthoritativeMemoryFile(projectsRoot, projectName);
197
+ await reconcileFile(memoryFile, 'memory', projectName);
126
198
  }
127
199
 
128
- return { ...counters, projectCount: projects.length };
200
+ return { ...counters, projectCount: projectNames.size };
201
+ }
202
+
203
+ export async function migrateThenSyncMarkdownMemories(
204
+ dbManager: DatabaseManager,
205
+ legacyGlobalDir: string | null,
206
+ globalDir: string,
207
+ projectsMemoryDir?: string,
208
+ agentRoot = AGENT_ROOT,
209
+ migrationOptions: MigrationSyncOptions = {},
210
+ ): Promise<BackfillCounters & { projectCount: number }> {
211
+ if (legacyGlobalDir) {
212
+ const migration = await migrateExtensionRoot(legacyGlobalDir, globalDir, migrationOptions);
213
+ const sessionsFailure = migration.criticalFailures.find((failure) => failure.name === 'sessions.db');
214
+ if (sessionsFailure) {
215
+ throw new Error(`sessions.db migration failed: ${sessionsFailure.message}`);
216
+ }
217
+ migrationOptions.onMigrationSucceeded?.();
218
+ }
219
+ return await syncMarkdownMemoriesToSqlite(dbManager, globalDir, projectsMemoryDir, agentRoot);
129
220
  }
130
221
 
131
222
  export function registerSyncMarkdownMemoriesCommand(
@@ -136,19 +227,20 @@ export function registerSyncMarkdownMemoriesCommand(
136
227
  agentRoot = AGENT_ROOT,
137
228
  ): void {
138
229
  pi.registerCommand('memory-sync-markdown', {
139
- description: 'Backfill Markdown memories into the SQLite search store',
230
+ description: 'Reconcile the SQLite search mirror with Markdown memories',
140
231
  handler: async (_args, ctx: ExtensionCommandContext) => {
141
- ctx.ui.notify('🔄 Scanning Markdown memory files for SQLite backfill...', 'info');
232
+ ctx.ui.notify('🔄 Reconciling the SQLite search mirror with Markdown memories...', 'info');
142
233
 
143
234
  try {
144
- const counters = syncMarkdownMemoriesToSqlite(dbManager, globalDir, projectsMemoryDir, agentRoot);
235
+ const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, projectsMemoryDir, agentRoot);
145
236
 
146
237
  let output = `\n✅ Markdown → SQLite sync complete!\n\n`;
147
238
  output += `📊 Results:\n`;
148
239
  output += `├─ Files scanned: ${counters.filesScanned}\n`;
149
240
  output += `├─ Entries scanned: ${counters.entriesScanned}\n`;
150
241
  output += `├─ Imported into SQLite: ${counters.imported}\n`;
151
- output += `└─ Skipped as duplicates: ${counters.skipped}\n`;
242
+ output += `├─ Skipped as duplicates: ${counters.skipped}\n`;
243
+ output += `└─ Removed orphaned rows: ${counters.removed}\n`;
152
244
 
153
245
  if (counters.projectCount > 0) {
154
246
  output += `\n📁 Project memories scanned: ${counters.projectCount}\n`;
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import * as path from "node:path";
26
+ import * as fs from "node:fs";
26
27
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
28
  import { MemoryStore } from "./store/memory-store.js";
28
29
  import { SkillStore } from "./store/skill-store.js";
@@ -45,14 +46,14 @@ import { registerInterviewCommand } from "./handlers/interview.js";
45
46
  import { registerSwitchProjectCommand } from "./handlers/switch-project.js";
46
47
  import { registerIndexSessionsCommand } from "./handlers/index-sessions.js";
47
48
  import { registerLearnMemoryCommand } from "./handlers/learn-memory.js";
48
- import { registerSyncMarkdownMemoriesCommand, syncMarkdownMemoriesToSqlite } from "./handlers/sync-markdown-memories.js";
49
+ import { migrateThenSyncMarkdownMemories, registerSyncMarkdownMemoriesCommand } from "./handlers/sync-markdown-memories.js";
49
50
  import { registerPreviewContextCommand } from "./handlers/preview-context.js";
50
51
  import { loadConfig } from "./config.js";
51
52
  import { detectProject, detectProjectSkills } from "./project.js";
52
53
  import { buildPromptContext } from "./prompt-context.js";
53
54
  import { migrateLegacyProjectMemoryDirs } from "./project-memory-migration.js";
54
- import { migrateExtensionRoot } from "./extension-root-migration.js";
55
55
  import { AGENT_ROOT } from "./paths.js";
56
+ import { isDatabaseMigrationPending } from "./extension-root-migration.js";
56
57
 
57
58
  export function resolveProjectSkillDiscovery(
58
59
  skillStore: SkillStore,
@@ -95,7 +96,7 @@ export default function (pi: ExtensionAPI) {
95
96
  : configuredMemoryDir;
96
97
 
97
98
  const shouldMigrateExtensionRoot = !configuredMemoryDir || pointsToLegacyMemoryDir;
98
- let extensionRootMigrated = false;
99
+ let persistenceInitialized = false;
99
100
 
100
101
  const store = new MemoryStore({ ...config, memoryDir: globalDir });
101
102
  const project = detectProject(config.projectsMemoryDir);
@@ -109,6 +110,15 @@ export default function (pi: ExtensionAPI) {
109
110
  migrationSentinelPath: path.join(globalDir, ".skills-migrated-to-extension-storage"),
110
111
  });
111
112
  const dbManager = new DatabaseManager(globalDir);
113
+ let databaseMigrationPending = shouldMigrateExtensionRoot
114
+ && isDatabaseMigrationPending(legacyGlobalDir, globalDir);
115
+ if (databaseMigrationPending) {
116
+ dbManager.setOpenGuard(() => {
117
+ if (databaseMigrationPending) {
118
+ throw new Error("Legacy sessions.db migration is pending");
119
+ }
120
+ });
121
+ }
112
122
  const sessionsDir = path.join(agentRoot, "sessions");
113
123
 
114
124
  const refreshSkillProjectContext = (cwd?: string) => {
@@ -124,12 +134,6 @@ export default function (pi: ExtensionAPI) {
124
134
  // ~/.pi/agent/<project>/ layout. This is non-destructive: legacy folders
125
135
  // remain in place while entries are copied/merged into projects-memory/.
126
136
  migrateLegacyProjectMemoryDirs(agentRoot, config.projectsMemoryDir);
127
- try {
128
- syncMarkdownMemoriesToSqlite(dbManager, globalDir, config.projectsMemoryDir, agentRoot);
129
- } catch {
130
- // Best-effort only: failed SQLite backfill should not block extension startup.
131
- }
132
-
133
137
  // Detect project from cwd using shared helper
134
138
  // Project-scoped store: ~/.pi/agent/<projectsMemoryDir>/<project_name>/
135
139
  const projectConfig = project.memoryDir
@@ -139,13 +143,25 @@ export default function (pi: ExtensionAPI) {
139
143
 
140
144
  // ── 1. Load memory from disk on session start ──
141
145
  pi.on("session_start", async (_event, ctx) => {
142
- if (shouldMigrateExtensionRoot && !extensionRootMigrated) {
146
+ if (!persistenceInitialized) {
143
147
  try {
144
- await migrateExtensionRoot(legacyGlobalDir, globalDir);
148
+ await migrateThenSyncMarkdownMemories(
149
+ dbManager,
150
+ shouldMigrateExtensionRoot ? legacyGlobalDir : null,
151
+ globalDir,
152
+ config.projectsMemoryDir,
153
+ agentRoot,
154
+ {
155
+ onMigrationSucceeded: () => {
156
+ databaseMigrationPending = false;
157
+ dbManager.setOpenGuard(null);
158
+ },
159
+ },
160
+ );
161
+ persistenceInitialized = true;
145
162
  } catch {
146
- // best effort migration only
163
+ // Best-effort only: migration or SQLite backfill must not block startup.
147
164
  }
148
- extensionRootMigrated = true;
149
165
  }
150
166
 
151
167
  refreshSkillProjectContext(ctx.cwd);
@@ -154,7 +170,7 @@ export default function (pi: ExtensionAPI) {
154
170
  await store.loadFromDisk();
155
171
  if (projectStore) await projectStore.loadFromDisk();
156
172
 
157
- scheduleSessionBackfill(dbManager, sessionsDir, {
173
+ if (persistenceInitialized) scheduleSessionBackfill(dbManager, sessionsDir, {
158
174
  notify: (message, level) => {
159
175
  const ui = (ctx as { ui?: { notify?: (message: string, level?: string) => void } }).ui;
160
176
  if (ui?.notify) {
@@ -188,10 +204,13 @@ export default function (pi: ExtensionAPI) {
188
204
  registerSkillTool(pi, skillStore);
189
205
 
190
206
  // ── 5. Setup background learning loop (with tool-call-aware nudge) ──
191
- setupBackgroundReview(pi, store, projectStore, config);
207
+ setupBackgroundReview(pi, store, projectStore, config, {
208
+ dbManager,
209
+ projectName: projectName || null,
210
+ });
192
211
 
193
212
  // ── 6. Setup session-end flush ──
194
- setupSessionFlush(pi, store, projectStore, config);
213
+ setupSessionFlush(pi, store, projectStore, config, dbManager, projectName);
195
214
 
196
215
  // ── 7. Setup auto-consolidation (inject consolidator into stores) ──
197
216
  store.setConsolidator(async (target, signal) => {
@@ -203,7 +222,7 @@ export default function (pi: ExtensionAPI) {
203
222
  return triggerConsolidation(pi, projectStore, target, signal, config.consolidationTimeoutMs, toolTarget, config);
204
223
  });
205
224
  }
206
- registerConsolidateCommand(pi, store, config.consolidationTimeoutMs, projectStore, projectName, config);
225
+ registerConsolidateCommand(pi, store, config.consolidationTimeoutMs, projectStore, projectName, config, dbManager);
207
226
 
208
227
  // ── 8. Setup correction detection ──
209
228
  setupCorrectionDetector(pi, store, projectStore, config, dbManager, projectName);
@@ -0,0 +1,252 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { createRequire } from 'node:module';
5
+ import { spawnSync } from 'node:child_process';
6
+
7
+ type StatementLike = {
8
+ run: (...args: unknown[]) => unknown;
9
+ get: (...args: unknown[]) => unknown;
10
+ all: (...args: unknown[]) => unknown[];
11
+ };
12
+
13
+ type DatabaseLike = {
14
+ prepare: (sql: string) => StatementLike;
15
+ exec: (sql: string) => void;
16
+ close: () => void;
17
+ };
18
+
19
+ type DatabaseCtor = new (dbPath: string) => DatabaseLike;
20
+
21
+ export interface AtomicLockOptions {
22
+ staleMs: number;
23
+ }
24
+
25
+ export interface AtomicLockLease {
26
+ token: string;
27
+ release: () => void;
28
+ }
29
+
30
+ export interface AtomicLockCoordinatorOptions {
31
+ pid?: number;
32
+ incarnation?: string;
33
+ probeIncarnation?: (pid: number) => string | null;
34
+ }
35
+
36
+ function loadDatabaseCtor(): DatabaseCtor {
37
+ const require = createRequire(import.meta.url);
38
+ if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') {
39
+ const bunSqlite = require('bun:sqlite') as { Database: DatabaseCtor };
40
+ return bunSqlite.Database;
41
+ }
42
+ const mod = require('better-sqlite3') as { default?: DatabaseCtor } | DatabaseCtor;
43
+ return (mod as { default?: DatabaseCtor }).default ?? (mod as DatabaseCtor);
44
+ }
45
+
46
+ const Database = loadDatabaseCtor();
47
+
48
+ function processIsAlive(pid: number): boolean {
49
+ try {
50
+ process.kill(pid, 0);
51
+ return true;
52
+ } catch (error) {
53
+ return (error as NodeJS.ErrnoException).code !== 'ESRCH';
54
+ }
55
+ }
56
+
57
+ function probeProcessIncarnation(pid: number): string | null {
58
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
59
+ if (process.platform === 'linux') {
60
+ try {
61
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf-8');
62
+ const end = stat.lastIndexOf(')');
63
+ const fields = stat.slice(end + 2).split(' ');
64
+ return fields[19] || null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ if (process.platform !== 'win32') {
71
+ const result = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
72
+ encoding: 'utf-8',
73
+ timeout: 250,
74
+ });
75
+ return result.status === 0 ? result.stdout.trim() || null : null;
76
+ }
77
+
78
+ const result = spawnSync(
79
+ 'powershell.exe',
80
+ ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${pid} -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().Ticks`],
81
+ { encoding: 'utf-8', timeout: 500 },
82
+ );
83
+ return result.status === 0 ? result.stdout.trim() || null : null;
84
+ }
85
+
86
+ const currentProcessIncarnation = probeProcessIncarnation(process.pid);
87
+ const RELEASE_ATTEMPTS = 3;
88
+ const pendingReleases = new Map<string, () => void>();
89
+
90
+ export class AtomicLockCoordinator {
91
+ private readonly pid: number;
92
+ private readonly incarnation: string | null;
93
+ private readonly probeIncarnation: (pid: number) => string | null;
94
+
95
+ constructor(private readonly dbPath: string, options: AtomicLockCoordinatorOptions = {}) {
96
+ this.pid = options.pid ?? process.pid;
97
+ this.probeIncarnation = options.probeIncarnation
98
+ ?? ((pid) => pid === process.pid ? currentProcessIncarnation : probeProcessIncarnation(pid));
99
+ this.incarnation = options.incarnation
100
+ ?? this.probeIncarnation(this.pid)
101
+ ?? null;
102
+ }
103
+
104
+ tryAcquire(key: string, options: AtomicLockOptions): AtomicLockLease | null {
105
+ this.retryPendingReleases(key);
106
+ const token = randomUUID();
107
+ const now = Date.now();
108
+ const db = this.open();
109
+ let acquired = false;
110
+
111
+ try {
112
+ db.exec('BEGIN IMMEDIATE');
113
+ try {
114
+ const owner = db.prepare(`
115
+ SELECT token, pid, incarnation, acquired_at
116
+ FROM locks
117
+ WHERE lock_key = ?
118
+ `).get(key) as { token: string; pid: number; incarnation: string | null; acquired_at: number } | undefined;
119
+
120
+ if (!owner) {
121
+ db.prepare(`
122
+ INSERT INTO locks (lock_key, token, pid, incarnation, acquired_at)
123
+ VALUES (?, ?, ?, ?, ?)
124
+ `).run(key, token, this.pid, this.incarnation, now);
125
+ acquired = true;
126
+ } else {
127
+ const observedIncarnation = this.probeIncarnation(owner.pid);
128
+ const alive = observedIncarnation !== null || processIsAlive(owner.pid);
129
+ const sameIncarnation = alive
130
+ && owner.incarnation !== null
131
+ && observedIncarnation !== null
132
+ && owner.incarnation === observedIncarnation;
133
+ const unknownIncarnation = alive && (owner.incarnation === null || observedIncarnation === null);
134
+ // A lease is reclaimable once it has been held for longer than staleMs,
135
+ // regardless of whether the owning process is still alive — it may be
136
+ // making no progress (blocked I/O, wedged, suspended) rather than dead.
137
+ // This is the sole backstop for that case: liveness/incarnation checks
138
+ // alone cannot distinguish "alive and working" from "alive and stuck".
139
+ // staleMs <= 0 disables time-based takeover (liveness checks only).
140
+ const stale = options.staleMs > 0 && now - owner.acquired_at >= options.staleMs;
141
+ if (stale || (!sameIncarnation && !unknownIncarnation)) {
142
+ db.prepare(`
143
+ UPDATE locks
144
+ SET token = ?, pid = ?, incarnation = ?, acquired_at = ?
145
+ WHERE lock_key = ? AND token = ?
146
+ `).run(token, this.pid, this.incarnation, now, key, owner.token);
147
+ acquired = true;
148
+ }
149
+ }
150
+
151
+ db.exec('COMMIT');
152
+ } catch (error) {
153
+ try { db.exec('ROLLBACK'); } catch {}
154
+ throw error;
155
+ }
156
+ } finally {
157
+ db.close();
158
+ }
159
+
160
+ if (!acquired) return null;
161
+ return {
162
+ token,
163
+ release: () => this.release(key, token),
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Fencing check for destructive operations that lack their own independent
169
+ * compare-and-swap (e.g. a plain fs.renameSync with no content/inode
170
+ * verification). A lease can be legitimately stolen from a stale-but-alive
171
+ * holder (see tryAcquire); a holder resuming after being stuck must verify
172
+ * it is still the current owner immediately before publishing, or abort.
173
+ * This narrows — it cannot fully close — the check-then-act race, since
174
+ * synchronous work between this call and the actual write is not atomic
175
+ * with it.
176
+ */
177
+ isCurrentOwner(key: string, token: string): boolean {
178
+ const db = this.open();
179
+ try {
180
+ const row = db.prepare('SELECT token FROM locks WHERE lock_key = ?').get(key) as { token: string } | undefined;
181
+ return row?.token === token;
182
+ } finally {
183
+ db.close();
184
+ }
185
+ }
186
+
187
+ release(key: string, token: string): void {
188
+ const pendingKey = this.pendingReleaseKey(key, token);
189
+ for (let attempt = 0; attempt < RELEASE_ATTEMPTS; attempt++) {
190
+ try {
191
+ this.deleteOwnedLock(key, token);
192
+ pendingReleases.delete(pendingKey);
193
+ return;
194
+ } catch {
195
+ }
196
+ }
197
+ pendingReleases.set(pendingKey, () => this.release(key, token));
198
+ }
199
+
200
+ private deleteOwnedLock(key: string, token: string): void {
201
+ const db = this.open();
202
+ try {
203
+ db.prepare('DELETE FROM locks WHERE lock_key = ? AND token = ?').run(key, token);
204
+ } finally {
205
+ db.close();
206
+ }
207
+ }
208
+
209
+ private retryPendingReleases(key: string): void {
210
+ const prefix = `${path.resolve(this.dbPath)}\0${key}\0`;
211
+ for (const [pendingKey, release] of [...pendingReleases.entries()]) {
212
+ if (pendingKey.startsWith(prefix)) release();
213
+ }
214
+ }
215
+
216
+ private pendingReleaseKey(key: string, token: string): string {
217
+ return `${path.resolve(this.dbPath)}\0${key}\0${token}`;
218
+ }
219
+
220
+ private open(): DatabaseLike {
221
+ fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
222
+ const existed = fs.existsSync(this.dbPath);
223
+ const db = new Database(this.dbPath);
224
+ try {
225
+ db.exec(`
226
+ PRAGMA busy_timeout = 5000;
227
+ PRAGMA journal_mode = WAL;
228
+ CREATE TABLE IF NOT EXISTS locks (
229
+ lock_key TEXT PRIMARY KEY,
230
+ token TEXT NOT NULL,
231
+ pid INTEGER NOT NULL,
232
+ incarnation TEXT,
233
+ acquired_at INTEGER NOT NULL
234
+ );
235
+ `);
236
+ const columns = db.prepare('PRAGMA table_info(locks)').all() as Array<{ name: string }>;
237
+ if (!columns.some(({ name }) => name === 'incarnation')) {
238
+ try {
239
+ db.exec('ALTER TABLE locks ADD COLUMN incarnation TEXT');
240
+ } catch (error) {
241
+ const refreshed = db.prepare('PRAGMA table_info(locks)').all() as Array<{ name: string }>;
242
+ if (!refreshed.some(({ name }) => name === 'incarnation')) throw error;
243
+ }
244
+ }
245
+ if (!existed) fs.chmodSync(this.dbPath, 0o600);
246
+ return db;
247
+ } catch (error) {
248
+ db.close();
249
+ throw error;
250
+ }
251
+ }
252
+ }