dsh-plugin-sessiongraph-sessions 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts CHANGED
@@ -110,6 +110,12 @@ export interface Config {
110
110
  * explicit `sessiongraph_index` / `/sessiongraph index` before anything is searchable.
111
111
  */
112
112
  autoBuild?: boolean;
113
+ /**
114
+ * Treat a write lock older than this many milliseconds as abandoned (default 300000). A crashed
115
+ * run leaves `index.lock` behind; without a staleness window the index would stay unwritable until
116
+ * someone deleted the file by hand.
117
+ */
118
+ indexLockStaleMs?: number;
113
119
  /**
114
120
  * Hand an explicit index run to the host's background-job registry once the session store holds at
115
121
  * least this many logs (default 1000). Below it the run is synchronous, because a small index is
package/lib/index.js CHANGED
@@ -40,6 +40,7 @@ import { basename, join } from 'node:path';
40
40
  import z from '@deepseek-ai/schemastery';
41
41
  import { loadNative } from 'dsh-plugin-sessiongraph-native';
42
42
  import { INDEX_RELATIVE_PATH, SessiongraphIndexerId } from 'dsh-plugin-sessiongraph-service';
43
+ import { acquireIndexLock } from "./lock.js";
43
44
  import { collectLiveSessions, LIVE_SOURCE_PREFIX } from "./live.js";
44
45
  import { parseSessionLog } from "./parse.js";
45
46
  import { scanSessions } from "./scan.js";
@@ -90,6 +91,7 @@ export const Config = z.object({
90
91
  maxFileBytes: z.number().default(DEFAULT_MAX_FILE_BYTES),
91
92
  autoIndex: z.boolean().default(true),
92
93
  autoBuild: z.boolean().default(true),
94
+ indexLockStaleMs: z.number().default(300_000),
93
95
  backgroundIndexThreshold: z.number().default(DEFAULT_BACKGROUND_INDEX_THRESHOLD),
94
96
  maxLiveSessions: z.number().default(DEFAULT_MAX_LIVE_SESSIONS),
95
97
  engine: z.union([z.const('auto'), z.const('native'), z.const('typescript')]).default(DEFAULT_ENGINE),
@@ -541,6 +543,27 @@ async function runLivePass(ctx, indexPath, config, inScope, provenance) {
541
543
  * @returns what the run did.
542
544
  */
543
545
  export async function runIndex(indexRoot, options, signal, config, ctx) {
546
+ // Serialize every writer of this root — the automatic refresh, the lazy refresh and an explicit
547
+ // `/sessiongraph index` all land here, and two engines opening the same SQLite file is how the
548
+ // index got corrupted before. Waiting for another run to finish is always better than rebuilding.
549
+ const release = await acquireIndexLock(indexRoot, { staleMs: config.indexLockStaleMs });
550
+ try {
551
+ return await runIndexUnlocked(indexRoot, options, signal, config, ctx);
552
+ }
553
+ finally {
554
+ release();
555
+ }
556
+ }
557
+ /**
558
+ * The actual run, with the write lock already held.
559
+ * @param indexRoot - directory holding the index.
560
+ * @param options - caller switches.
561
+ * @param signal - aborts the run.
562
+ * @param config - resolved plugin configuration.
563
+ * @param ctx - plugin context, consulted for the optional live-session service.
564
+ * @returns the run's report.
565
+ */
566
+ async function runIndexUnlocked(indexRoot, options, signal, config, ctx) {
544
567
  const startedAt = Date.now();
545
568
  const indexPath = join(indexRoot, INDEX_RELATIVE_PATH);
546
569
  const workspaceFilter = options.workspace;
package/lib/lock.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * One writer at a time per index root, across processes.
3
+ *
4
+ * Why this exists: the same `index.db` is written by two engines (the TypeScript one through
5
+ * `node:sqlite`, the Rust one through bundled rusqlite) and reached by three triggers (the
6
+ * `agent/created` background job, the lazy refresh before a query, and an explicit
7
+ * `sessiongraph_index`). Nothing in SQLite serializes two writers that open the file
8
+ * independently, and the failure mode is not a clean lock conflict — it is a corpus that later
9
+ * reports `database disk image is malformed` and has to be rebuilt from scratch. A lock file turns
10
+ * that into a short wait.
11
+ *
12
+ * The lock is a file created with `wx` (exclusive create): the winner writes its pid and proceeds,
13
+ * the losers poll until it disappears. A crash leaves the file behind, so a lock older than
14
+ * {@link STALE_LOCK_MS} is broken — the alternative is a permanently unusable index that only
15
+ * deleting a file by hand can fix.
16
+ * @module dsh-plugin-sessiongraph-sessions/lock
17
+ */
18
+ /** File name of the lock inside an index root. */
19
+ export declare const LOCK_FILE_NAME = "index.lock";
20
+ /** How long a lock may sit untouched before another writer treats its owner as dead. */
21
+ export declare const STALE_LOCK_MS: number;
22
+ /** How long a writer waits for the lock before giving up. */
23
+ export declare const LOCK_TIMEOUT_MS: number;
24
+ /** Options for {@link acquireIndexLock}, all overridable so tests do not wait minutes. */
25
+ export interface IndexLockOptions {
26
+ /** Give up after this long. */
27
+ timeoutMs?: number;
28
+ /** Treat a lock older than this as abandoned. */
29
+ staleMs?: number;
30
+ }
31
+ /**
32
+ * Acquire the write lock for one index root.
33
+ *
34
+ * @param indexRoot - directory holding the index the caller is about to write.
35
+ * @param options - timeout and staleness overrides, for tests.
36
+ * @returns a release function; calling it more than once is safe.
37
+ * @throws when the lock cannot be taken before the timeout, so the caller can skip the run instead
38
+ * of writing concurrently.
39
+ */
40
+ export declare function acquireIndexLock(indexRoot: string, options?: IndexLockOptions): Promise<() => void>;
41
+ //# sourceMappingURL=lock.d.ts.map
package/lib/lock.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * One writer at a time per index root, across processes.
3
+ *
4
+ * Why this exists: the same `index.db` is written by two engines (the TypeScript one through
5
+ * `node:sqlite`, the Rust one through bundled rusqlite) and reached by three triggers (the
6
+ * `agent/created` background job, the lazy refresh before a query, and an explicit
7
+ * `sessiongraph_index`). Nothing in SQLite serializes two writers that open the file
8
+ * independently, and the failure mode is not a clean lock conflict — it is a corpus that later
9
+ * reports `database disk image is malformed` and has to be rebuilt from scratch. A lock file turns
10
+ * that into a short wait.
11
+ *
12
+ * The lock is a file created with `wx` (exclusive create): the winner writes its pid and proceeds,
13
+ * the losers poll until it disappears. A crash leaves the file behind, so a lock older than
14
+ * {@link STALE_LOCK_MS} is broken — the alternative is a permanently unusable index that only
15
+ * deleting a file by hand can fix.
16
+ * @module dsh-plugin-sessiongraph-sessions/lock
17
+ */
18
+ import { rmSync } from 'node:fs';
19
+ import { mkdir, open, rm, stat } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+ /** File name of the lock inside an index root. */
22
+ export const LOCK_FILE_NAME = 'index.lock';
23
+ /** How long a lock may sit untouched before another writer treats its owner as dead. */
24
+ export const STALE_LOCK_MS = 5 * 60 * 1000;
25
+ /** How long a writer waits for the lock before giving up. */
26
+ export const LOCK_TIMEOUT_MS = 2 * 60 * 1000;
27
+ /** Polling interval while another writer holds the lock. */
28
+ const POLL_MS = 100;
29
+ /**
30
+ * Acquire the write lock for one index root.
31
+ *
32
+ * @param indexRoot - directory holding the index the caller is about to write.
33
+ * @param options - timeout and staleness overrides, for tests.
34
+ * @returns a release function; calling it more than once is safe.
35
+ * @throws when the lock cannot be taken before the timeout, so the caller can skip the run instead
36
+ * of writing concurrently.
37
+ */
38
+ export async function acquireIndexLock(indexRoot, options = {}) {
39
+ const timeoutMs = options.timeoutMs ?? LOCK_TIMEOUT_MS;
40
+ const staleMs = options.staleMs ?? STALE_LOCK_MS;
41
+ const lockPath = join(indexRoot, LOCK_FILE_NAME);
42
+ // The index root may not exist yet — a first build creates it, and creating the lock is what has
43
+ // to happen first.
44
+ await mkdir(indexRoot, { recursive: true });
45
+ const deadline = Date.now() + timeoutMs;
46
+ for (;;) {
47
+ try {
48
+ const handle = await open(lockPath, 'wx');
49
+ try {
50
+ await handle.writeFile(`${process.pid}\n`, 'utf8');
51
+ }
52
+ finally {
53
+ await handle.close();
54
+ }
55
+ let released = false;
56
+ return () => {
57
+ if (released)
58
+ return;
59
+ released = true;
60
+ // Synchronous on purpose: a disposer returning a promise can be dropped by a `finally` on
61
+ // an async path that is already unwinding, and a leaked lock blocks every later writer
62
+ // until the staleness window reclaims it.
63
+ try {
64
+ rmSync(lockPath, { force: true });
65
+ }
66
+ catch {
67
+ // A lock we cannot remove is bad, but throwing from a release would mask the run's own
68
+ // error. The staleness window is the backstop.
69
+ }
70
+ };
71
+ }
72
+ catch (error) {
73
+ if (error.code !== 'EEXIST')
74
+ throw error;
75
+ await breakStaleLock(lockPath, staleMs);
76
+ if (Date.now() > deadline) {
77
+ throw new Error(`sessiongraph: timed out after ${timeoutMs}ms waiting for the index write lock at "${lockPath}"`);
78
+ }
79
+ await sleep(POLL_MS);
80
+ }
81
+ }
82
+ }
83
+ /**
84
+ * Remove the lock if its owner is presumed dead.
85
+ * @param lockPath - the lock file.
86
+ * @param staleMs - age past which the lock counts as abandoned.
87
+ */
88
+ async function breakStaleLock(lockPath, staleMs) {
89
+ try {
90
+ const info = await stat(lockPath);
91
+ if (Date.now() - info.mtimeMs > staleMs)
92
+ await rm(lockPath, { force: true });
93
+ }
94
+ catch {
95
+ // The holder released it between our failed create and this stat; the next iteration wins.
96
+ }
97
+ }
98
+ /**
99
+ * Wait without keeping the process alive.
100
+ * @param ms - milliseconds to sleep.
101
+ */
102
+ function sleep(ms) {
103
+ return new Promise(resolve => {
104
+ setTimeout(resolve, ms).unref?.();
105
+ });
106
+ }
107
+ //# sourceMappingURL=lock.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-sessiongraph-sessions",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Session-log indexer for DeepSeek Harness — scans ~/.dsh/sessions, decompresses the multi-frame zstd JSONL logs and writes the sessiongraph SQLite index the store reads",
5
5
  "keywords": [
6
6
  "dsh",
@@ -49,8 +49,8 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "@deepseek-ai/schemastery": "^3.18.1",
52
- "dsh-plugin-sessiongraph-service": "^0.1.9",
53
- "dsh-plugin-sessiongraph-native": "^0.1.9"
52
+ "dsh-plugin-sessiongraph-service": "^0.1.11",
53
+ "dsh-plugin-sessiongraph-native": "^0.1.11"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "@deepseek-ai/cordis": ">=4.0.1",