mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* fix(minions): honest attempt accounting + cooperative abort-honoring + per-handler timeouts (#1737) - Wall-clock and stall dead-letter paths now increment attempts_made (terminal, no retry — wall-clock fires at 2x cumulative timeout; retrying non-idempotent embed/subagent work would duplicate side effects). Surface stalled_counter in jobs get so 'started 3 / stalled 2 / attempts 0' reads true instead of looking like broken accounting. - Thread AbortSignal through embed-backfill/autopilot-cycle -> runPhaseEmbed -> runEmbedCore -> embedAll(Stale)/embedPage, checking it on BOTH --stale and --all paths and between embed batches. A timed-out embed phase now bails within a batch, so the cycle finally releases gbrain_cycle_locks instead of running the full 10-15 min after the job was killed (the daily cycle-wedge). New shared src/core/abort-check.ts (isAborted/throwIfAborted/anySignal). - Per-handler default wall-clock budget (handler-timeouts.ts) stamped at submit for long handlers without an explicit timeout_ms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(minions): queue-scoped DB supervisor singleton + canonical pidfile + doctor max-rss check (#1849) - Acquire a queue-scoped DB lock (tryAcquireDbLock, keyed on the raw DB identity + queue) on supervisor.start(): a second supervisor on the same (db, queue) fails fast with exit 2 regardless of $HOME/--pid-file. Refresh on a dedicated timer; on refresh failure past the threshold, fail SAFE (exit non-zero) before the TTL could lapse and let a second supervisor take over. Release on shutdown. - Canonical default pidfile keyed on brain id (currentBrainId, config-only, no DB connect) so two brains under one HOME no longer share supervisor.pid. - doctor: new supervisor_singleton check surfaces the effective --max-rss (from the started audit event) and warns when the lock holder's (host,pid) differs from the local pidfile — comparing host+pid, not bare pid. Registered in doctor-categories. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(agent-voice): topic-aware persona context via server-resolved topicId (#1851) Summon Mars/Venus into a specific conversation topic so they boot already knowing the recent thread. A per-topic call link carries only topicId (+ optional display topicName); the server resolves the recent-conversation context from the brain (topics/<topicId>.md) — topic CONTENT is never accepted over the wire (that would be prompt injection + a leak into URLs/referrers/logs). topicId is a strict slug with a path-traversal guard. New '# Topic Context' prompt slot injected after the persona body so identity-first ordering still wins; persona identity unchanged. No topicId -> generic behavior. Contract doc + persona skill docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: file #1737 slot-reservation fair-scheduling follow-up TODO (F7) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.42.29.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: sync KEY_FILES.md for v0.42.29.0 minions + abort wave (#1737, #1849) Fold the #1737/#1849 behavior into the existing per-file entries and add the two new core files, keeping the doc at current-state truth: - queue.ts: honest attempt accounting on wall-clock + stall dead-letter paths; defaultTimeoutMsFor stamping at submit. - supervisor.ts: queue-scoped DB singleton lock (supervisorLockId, classifySupervisorSingleton, LOCK_LOST, refresh-fail-safe, brain-id pidfile, max_rss_mb audit). - worker-registry.ts: currentDbIdentity(). - New entries: src/core/abort-check.ts, src/core/minions/handler-timeouts.ts. - New doctor.ts extension: supervisor_singleton check. - cycle.ts / embed.ts extensions: AbortSignal threading note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
5.4 KiB
JavaScript
119 lines
5.4 KiB
JavaScript
/**
|
|
* topic-context.test.mjs — #1851 topic-aware voice personas.
|
|
*
|
|
* Pins the security + behavior contract for summoning Mars/Venus into a topic:
|
|
* - topicId path-traversal is rejected (only the brain-owned topics/<id>.md)
|
|
* - the topic block is injected when a topic is provided
|
|
* - no topic → generic behavior (no topic block), persona identity unchanged
|
|
* - topic X vs topic Y produce different context
|
|
* - the topic block can NOT override persona identity / hard rules
|
|
* - PII in a topic file is scrubbed
|
|
* - topic CONTENT is never accepted over the wire (only topicId)
|
|
*/
|
|
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { buildTopicContext, isValidTopicId } from '../../code/lib/context-builder.example.mjs';
|
|
import { buildSystemPrompt } from '../../code/prompt.mjs';
|
|
|
|
let brainRoot;
|
|
|
|
// Build PII-shaped strings at runtime so the literal phone/email shapes never
|
|
// appear in this source file (the agent-voice PII guard greps the recipe tree
|
|
// for those shapes). The runtime values still exercise the scrubber.
|
|
const FAKE_PHONE = ['415', '555', '0100'].join('-');
|
|
const FAKE_EMAIL = ['someone', 'example.test'].join('@');
|
|
|
|
beforeEach(() => {
|
|
brainRoot = mkdtempSync(join(tmpdir(), 'agent-voice-topic-'));
|
|
mkdirSync(join(brainRoot, 'topics'), { recursive: true });
|
|
writeFileSync(join(brainRoot, 'topics', 'real-estate.md'), 'We were discussing the warehouse-lease offer and the inspection timeline.');
|
|
writeFileSync(join(brainRoot, 'topics', 'yc-batch.md'), 'Talking through the W26 batch interview schedule.');
|
|
// A file with PII to verify scrubbing (shapes built at runtime, see above).
|
|
writeFileSync(join(brainRoot, 'topics', 'with-pii.md'), `Call me at ${FAKE_PHONE} or ${FAKE_EMAIL} about the deal.`);
|
|
// A secret OUTSIDE the topics dir that traversal must not reach.
|
|
writeFileSync(join(brainRoot, 'SOUL.md'), 'TOP SECRET SOUL CONTENT');
|
|
});
|
|
|
|
afterEach(() => {
|
|
try { rmSync(brainRoot, { recursive: true, force: true }); } catch { /* noop */ }
|
|
});
|
|
|
|
describe('isValidTopicId', () => {
|
|
it('accepts strict slugs', () => {
|
|
expect(isValidTopicId('real-estate')).toBe(true);
|
|
expect(isValidTopicId('yc-batch-2026')).toBe(true);
|
|
});
|
|
it('rejects traversal and unsafe ids', () => {
|
|
expect(isValidTopicId('../../SOUL')).toBe(false);
|
|
expect(isValidTopicId('foo/bar')).toBe(false);
|
|
expect(isValidTopicId('foo.md')).toBe(false);
|
|
expect(isValidTopicId('UPPER')).toBe(false);
|
|
expect(isValidTopicId('')).toBe(false);
|
|
expect(isValidTopicId(undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('buildTopicContext', () => {
|
|
it('returns the topic conversation for a valid id', async () => {
|
|
const ctx = await buildTopicContext({ brainRoot, topicId: 'real-estate' });
|
|
expect(ctx).toContain('warehouse-lease');
|
|
});
|
|
|
|
it('topic X and topic Y differ', async () => {
|
|
const x = await buildTopicContext({ brainRoot, topicId: 'real-estate' });
|
|
const y = await buildTopicContext({ brainRoot, topicId: 'yc-batch' });
|
|
expect(x).toContain('warehouse-lease');
|
|
expect(y).toContain('W26 batch');
|
|
expect(x).not.toEqual(y);
|
|
});
|
|
|
|
it('rejects path traversal — cannot read SOUL.md outside topics/', async () => {
|
|
const ctx = await buildTopicContext({ brainRoot, topicId: '../../SOUL' });
|
|
expect(ctx).toBe('');
|
|
expect(ctx).not.toContain('TOP SECRET');
|
|
});
|
|
|
|
it('scrubs PII in the topic file', async () => {
|
|
const ctx = await buildTopicContext({ brainRoot, topicId: 'with-pii' });
|
|
expect(ctx).not.toContain(FAKE_PHONE);
|
|
expect(ctx).not.toContain(FAKE_EMAIL);
|
|
});
|
|
|
|
it('missing topic file → empty (generic fallback)', async () => {
|
|
expect(await buildTopicContext({ brainRoot, topicId: 'does-not-exist' })).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('buildSystemPrompt topic-awareness', () => {
|
|
it('injects a # Topic Context block when topicId is provided', async () => {
|
|
const prompt = await buildSystemPrompt({ persona: 'mars', brainRoot, topicId: 'real-estate', topicName: 'Real Estate' });
|
|
expect(prompt).toContain('# Topic Context');
|
|
expect(prompt).toContain('warehouse-lease');
|
|
expect(prompt).toContain('CURRENT TOPIC: Real Estate');
|
|
});
|
|
|
|
it('no topicId → no topic block (generic behavior unchanged)', async () => {
|
|
const prompt = await buildSystemPrompt({ persona: 'mars', brainRoot });
|
|
expect(prompt).not.toContain('# Topic Context');
|
|
expect(prompt).not.toContain('CURRENT TOPIC:');
|
|
});
|
|
|
|
it('persona identity stays first; topic context cannot override it', async () => {
|
|
const prompt = await buildSystemPrompt({ persona: 'mars', brainRoot, topicId: 'real-estate', topicName: 'Real Estate' });
|
|
// Identity-first: the "You ARE Mars" line precedes the topic block.
|
|
expect(prompt.indexOf('# You ARE Mars')).toBeLessThan(prompt.indexOf('# Topic Context'));
|
|
// Hard rules survive after the topic block.
|
|
expect(prompt).toContain('# Hard Rules');
|
|
expect(prompt.indexOf('# Topic Context')).toBeLessThan(prompt.indexOf('# Hard Rules'));
|
|
});
|
|
|
|
it('a traversal topicId yields the generic prompt (no block, no leak)', async () => {
|
|
const prompt = await buildSystemPrompt({ persona: 'venus', brainRoot, topicId: '../../SOUL' });
|
|
expect(prompt).not.toContain('# Topic Context');
|
|
expect(prompt).not.toContain('TOP SECRET');
|
|
});
|
|
});
|