-
Notifications
You must be signed in to change notification settings - Fork 2.4k
fix(consolidation): dedupe double-fired consolidation LLM runs + crash-safe audit #1319
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,19 +5,37 @@ import type { | |
| SessionSummary, | ||
| Memory, | ||
| MemoryProvider, | ||
| AuditEntry, | ||
| } from "../types.js"; | ||
| import { KV, generateId } from "../state/schema.js"; | ||
| import { KV, fingerprintId, generateId } from "../state/schema.js"; | ||
| import type { StateKV } from "../state/kv.js"; | ||
| import { | ||
| SEMANTIC_MERGE_SYSTEM, | ||
| buildSemanticMergePrompt, | ||
| PROCEDURAL_EXTRACTION_SYSTEM, | ||
| buildProceduralExtractionPrompt, | ||
| } from "../prompts/consolidation.js"; | ||
| import { recordAudit } from "./audit.js"; | ||
| import { withKeyedLock } from "../state/keyed-mutex.js"; | ||
| import { getConsolidationDecayDays, isConsolidationEnabled } from "../config.js"; | ||
| import { logger } from "../logger.js"; | ||
|
|
||
| // Corpus-level dedup guard for the semantic merge tier. Stored in KV.config | ||
| // so the guard survives worker restarts and is shared across processes. | ||
| // | ||
| // Semantics: the fingerprint guard is UNCONDITIONAL — it applies to every | ||
| // invocation, including force:true. `force` only bypasses the | ||
| // isConsolidationEnabled() gate (policy bypass); it does not mean "re-run | ||
| // the LLM on an unchanged corpus". The automated callers (session-stop | ||
| // fan-out, eviction recovery) already check isConsolidationEnabled() before | ||
| // firing, so their force:true is redundant; it must never bypass dedup or | ||
| // the 340ms double-fire returns. | ||
| // | ||
| // Fingerprint window: the hash covers only the 20 most recent summaries | ||
| // and only {title, narrative, concepts}. Changes beyond the window (or to | ||
| // other summary fields) do not invalidate it — fine for a recency-ordered, | ||
| // append-only corpus, but the guard is NOT a full-corpus change detector. | ||
| const CORPUS_FINGERPRINT_KEY = "consolidation:corpusFingerprint"; | ||
|
|
||
| function applyDecay( | ||
| items: Array<{ | ||
| strength: number; | ||
|
|
@@ -49,13 +67,48 @@ export function registerConsolidationPipelineFunction( | |
| ): void { | ||
| sdk.registerFunction("mem::consolidate-pipeline", | ||
| async (data?: { tier?: string; force?: boolean; project?: string }) => { | ||
| // Serialize pipeline invocations in-process so concurrent triggers | ||
| // (session-stop fan-out, 2h timer, REST trigger, eviction recovery) | ||
| // cannot interleave two full-corpus passes on the same corpus. | ||
| // | ||
| // Cross-process: the CLI enforces one worker per engine (main() probes | ||
| // /agentmemory/livez and refuses to boot a second instance, cli.ts), | ||
| // so the in-process lock plus the KV fingerprint reserve are | ||
| // sufficient — no distributed lease needed. Topology notes: | ||
| // - --instance N is a port shortcut (own REST/engine port quartet), | ||
| // so it runs its OWN engine+worker, not a second worker on the | ||
| // shared engine; when instances share a data directory the KV (and | ||
| // the fingerprint reserve) is shared, so the reserve is the only | ||
| // cross-process guard, with a sub-ms read-reserve TOCTOU window | ||
| // that is accepted. | ||
| // - A worker attached to an existing engine via an explicit | ||
| // III_ENGINE_URL/III_ENGINE_PORT override has the same property: | ||
| // the fingerprint reserve is the sole cross-process guard. | ||
| return withKeyedLock("consolidation:global", async () => { | ||
| if (!data?.force && !isConsolidationEnabled()) { | ||
| return { success: false, skipped: true, reason: "Consolidation disabled: set CONSOLIDATION_ENABLED=true or configure an LLM provider (ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY / GEMINI_API_KEY / GOOGLE_API_KEY / MINIMAX_API_KEY / OPENAI_BASE_URL / AGENTMEMORY_PROVIDER=agent-sdk)" }; | ||
| } | ||
| const tier = data?.tier || "all"; | ||
| const decayDays = getConsolidationDecayDays(); | ||
| const results: Record<string, unknown> = {}; | ||
|
|
||
| // Crash-safe audit: write the row BEFORE any LLM/state work so a kill | ||
| // mid-pipeline (e.g. between semantic writes and completion — observed | ||
| // 2026-09-01, semantic facts persisted with no audit row) still leaves | ||
| // an audit trail. The row is updated in place at the end with results; | ||
| // the stable aud_ id correlates started→completed and its timestamp is | ||
| // the pipeline start time. | ||
| const auditId = generateId("aud"); | ||
| const auditEntry: AuditEntry = { | ||
| id: auditId, | ||
| timestamp: new Date().toISOString(), | ||
| operation: "consolidate", | ||
| functionId: "mem::consolidate-pipeline", | ||
| targetIds: [], | ||
| details: { tier, project: data?.project, status: "started" }, | ||
| }; | ||
| await kv.set(KV.audit, auditId, auditEntry); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift Route the two-phase audit lifecycle through The function directly writes audit state with As per coding guidelines, Also applies to: 339-342 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| if (tier === "all" || tier === "semantic") { | ||
| const summaries = await kv.list<SessionSummary>(KV.summaries); | ||
| const existingSemantic = await kv.list<SemanticMemory>(KV.semantic); | ||
|
|
@@ -69,61 +122,86 @@ export function registerConsolidationPipelineFunction( | |
| ) | ||
| .slice(0, 20); | ||
|
|
||
| const prompt = buildSemanticMergePrompt( | ||
| recentSummaries.map((s) => ({ | ||
| title: s.title, | ||
| narrative: s.narrative, | ||
| concepts: s.concepts, | ||
| })), | ||
| const corpusItems = recentSummaries.map((s) => ({ | ||
| title: s.title, | ||
| narrative: s.narrative, | ||
| concepts: s.concepts, | ||
| })); | ||
| const prompt = buildSemanticMergePrompt(corpusItems); | ||
| const corpusFingerprint = fingerprintId( | ||
| "consolidation", | ||
| JSON.stringify(corpusItems), | ||
| ); | ||
|
|
||
| try { | ||
| const response = await provider.summarize( | ||
| SEMANTIC_MERGE_SYSTEM, | ||
| prompt, | ||
| ); | ||
| const lastConsolidation = await kv | ||
| .get<{ fingerprint?: string }>(KV.config, CORPUS_FINGERPRINT_KEY) | ||
| .catch(() => null); | ||
| if (lastConsolidation?.fingerprint === corpusFingerprint) { | ||
| results.semantic = { | ||
| skipped: true, | ||
| reason: "corpus unchanged since last consolidation", | ||
| }; | ||
| } else { | ||
| try { | ||
| // Reserve the fingerprint before the LLM call so a concurrent | ||
| // identical invocation (session-stop fan-out, 2h timer, REST | ||
| // trigger) observes the reservation and skips. iii-sdk offers | ||
| // no CAS primitive, so this is the strongest cross-process | ||
| // guard available; the reservation is released on failure so | ||
| // a failed consolidation can be retried. | ||
| await kv | ||
| .set(KV.config, CORPUS_FINGERPRINT_KEY, { | ||
| fingerprint: corpusFingerprint, | ||
| }) | ||
| .catch(() => {}); | ||
|
Comment on lines
+152
to
+156
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Make fingerprint reservation acquisition fail closed and owner-scoped across processes. The current read/write sequence allows two processes to observe the same fingerprint before either persists the new reservation, so both can invoke consolidation. A failed reservation write is also ignored, allowing the LLM call to proceed without a durable reservation. Cleanup can additionally delete a newer run's reservation. Use an atomic compare-and-set or lease with a run token and expiry, release only when the token matches, and propagate reservation-write failures so processing cannot start without a durable reservation. 📍 Affects 1 file
🤖 Prompt for AI Agents |
||
| const response = await provider.summarize( | ||
| SEMANTIC_MERGE_SYSTEM, | ||
| prompt, | ||
| ); | ||
|
|
||
| const factRegex = /<fact\s+confidence="([^"]+)">([^<]+)<\/fact>/g; | ||
| let match; | ||
| let newFacts = 0; | ||
| const now = new Date().toISOString(); | ||
| const factRegex = /<fact\s+confidence="([^"]+)">([^<]+)<\/fact>/g; | ||
| let match; | ||
| let newFacts = 0; | ||
| const now = new Date().toISOString(); | ||
|
|
||
| while ((match = factRegex.exec(response)) !== null) { | ||
| const parsedConf = parseFloat(match[1]); | ||
| const confidence = Number.isNaN(parsedConf) ? 0.5 : parsedConf; | ||
| const fact = match[2].trim(); | ||
| while ((match = factRegex.exec(response)) !== null) { | ||
| const parsedConf = parseFloat(match[1]); | ||
| const confidence = Number.isNaN(parsedConf) ? 0.5 : parsedConf; | ||
| const fact = match[2].trim(); | ||
|
|
||
| const existing = existingSemantic.find( | ||
| (s) => s.fact.toLowerCase() === fact.toLowerCase(), | ||
| ); | ||
| if (existing) { | ||
| existing.accessCount++; | ||
| existing.lastAccessedAt = now; | ||
| existing.updatedAt = now; | ||
| existing.confidence = Math.max(existing.confidence, confidence); | ||
| await kv.set(KV.semantic, existing.id, existing); | ||
| } else { | ||
| const sem: SemanticMemory = { | ||
| id: generateId("sem"), | ||
| fact, | ||
| confidence, | ||
| sourceSessionIds: recentSummaries.map((s) => s.sessionId), | ||
| sourceMemoryIds: [], | ||
| accessCount: 1, | ||
| lastAccessedAt: now, | ||
| strength: confidence, | ||
| createdAt: now, | ||
| updatedAt: now, | ||
| }; | ||
| await kv.set(KV.semantic, sem.id, sem); | ||
| newFacts++; | ||
| const existing = existingSemantic.find( | ||
| (s) => s.fact.toLowerCase() === fact.toLowerCase(), | ||
| ); | ||
| if (existing) { | ||
| existing.accessCount++; | ||
| existing.lastAccessedAt = now; | ||
| existing.updatedAt = now; | ||
| existing.confidence = Math.max(existing.confidence, confidence); | ||
| await kv.set(KV.semantic, existing.id, existing); | ||
| } else { | ||
| const sem: SemanticMemory = { | ||
| id: generateId("sem"), | ||
| fact, | ||
| confidence, | ||
| sourceSessionIds: recentSummaries.map((s) => s.sessionId), | ||
| sourceMemoryIds: [], | ||
| accessCount: 1, | ||
| lastAccessedAt: now, | ||
| strength: confidence, | ||
| createdAt: now, | ||
| updatedAt: now, | ||
| }; | ||
| await kv.set(KV.semantic, sem.id, sem); | ||
| newFacts++; | ||
| } | ||
| } | ||
| results.semantic = { newFacts, totalSummaries: summaries.length }; | ||
| } catch (err) { | ||
| const msg = err instanceof Error ? err.message : String(err); | ||
| logger.error("Semantic consolidation failed", { error: msg }); | ||
| results.semantic = { error: msg }; | ||
| await kv.delete(KV.config, CORPUS_FINGERPRINT_KEY).catch(() => {}); | ||
| } | ||
| results.semantic = { newFacts, totalSummaries: summaries.length }; | ||
| } catch (err) { | ||
| const msg = err instanceof Error ? err.message : String(err); | ||
| logger.error("Semantic consolidation failed", { error: msg }); | ||
| results.semantic = { error: msg }; | ||
| } | ||
| } else { | ||
| results.semantic = { | ||
|
|
@@ -258,13 +336,14 @@ export function registerConsolidationPipelineFunction( | |
| } | ||
| } | ||
|
|
||
| await recordAudit(kv, "consolidate", "mem::consolidate-pipeline", [], { | ||
| tier, | ||
| results, | ||
| await kv.set(KV.audit, auditId, { | ||
| ...auditEntry, | ||
| details: { ...auditEntry.details, status: "completed", results }, | ||
| }); | ||
|
|
||
| logger.info("Consolidation pipeline complete", { tier, results }); | ||
| return { success: true, results }; | ||
| }); | ||
| }, | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Remove implementation-explanation comments from these source files.
Use clear names and focused abstractions for behavior. Keep only comments that document information code cannot express.
src/functions/consolidation-pipeline.ts#L22-L36: remove the corpus fingerprint behavior narrative.src/functions/consolidation-pipeline.ts#L70-L86: remove the lock and deployment topology narrative.src/functions/consolidation-pipeline.ts#L95-L100: remove the audit lifecycle narrative.src/functions/consolidation-pipeline.ts#L146-L151: remove the reservation behavior narrative.src/functions/audit.ts#L31-L36: remove the two-phase audit implementation narrative.As per coding guidelines,
src/**/*.ts: “Do not add comments that explain what code does; use clear naming instead.”📍 Affects 2 files
src/functions/consolidation-pipeline.ts#L22-L36(this comment)src/functions/consolidation-pipeline.ts#L70-L86src/functions/consolidation-pipeline.ts#L95-L100src/functions/consolidation-pipeline.ts#L146-L151src/functions/audit.ts#L31-L36🤖 Prompt for AI Agents
Source: Coding guidelines