Add memory rollout feature flags and docs

This commit is contained in:
thePR0M3TH3AN committed 2026-02-14 15:23:51 -05:00
1 parent ab680f8b7b
commit d1586228be
10 files changed
+274 -27

No files matched your search

+20
View File
@@ -67,6 +67,10 @@ You can override configuration using environment variables:
- `NOSTR_LOCK_WEEKLY_ROSTER` (comma-separated list of agents)
- `TORCH_CONFIG_PATH` (path to config file)
- `AGENT_PLATFORM` (platform identifier, e.g. `codex`)
- `TORCH_MEMORY_ENABLED` (`true`/`false`; global memory kill switch, defaults to enabled)
- `TORCH_MEMORY_INGEST_ENABLED` (`true`/`false` or comma-separated canary `agent_id` allow list)
- `TORCH_MEMORY_RETRIEVAL_ENABLED` (`true`/`false` or comma-separated canary `agent_id` allow list)
- `TORCH_MEMORY_PRUNE_ENABLED` (`true`, `false`, or `dry-run`)
## Roster precedence
@@ -98,3 +102,19 @@ If you are developing `torch-lock` itself:
## Memory rollout plan
1. Deploy memory schema changes with scheduler jobs disabled (`TORCH_MEMORY_ENABLED=false` or subsystem flags set to `false`).
2. Enable ingest for one canary `agent_id` via `TORCH_MEMORY_INGEST_ENABLED=<agent_id>`.
3. Validate retrieval quality and storage growth metrics before expanding scope.
4. Enable broader retrieval (`TORCH_MEMORY_RETRIEVAL_ENABLED=<allow-list>` then `true`).
5. Enable pruning in `dry-run` mode first, then switch to active pruning after revalidation.
## Memory rollback plan
1. Disable memory flags (`TORCH_MEMORY_ENABLED=false` and/or set ingest/retrieval/prune flags to `false`).
2. Stop memory maintenance scheduler processes.
3. Preserve database state for post-incident analysis; do not drop or rewrite memory tables during rollback.
4. Keep prune actions in `dry-run` (or disabled) until lifecycle policy and data integrity are revalidated.
+20
View File
@@ -36,6 +36,10 @@ node src/nostr-lock.mjs list
- `NOSTR_LOCK_WEEKLY_ROSTER`
- `TORCH_CONFIG_PATH`
- `AGENT_PLATFORM`
- `TORCH_MEMORY_ENABLED` (`true`/`false`; global memory kill switch, defaults to enabled)
- `TORCH_MEMORY_INGEST_ENABLED` (`true`/`false` or comma-separated canary `agent_id` allow list)
- `TORCH_MEMORY_RETRIEVAL_ENABLED` (`true`/`false` or comma-separated canary `agent_id` allow list)
- `TORCH_MEMORY_PRUNE_ENABLED` (`true`, `false`, or `dry-run`)
## torch-config.json
@@ -75,3 +79,19 @@ The lock CLI resolves roster names in this order:
- `1`: usage error
- `2`: relay/network error
- `3`: lock denied (already locked or race lost)
## Memory rollout plan
1. Deploy memory schema changes with scheduler jobs disabled (`TORCH_MEMORY_ENABLED=false` or subsystem flags set to `false`).
2. Enable ingest for one canary `agent_id` via `TORCH_MEMORY_INGEST_ENABLED=<agent_id>`.
3. Validate retrieval quality and storage growth metrics before expanding scope.
4. Enable broader retrieval (`TORCH_MEMORY_RETRIEVAL_ENABLED=<allow-list>` then `true`).
5. Enable pruning in `dry-run` mode first, then switch to active pruning after revalidation.
## Memory rollback plan
1. Disable memory flags (`TORCH_MEMORY_ENABLED=false` and/or set ingest/retrieval/prune flags to `false`).
2. Stop memory maintenance scheduler processes.
3. Preserve database state for post-incident analysis; do not drop or rewrite memory tables during rollback.
4. Keep prune actions in `dry-run` (or disabled) until lifecycle policy and data integrity are revalidated.
+61
View File
@@ -0,0 +1,61 @@
const FALSEY = new Set(['0', 'false', 'off', 'no', 'disabled']);
const TRUTHY = new Set(['1', 'true', 'on', 'yes', 'enabled', 'all']);
function normalize(value) {
return String(value ?? '').trim();
}
function parseAgentToggle(value) {
const raw = normalize(value);
if (!raw) return { kind: 'unset' };
const lower = raw.toLowerCase();
if (FALSEY.has(lower)) return { kind: 'off' };
if (TRUTHY.has(lower)) return { kind: 'all' };
const allowList = raw
.split(',')
.map((item) => item.trim())
.filter(Boolean);
return allowList.length > 0 ? { kind: 'allow-list', allowList } : { kind: 'off' };
}
function isToggleEnabledForAgent(value, agentId, fallbackWhenUnset) {
const parsed = parseAgentToggle(value);
if (parsed.kind === 'unset') return fallbackWhenUnset;
if (parsed.kind === 'off') return false;
if (parsed.kind === 'all') return true;
if (!agentId) return false;
return parsed.allowList.includes(String(agentId));
}
export function isMemoryEnabled(env = process.env) {
return isToggleEnabledForAgent(env.TORCH_MEMORY_ENABLED, undefined, true);
}
export function isMemoryIngestEnabled(agentId, env = process.env) {
if (!isMemoryEnabled(env)) return false;
return isToggleEnabledForAgent(env.TORCH_MEMORY_INGEST_ENABLED, agentId, true);
}
export function isMemoryRetrievalEnabled(agentId, env = process.env) {
if (!isMemoryEnabled(env)) return false;
return isToggleEnabledForAgent(env.TORCH_MEMORY_RETRIEVAL_ENABLED, agentId, false);
}
export function getMemoryPruneMode(env = process.env) {
if (!isMemoryEnabled(env)) return 'off';
const raw = normalize(env.TORCH_MEMORY_PRUNE_ENABLED);
if (!raw) return 'active';
const lower = raw.toLowerCase();
if (FALSEY.has(lower)) return 'off';
if (['dry-run', 'dryrun', 'report', 'audit'].includes(lower)) return 'dry-run';
return 'active';
}
export function isMemoryPruneEnabled(env = process.env) {
return getMemoryPruneMode(env) === 'active';
}
+2 -26
View File
@@ -1,3 +1,5 @@
import { isMemoryRetrievalEnabled } from './feature-flags.js';
export { isMemoryRetrievalEnabled } from './feature-flags.js';
const DEFAULT_TOKEN_BUDGET = 1000;
const DEFAULT_MAX_SUMMARY_CHARS = 240;
const DEFAULT_MAX_CONTEXT_CHARS = 320;
@@ -130,32 +132,6 @@ function toContextString(agentContext) {
return String(agentContext).trim();
}
/**
* Supports safe rollout and canarying with one env var:
* - true/1/on/all => enabled for all agents
* - false/0/off/no/empty => disabled
* - comma-separated agent ids => enabled only for matching ids
*
* @param {string} agentId
* @param {Record<string, string | undefined>} [env]
*/
export function isMemoryRetrievalEnabled(agentId, env = process.env) {
const raw = String(env.TORCH_MEMORY_RETRIEVAL_ENABLED ?? '').trim();
if (!raw) return false;
const normalized = raw.toLowerCase();
if (['0', 'false', 'off', 'no', 'disabled'].includes(normalized)) return false;
if (['1', 'true', 'on', 'yes', 'enabled', 'all'].includes(normalized)) return true;
const allowList = raw
.split(',')
.map((value) => value.trim())
.filter(Boolean);
if (allowList.length === 0) return false;
return allowList.includes(agentId);
}
/**
* Builds a final prompt with retrieved memory context inserted ahead of the base prompt.
*
+22 -1
View File
@@ -3,6 +3,7 @@ import { ingestMemoryWindow } from './ingestor.js';
import { listPruneCandidates, selectPrunableMemories } from './pruner.js';
import { filterAndRankMemories, updateMemoryUsage } from './retriever.js';
import { startScheduler } from './scheduler.js';
import { getMemoryPruneMode, isMemoryIngestEnabled } from './feature-flags.js';
const memoryStore = new Map();
const cache = createMemoryCache();
@@ -133,6 +134,10 @@ function recordThroughput(samples, now) {
export async function ingestEvents(events, options = {}) {
const repository = options.repository ?? memoryRepository;
const telemetry = buildTelemetryEmitter(options);
if (!isMemoryIngestEnabled(options.agent_id, options.env)) {
telemetry('memory:ingest_skipped', { reason: 'flag_disabled', agent_id: options.agent_id ?? null });
return [];
}
const records = await ingestMemoryWindow({ events }, {
...options,
repository,
@@ -201,6 +206,7 @@ export async function runPruneCycle(options = {}) {
const repository = options.repository ?? memoryRepository;
const telemetry = buildTelemetryEmitter(options);
const retentionMs = options.retentionMs ?? (1000 * 60 * 60 * 24 * 30);
const pruneMode = options.pruneMode ?? getMemoryPruneMode(options.env);
const dbCandidates = await listPruneCandidates(repository, {
retentionMs,
now: options.now,
@@ -213,6 +219,21 @@ export async function runPruneCycle(options = {}) {
now: options.now,
});
if (pruneMode === 'off') {
telemetry('memory:prune_skipped', { reason: 'flag_disabled', retention_ms: retentionMs });
return { pruned: [], mode: 'off' };
}
if (pruneMode === 'dry-run') {
telemetry('memory:pruned', {
pruned_count: prunable.length,
retention_ms: retentionMs,
dry_run: true,
});
emitMetric(options, 'memory_pruned_total', { count: 0, retention_ms: retentionMs, dry_run: true });
return { pruned: [], candidates: prunable, mode: 'dry-run' };
}
for (const memory of prunable) {
memoryStore.delete(memory.id);
}
@@ -229,7 +250,7 @@ export async function runPruneCycle(options = {}) {
const result = { pruned: prunable };
if (Number.isFinite(options.scheduleEveryMs) && options.scheduleEveryMs > 0) {
result.scheduler = startScheduler(() => runPruneCycle({ retentionMs, repository }), {
result.scheduler = startScheduler(() => runPruneCycle({ retentionMs, repository, env: options.env, pruneMode: options.pruneMode }), {
intervalMs: options.scheduleEveryMs,
});
}
+51
View File
@@ -1,3 +1,4 @@
import { getMemoryPruneMode, isMemoryEnabled, isMemoryIngestEnabled } from './feature-flags.js';
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
@@ -182,6 +183,21 @@ async function runScheduledJob(options) {
}
}
function shouldRunJob(jobName, options) {
const env = options.env ?? process.env;
if (!isMemoryEnabled(env)) return false;
if (jobName === 'ingestRecentRuntimeEvents') {
return isMemoryIngestEnabled(options.ingestAgentId, env);
}
if (jobName === 'pruningCycle') {
return getMemoryPruneMode(env) !== 'off';
}
return true;
}
/**
* @param {{
* handlers: {
@@ -218,6 +234,17 @@ export function startMemoryMaintenanceScheduler(options) {
const startFixedIntervalJob = (jobName, definition, handler) => {
const run = () => {
if (!shouldRunJob(jobName, options)) {
options.emitMetric?.('memory_scheduler_job', {
job: jobName,
status: 'skipped_flag_disabled',
durationMs: 0,
itemCount: 0,
failures: 0,
retries: 0,
});
return;
}
void runJob(jobName, definition, handler);
};
@@ -232,6 +259,18 @@ export function startMemoryMaintenanceScheduler(options) {
const delayMs = definition.minIntervalMs + Math.floor((definition.maxIntervalMs - definition.minIntervalMs) * jitterRatio);
const timeout = setTimeout(async () => {
timeouts.delete(timeout);
if (!shouldRunJob(jobName, options)) {
options.emitMetric?.('memory_scheduler_job', {
job: jobName,
status: 'skipped_flag_disabled',
durationMs: 0,
itemCount: 0,
failures: 0,
retries: 0,
});
scheduleNext();
return;
}
await runJob(jobName, definition, handler);
scheduleNext();
}, delayMs);
@@ -239,6 +278,18 @@ export function startMemoryMaintenanceScheduler(options) {
};
if (options.runImmediately !== false) {
if (!shouldRunJob(jobName, options)) {
options.emitMetric?.('memory_scheduler_job', {
job: jobName,
status: 'skipped_flag_disabled',
durationMs: 0,
itemCount: 0,
failures: 0,
retries: 0,
});
scheduleNext();
return;
}
void runJob(jobName, definition, handler).then(() => {
scheduleNext();
});
+29
View File
@@ -8,6 +8,7 @@ import {
listMemories,
memoryStats,
pinMemory,
runPruneCycle,
triggerPruneDryRun,
unpinMemory,
} from '../src/services/memory/index.js';
@@ -104,3 +105,31 @@ test('memoryStats reports counts/rates and retrieval telemetry remains redaction
assert.ok(stats.indexSizeEstimateBytes >= 0);
assert.ok(Number.isFinite(stats.ingestThroughputPerMinute));
});
test('runPruneCycle respects prune feature-flag modes', async () => {
const repository = {
async listPruneCandidates() {
return [
{ id: 'old-1', agent_id: 'agent-z', type: 'event', pinned: false, last_seen: 1, merged_into: null },
];
},
};
const dryRunResult = await runPruneCycle({
repository,
env: { TORCH_MEMORY_PRUNE_ENABLED: 'dry-run' },
retentionMs: 1000,
});
assert.equal(dryRunResult.mode, 'dry-run');
assert.equal(dryRunResult.pruned.length, 0);
assert.equal(dryRunResult.candidates.length, 1);
const offResult = await runPruneCycle({
repository,
env: { TORCH_MEMORY_PRUNE_ENABLED: 'false' },
retentionMs: 1000,
});
assert.equal(offResult.mode, 'off');
assert.equal(offResult.pruned.length, 0);
});
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getMemoryPruneMode,
isMemoryEnabled,
isMemoryIngestEnabled,
isMemoryPruneEnabled,
} from '../src/services/memory/feature-flags.js';
test('memory feature flags parse defaults and canary allow-lists', () => {
assert.equal(isMemoryEnabled({}), true);
assert.equal(isMemoryEnabled({ TORCH_MEMORY_ENABLED: 'false' }), false);
assert.equal(isMemoryIngestEnabled('agent-a', {}), true);
assert.equal(isMemoryIngestEnabled('agent-a', { TORCH_MEMORY_INGEST_ENABLED: 'false' }), false);
assert.equal(isMemoryIngestEnabled('agent-a', { TORCH_MEMORY_INGEST_ENABLED: 'agent-a,agent-b' }), true);
assert.equal(isMemoryIngestEnabled('agent-c', { TORCH_MEMORY_INGEST_ENABLED: 'agent-a,agent-b' }), false);
});
test('memory prune flag supports off, dry-run, and active modes', () => {
assert.equal(getMemoryPruneMode({}), 'active');
assert.equal(getMemoryPruneMode({ TORCH_MEMORY_PRUNE_ENABLED: 'false' }), 'off');
assert.equal(getMemoryPruneMode({ TORCH_MEMORY_PRUNE_ENABLED: 'dry-run' }), 'dry-run');
assert.equal(getMemoryPruneMode({ TORCH_MEMORY_PRUNE_ENABLED: 'true' }), 'active');
assert.equal(isMemoryPruneEnabled({ TORCH_MEMORY_PRUNE_ENABLED: 'dry-run' }), false);
assert.equal(isMemoryPruneEnabled({ TORCH_MEMORY_PRUNE_ENABLED: 'true' }), true);
});
+7
View File
@@ -143,6 +143,13 @@ test('isMemoryRetrievalEnabled supports global toggle and agent canary list', ()
isMemoryRetrievalEnabled('agent-c', { TORCH_MEMORY_RETRIEVAL_ENABLED: 'agent-a,agent-b' }),
false,
);
assert.equal(
isMemoryRetrievalEnabled('agent-a', {
TORCH_MEMORY_ENABLED: 'false',
TORCH_MEMORY_RETRIEVAL_ENABLED: 'true',
}),
false,
);
});
test('renderPromptWithMemoryContext injects bounded memory block before base prompt', async () => {
+34
View File
@@ -90,3 +90,37 @@ test('startMemoryMaintenanceScheduler runs all jobs and emits metrics including
assert.equal(byJob.pruningCycle[0].payload.status, 'success');
assert.equal(byJob.deepMergeArchivalMaintenance[0].payload.status, 'success');
});
test('startMemoryMaintenanceScheduler skips jobs when feature flags disable memory', async () => {
const metrics = [];
const called = [];
const scheduler = startMemoryMaintenanceScheduler({
runImmediately: true,
env: { TORCH_MEMORY_ENABLED: 'false' },
emitMetric(name, payload) {
metrics.push({ name, payload });
},
handlers: {
async ingestRecentRuntimeEvents() {
called.push('ingest');
},
async consolidateObservations() {
called.push('consolidate');
},
async pruningCycle() {
called.push('prune');
},
async deepMergeArchivalMaintenance() {
called.push('merge');
},
},
});
await wait(10);
scheduler.stop();
assert.deepEqual(called, []);
assert.ok(metrics.some((entry) => entry.payload.status === 'skipped_flag_disabled'));
});