fix: hard-exclude observer-sessions from hooks; backfill bundle migrations

Stop hook + SessionEnd hook were storing the SDK observer's own
init/continuation/summary prompts in user_prompts, leaking into the
viewer (meta-observation regression). 25 such rows accumulated.

- shouldTrackProject: hard-reject OBSERVER_SESSIONS_DIR (and its subtree)
  before consulting user-configured exclusion globs.
- summarize.ts (Stop) and session-complete.ts (SessionEnd): early-return
  when shouldTrackProject(cwd) is false, so the observer's own hooks
  cannot bootstrap the worker or queue a summary against the meta-session.
- SessionRoutes: cap user-prompt body at 256 KiB at the session-init
  boundary so a runaway observer prompt cannot blow up storage.
- SessionStore: add migration 29 (UNIQUE(memory_session_id, content_hash)
  on observations) inline so bundled artifacts (worker-service.cjs,
  context-generator.cjs) stay schema-consistent — without it, the
  ON CONFLICT clause in observation inserts throws.
- spawnSdkProcess: stdio[stdin] from 'ignore' to 'pipe' so the
  supervisor can actually feed the observer's stdin.

Also rebuilds plugin/scripts/{worker-service,context-generator}.cjs.
This commit is contained in:
Alex Newman
2026-04-24 19:27:38 -07:00
parent dfbff9cd8c
commit 94a999dd08
8 changed files with 312 additions and 214 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,12 +13,19 @@ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js'
import { executeWithWorkerFallback, isWorkerFallback } from '../../shared/worker-utils.js';
import { logger } from '../../utils/logger.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
import { shouldTrackProject } from '../../shared/should-track-project.js';
export const sessionCompleteHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
const { sessionId } = input;
const platformSource = normalizePlatformSource(input.platform);
// Same OBSERVER_SESSIONS_DIR exclusion as the rest of the hook surface —
// the observer's child Claude Code must never call /api/sessions/complete.
if (input.cwd && !shouldTrackProject(input.cwd)) {
return { continue: true, suppressOutput: true };
}
if (!sessionId) {
logger.warn('HOOK', 'session-complete: Missing sessionId, skipping');
return { continue: true, suppressOutput: true };

View File

@@ -13,6 +13,7 @@ import { logger } from '../../utils/logger.js';
import { extractLastMessage } from '../../shared/transcript-parser.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
import { shouldTrackProject } from '../../shared/should-track-project.js';
interface SessionEndResponse {
ok: boolean;
@@ -22,6 +23,13 @@ interface SessionEndResponse {
export const summarizeHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
// Skip Stop hook entirely when firing from an excluded project (notably
// OBSERVER_SESSIONS_DIR). Without this, the SDK observer's own Stop hook
// queues summaries against its meta-session and triggers a recovery loop.
if (input.cwd && !shouldTrackProject(input.cwd)) {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
// Skip summaries in subagent context — subagents do not own the session summary.
// Gate on agentId only: that field is present exclusively for Task-spawned subagents.
// agentType alone (no agentId) indicates `--agent`-started main sessions, which still

View File

@@ -73,6 +73,7 @@ export class SessionStore {
this.addObservationModelColumns();
this.ensureMergedIntoProjectColumns();
this.addObservationSubagentColumns();
this.addObservationsUniqueContentHashIndex();
}
/**
@@ -1037,6 +1038,47 @@ export class SessionStore {
}
}
/**
* Add UNIQUE(memory_session_id, content_hash) on observations (migration 29).
* Mirrors MigrationRunner.addObservationsUniqueContentHashIndex so bundled
* artifacts that embed SessionStore (e.g. worker-service.cjs, context-generator.cjs)
* stay schema-consistent. Without this, INSERT … ON CONFLICT(memory_session_id,
* content_hash) DO NOTHING throws "ON CONFLICT clause does not match any
* PRIMARY KEY or UNIQUE constraint" and every observation insert fails.
*/
private addObservationsUniqueContentHashIndex(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(29) as SchemaVersion | undefined;
if (applied) return;
const obsCols = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
const hasMem = obsCols.some(c => c.name === 'memory_session_id');
const hasHash = obsCols.some(c => c.name === 'content_hash');
if (!hasMem || !hasHash) {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(29, new Date().toISOString());
return;
}
this.db.run('BEGIN TRANSACTION');
try {
this.db.run(`
DELETE FROM observations
WHERE id NOT IN (
SELECT MIN(id) FROM observations
GROUP BY memory_session_id, content_hash
)
`);
this.db.run(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_observations_session_hash
ON observations(memory_session_id, content_hash)
`);
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(29, new Date().toISOString());
this.db.run('COMMIT');
} catch (error) {
this.db.run('ROLLBACK');
throw error;
}
}
/**
* Update the memory session ID for a session
* Called by SDKAgent when it captures the session ID from the first SDK message

View File

@@ -29,6 +29,8 @@ import { getProjectContext } from '../../../../utils/project-name.js';
import { normalizePlatformSource } from '../../../../shared/platform-source.js';
import { RestartGuard } from '../../RestartGuard.js';
const MAX_USER_PROMPT_BYTES = 256 * 1024;
export class SessionRoutes extends BaseRouteHandler {
private spawnInProgress = new Map<number, boolean>();
private crashRecoveryScheduled = new Set<number>();
@@ -929,10 +931,22 @@ export class SessionRoutes extends BaseRouteHandler {
// Only contentSessionId is truly required — Cursor and other platforms
// may omit prompt/project in their payload (#838, #1049)
const project = req.body.project || 'unknown';
const prompt = req.body.prompt || '[media prompt]';
let prompt = req.body.prompt || '[media prompt]';
const platformSource = normalizePlatformSource(req.body.platformSource);
const customTitle = req.body.customTitle || undefined;
const promptByteLength = Buffer.byteLength(prompt, 'utf8');
if (promptByteLength > MAX_USER_PROMPT_BYTES) {
logger.warn('HTTP', 'SessionRoutes: oversized prompt truncated at session-init boundary', {
project,
contentSessionId,
promptByteLength,
maxBytes: MAX_USER_PROMPT_BYTES,
preview: prompt.slice(0, 200)
});
prompt = Buffer.from(prompt).subarray(0, MAX_USER_PROMPT_BYTES).toString('utf8');
}
logger.info('HTTP', 'SessionRoutes: handleSessionInitByClaudeId called', {
contentSessionId,
project,

View File

@@ -13,14 +13,23 @@
import { isProjectExcluded } from '../utils/project-filter.js';
import { loadFromFileOnce } from './hook-settings.js';
import { OBSERVER_SESSIONS_DIR } from './paths.js';
/**
* @returns true when the project at `cwd` is NOT excluded from claude-mem
* tracking, i.e., the hook should proceed; false when the project
* matches one of the exclusion globs.
*
* Hard-excludes OBSERVER_SESSIONS_DIR: the SDK agent spawns Claude Code with
* that cwd, and its hooks must never feed the worker — otherwise the observer's
* own init/continuation/summary prompts end up stored as `user_prompts` and
* leak into the viewer (meta-observation).
*/
export function shouldTrackProject(cwd: string): boolean {
if (!cwd) return true;
if (cwd === OBSERVER_SESSIONS_DIR || cwd.startsWith(OBSERVER_SESSIONS_DIR + '/')) {
return false;
}
const settings = loadFromFileOnce();
return !isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS);
}

View File

@@ -670,7 +670,7 @@ export function spawnSdkProcess(
cwd: options.cwd,
env,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['pipe', 'pipe', 'pipe'],
signal: options.signal,
windowsHide: true,
})
@@ -678,7 +678,7 @@ export function spawnSdkProcess(
cwd: options.cwd,
env,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['pipe', 'pipe', 'pipe'],
signal: options.signal,
windowsHide: true,
});