@nanhara/hara 0.112.4 → 0.112.5
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/dist/index.js +11 -1
- package/dist/session/store.js +49 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -65,7 +65,7 @@ import { getEmbedder } from "./search/embed.js";
|
|
|
65
65
|
import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
|
|
66
66
|
import { searchHybrid } from "./search/hybrid.js";
|
|
67
67
|
import { expandMentions, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
|
|
68
|
-
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
|
|
68
|
+
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
|
|
69
69
|
import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
|
|
70
70
|
import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
71
71
|
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
@@ -2392,6 +2392,16 @@ program.action(async (opts) => {
|
|
|
2392
2392
|
createdAt: new Date().toISOString(),
|
|
2393
2393
|
updatedAt: "",
|
|
2394
2394
|
};
|
|
2395
|
+
// Single-writer guard: two hara processes on the SAME session race writes to its append-only history and
|
|
2396
|
+
// corrupt it. Lock it here (a brand-new session's id is unique, so this only ever blocks a DOUBLE-resume
|
|
2397
|
+
// of a session already open elsewhere). Released on exit.
|
|
2398
|
+
const lock = acquireSessionLock(meta.id);
|
|
2399
|
+
if (!lock.ok) {
|
|
2400
|
+
out(c.red(`Session ${shortId(meta.id)} is already open in another hara process (pid ${lock.pid}).`) +
|
|
2401
|
+
c.dim(` Resuming the same session twice races writes and can corrupt its history. Close that one, or run \`hara\` for a new session. (Override: rm ~/.hara/sessions/${meta.id}.lock)\n`));
|
|
2402
|
+
process.exit(1);
|
|
2403
|
+
}
|
|
2404
|
+
process.on("exit", () => releaseSessionLock(meta.id));
|
|
2395
2405
|
// Per-session model precedence on resume:
|
|
2396
2406
|
// 1. --model flag (already applied to cfg.model up-top) → wins and is written back to meta.model.
|
|
2397
2407
|
// 2. resumed meta.model → restored into cfg.model (the user's last /model choice).
|
package/dist/session/store.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Session persistence — conversations saved as JSON under ~/.hara/sessions, resumable.
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
function sessionsDir() {
|
|
7
7
|
const d = join(homedir(), ".hara", "sessions");
|
|
@@ -9,6 +9,54 @@ function sessionsDir() {
|
|
|
9
9
|
return d;
|
|
10
10
|
}
|
|
11
11
|
const sessionFile = (id) => join(sessionsDir(), `${id}.json`);
|
|
12
|
+
const lockFile = (id) => join(sessionsDir(), `${id}.lock`);
|
|
13
|
+
/** Is a process with this pid alive? `process.kill(pid, 0)` sends no signal — it just probes: throws
|
|
14
|
+
* ESRCH if dead, EPERM if alive-but-not-ours (still alive). Best-effort across platforms. */
|
|
15
|
+
function pidAlive(pid) {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(pid, 0);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
return e?.code === "EPERM";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Take an exclusive lock on a session so two hara processes can't resume the SAME session and corrupt
|
|
25
|
+
* its append-only history by racing writes. Returns `{ ok: true }` if acquired (or the previous holder
|
|
26
|
+
* is dead → we take over), or `{ ok: false, pid }` if a LIVE process already holds it. Best-effort: any
|
|
27
|
+
* filesystem hiccup resolves to `ok:true` so a lock problem never blocks the user. Pair with
|
|
28
|
+
* `releaseSessionLock` on exit. */
|
|
29
|
+
export function acquireSessionLock(id) {
|
|
30
|
+
const f = lockFile(id);
|
|
31
|
+
try {
|
|
32
|
+
if (existsSync(f)) {
|
|
33
|
+
const held = JSON.parse(readFileSync(f, "utf8"));
|
|
34
|
+
if (typeof held.pid === "number" && held.pid !== process.pid && pidAlive(held.pid)) {
|
|
35
|
+
return { ok: false, pid: held.pid };
|
|
36
|
+
}
|
|
37
|
+
// stale (dead pid) or already ours → fall through and (re)claim it
|
|
38
|
+
}
|
|
39
|
+
writeFileSync(f, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
|
|
40
|
+
return { ok: true };
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { ok: true };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Release a session lock we hold (only removes it if the pid matches ours — never steals another's). */
|
|
47
|
+
export function releaseSessionLock(id) {
|
|
48
|
+
try {
|
|
49
|
+
const f = lockFile(id);
|
|
50
|
+
if (!existsSync(f))
|
|
51
|
+
return;
|
|
52
|
+
const held = JSON.parse(readFileSync(f, "utf8"));
|
|
53
|
+
if (held?.pid === process.pid)
|
|
54
|
+
rmSync(f);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* best-effort */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
12
60
|
/** A full UUID per session (the stable identity). */
|
|
13
61
|
export const newSessionId = () => randomUUID();
|
|
14
62
|
/** First segment of the UUID — a compact label for the status bar / `/sessions`. */
|