mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
Add memory rollout feature flags and docs
This commit is contained in:
1 parent
ab680f8b7b
commit
d1586228be
10 files changed
+274
-27
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
Reference in new issue
Block a user