Files
Rohit Ghumare 6cc9b9f0fe fix: env hydration, indexing, consolidation lifecycle, connector activation, hardening (#1136)
* fix: env hydration, indexing, consolidation, connectors, hardening

- config: hydrate ~/.agentmemory/.env into process.env at boot so all modules see it
- search: shared indexRecords() so export-import and replay populate BM25 and vector (#1072)
- snapshot: wire the periodic timer (#1006), clamp non-positive intervals, add a reentrancy guard
- schema: CJK-aware jaccard dedup plus exact-match fallback for short memories
- embeddings: shared resolveDimensions() so openrouter stops hardcoding 1536 (#1002)
- viewer: buffer request bodies before decoding to fix multibyte corruption (#930)
- providers: retry 429/503 with Retry-After under a total-elapsed budget cap
- consolidation: fire on session stop (#1087), gate keyless installs, debounce the per-turn stop hook, drop the client-side double-fire
- evict: bound stale-session recovery to one consolidation pass
- api/patterns: bound session fan-out (#1100)
- connect: write a memory-usage guideline into each hook-less agent's native rules file (12 agents, doc-verified paths, --no-guidelines opt-out)
- graph: import graphify's graph.json via mem::graph::import-graphify + POST /agentmemory/graph/import-graphify; shared persistGraphDelta with endpoint remap so merged nodes never leave dangling or duplicate edges
- fs-watcher: stat roots before fs.watch so missing roots fail deterministically on Node 24+
- test: regression tests for every fix

* fix: address review findings on import, debounce, and connect paths

- guidelines: refuse to touch files with a lone or reversed marker pair
- export-import/replay: indexing after committed writes is best-effort,
  logged instead of failing the import; flatten the nested runChunked so
  replace-mode deletes stay bounded to one chunk
- graph: persist the snapshot when merge-only batches mutate cached
  topNodes/topEdges entries
- graph-import: async fs, typeof validation on path/cwd; REST handler
  whitelists the payload and 400s non-string values
- fetch: cancel discarded response bodies before retrying
- events: serialize the consolidation cooldown check so concurrent stops
  cannot both pass the read-check-write window
- evict: gate recovered-session consolidation on isConsolidationEnabled
  and mirror the stop path's force flag
- search: rebuild indexes per session chunk to bound peak memory
- test: regression coverage for each (malformed markers, concurrent
  stops, snapshot persistence, AMBIGUOUS/default mappings, env isolation)
2026-08-02 11:16:30 +01:00

97 lines
3.7 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadSnapshotConfig, __resetEnvFileCache } from "../src/config.js";
// loadSnapshotConfig reads getMergedEnv(), which merges the on-disk
// ~/.agentmemory/.env under process.env. The tests below delete the
// process.env keys, so a developer machine with SNAPSHOT_* in its real .env
// would leak into the defaults; point HOME at an empty temp dir and reset
// the env-file cache so the file layer is deterministic.
//
// Regression (P1): a zero/negative SNAPSHOT_INTERVAL flowed straight into
// setInterval(fn, interval * 1000). Node clamps a non-positive delay to ~1ms,
// so the git-snapshot timer would fire on nearly every event-loop tick,
// saturating the worker with back-to-back full-state snapshots + commits.
// Non-positive values must fall back to the documented 3600s default.
const KEYS = ["SNAPSHOT_INTERVAL", "SNAPSHOT_ENABLED", "SNAPSHOT_DIR"] as const;
const DEFAULT_INTERVAL = 3600;
describe("loadSnapshotConfig interval validation", () => {
const saved: Record<string, string | undefined> = {};
let sandboxHome: string;
let savedHome: string | undefined;
let savedUserProfile: string | undefined;
beforeEach(() => {
sandboxHome = mkdtempSync(join(tmpdir(), "am-snapcfg-"));
savedHome = process.env["HOME"];
savedUserProfile = process.env["USERPROFILE"];
process.env["HOME"] = sandboxHome;
process.env["USERPROFILE"] = sandboxHome;
__resetEnvFileCache();
for (const k of KEYS) {
saved[k] = process.env[k];
delete process.env[k];
}
});
afterEach(() => {
for (const k of KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
if (savedHome === undefined) delete process.env["HOME"];
else process.env["HOME"] = savedHome;
if (savedUserProfile === undefined) delete process.env["USERPROFILE"];
else process.env["USERPROFILE"] = savedUserProfile;
__resetEnvFileCache();
rmSync(sandboxHome, { recursive: true, force: true });
});
it("falls back to the default when interval is zero", () => {
process.env["SNAPSHOT_INTERVAL"] = "0";
expect(loadSnapshotConfig().interval).toBe(DEFAULT_INTERVAL);
});
it("falls back to the default when interval is negative", () => {
process.env["SNAPSHOT_INTERVAL"] = "-5";
expect(loadSnapshotConfig().interval).toBe(DEFAULT_INTERVAL);
});
it("falls back to the default when interval is non-numeric", () => {
process.env["SNAPSHOT_INTERVAL"] = "not-a-number";
expect(loadSnapshotConfig().interval).toBe(DEFAULT_INTERVAL);
});
it("uses the default when interval is unset", () => {
expect(loadSnapshotConfig().interval).toBe(DEFAULT_INTERVAL);
});
it("accepts a valid positive interval unchanged", () => {
process.env["SNAPSHOT_INTERVAL"] = "120";
expect(loadSnapshotConfig().interval).toBe(120);
});
it("accepts the minimum floor of 1 second", () => {
process.env["SNAPSHOT_INTERVAL"] = "1";
expect(loadSnapshotConfig().interval).toBe(1);
});
it("never yields an interval that would clamp setInterval to sub-second", () => {
for (const bad of ["0", "-1", "-3600", "0.5"]) {
process.env["SNAPSHOT_INTERVAL"] = bad;
expect(loadSnapshotConfig().interval).toBeGreaterThanOrEqual(1);
}
});
it("reports enabled state from SNAPSHOT_ENABLED", () => {
process.env["SNAPSHOT_ENABLED"] = "true";
expect(loadSnapshotConfig().enabled).toBe(true);
process.env["SNAPSHOT_ENABLED"] = "false";
expect(loadSnapshotConfig().enabled).toBe(false);
});
});