fix: eliminate saveData race condition with sessionId

Root cause: appendMessage() fired fire-and-forget saveMessages() on
every message. In handleSend(), the user message save raced against
the later sessionId save — whichever completed last won. Since the
user message save fired first (before the API response), if it
completed last it overwrote chatSessionId back to empty.

Fixes:
- Removed per-message saveMessages from appendMessage(). Persistence
  now happens in two places only: handleSend() after full exchange,
  and onClose() on shutdown.
- saveSessionId no longer calls saveData — it stamps the value on
  settings and lets the subsequent saveMessages() persist both
  fields in a single atomic write.
- saveSessionId is awaited before saveMessages so ordering is
  guaranteed: sessionId is on settings when saveData fires.
- Added request logging: [Hermes API] shows URL, model, message
  count, and session ID for every API call.
- Added restore logging: [Hermes] shows whether session was restored
  or started fresh.

The Marco/Bolo test should now work — sessionId survives restart.
This commit is contained in:
Helm 2026-07-10 13:56:24 -04:00
parent 7b857803a7
commit 16f4bb589a
4 changed files with 30 additions and 13 deletions

10
main.js

File diff suppressed because one or more lines are too long

View file

@ -134,6 +134,11 @@ export class ApiClient {
headers["X-Hermes-Session-Id"] = this.currentSessionId; headers["X-Hermes-Session-Id"] = this.currentSessionId;
} }
console.log("[Hermes API] POST", `${this.baseUrl}/chat/completions`,
"| model:", this.model,
"| msgs:", messages.length,
"| session:", this.currentSessionId ? this.currentSessionId.slice(0, 8) + "..." : "(none)");
const response = await this.fetchWithTimeout( const response = await this.fetchWithTimeout(
`${this.baseUrl}/chat/completions`, `${this.baseUrl}/chat/completions`,
{ {
@ -193,6 +198,11 @@ export class ApiClient {
headers["X-Hermes-Session-Id"] = this.currentSessionId; headers["X-Hermes-Session-Id"] = this.currentSessionId;
} }
console.log("[Hermes API] POST", `${this.baseUrl}/chat/completions`,
"| model:", this.model,
"| msgs:", messages.length,
"| session:", this.currentSessionId ? this.currentSessionId.slice(0, 8) + "..." : "(none)");
const response = await this.fetchWithTimeout( const response = await this.fetchWithTimeout(
`${this.baseUrl}/chat/completions`, `${this.baseUrl}/chat/completions`,
{ {
@ -215,6 +225,7 @@ export class ApiClient {
const sessionId = response.headers.get("X-Hermes-Session-Id"); const sessionId = response.headers.get("X-Hermes-Session-Id");
if (sessionId) { if (sessionId) {
this.currentSessionId = sessionId; this.currentSessionId = sessionId;
console.log("[Hermes API] New session:", sessionId.slice(0, 8) + "...");
} }
// Read the SSE stream // Read the SSE stream

View file

@ -259,11 +259,8 @@ export class ChatView extends ItemView {
timestamp: Date.now(), timestamp: Date.now(),
}; };
this.chatState.messages.push(msg); this.chatState.messages.push(msg);
// Fire-and-forget save — don't block rendering. // Persistence is handled by handleSend() (after full exchange) and onClose().
// Suppressed during restore (too many racing saveData writes). // Per-message saves created race conditions with sessionId persistence.
if (!this._restoring) {
this.persistence?.saveMessages(this.chatState.messages);
}
const wrapper = this.messagesContainer.createDiv("chat-message"); const wrapper = this.messagesContainer.createDiv("chat-message");
wrapper.addClass(`chat-message-${role}`); wrapper.addClass(`chat-message-${role}`);
@ -544,13 +541,17 @@ export class ChatView extends ItemView {
timestamp: Date.now(), timestamp: Date.now(),
}; };
this.chatState.messages.push(assistantMsg); this.chatState.messages.push(assistantMsg);
this.persistence?.saveMessages(this.chatState.messages);
if (response.sessionId) { if (response.sessionId) {
this.chatState.sessionId = response.sessionId; this.chatState.sessionId = response.sessionId;
this.updateStatus(response.model, response.sessionId); this.updateStatus(response.model, response.sessionId);
this.persistence?.saveSessionId(response.sessionId); // Set on settings object before saveMessages so both are persisted
// in a single saveData() call — no racing writes.
await this.persistence?.saveSessionId(response.sessionId);
} }
// Single persistence call: messages + sessionId (if set above) in one write
await this.persistence?.saveMessages(this.chatState.messages);
} catch (error: any) { } catch (error: any) {
if (contentDiv) { if (contentDiv) {
contentDiv.setText(`Error: ${error.message || "Unknown error"}`); contentDiv.setText(`Error: ${error.message || "Unknown error"}`);

View file

@ -28,7 +28,9 @@ export default class HermesChatPlugin extends Plugin {
loadSessionId: () => this.settings.chatSessionId ?? "", loadSessionId: () => this.settings.chatSessionId ?? "",
saveSessionId: async (id) => { saveSessionId: async (id) => {
this.settings.chatSessionId = id; this.settings.chatSessionId = id;
await this.saveData(this.settings); // Don't call saveData here — saveMessages handles the write.
// Both chatMessages and chatSessionId live on this.settings,
// so a single saveData() call persists them atomically.
}, },
}; };
const view = new ChatView(leaf, client, this.contextBridge!, persistence); const view = new ChatView(leaf, client, this.contextBridge!, persistence);
@ -39,6 +41,9 @@ export default class HermesChatPlugin extends Plugin {
if (this.settings.chatSessionId) { if (this.settings.chatSessionId) {
client.setSessionId(this.settings.chatSessionId); client.setSessionId(this.settings.chatSessionId);
view.updateStatus("hermes-agent", this.settings.chatSessionId); view.updateStatus("hermes-agent", this.settings.chatSessionId);
console.log("[Hermes] Restored session:", this.settings.chatSessionId.slice(0, 8) + "...");
} else {
console.log("[Hermes] No saved session — starting fresh");
} }
return view; return view;
} }