feat: disable subagent summaries, label subagent observations (#2073)

* feat: disable subagent summaries and label subagent observations

Detect Claude Code subagent hook context via `agent_id`/`agent_type` on
stdin, short-circuit the Stop-hook summary path when present, and thread
the subagent identity end-to-end onto observation rows (new `agent_type`
and `agent_id` columns, migration 010 at version 27). Main-session rows
remain NULL; content-hash dedup is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address PR #2073 review feedback

- Narrow summarize subagent guard to agentId only so --agent-started
  main sessions still own their summary (agentType alone is main-session).
- Remove now-dead agentId/agentType spreads from the summarize POST body.
- Always overwrite pendingAgentId/pendingAgentType in SDK/Gemini/OpenRouter
  agents (clears stale subagent identity on main-session messages after
  a subagent message in the same batch).
- Add idx_observations_agent_id index in migration 010 + the mirror
  migration in SessionStore + the runner.
- Replace console.log in migration010 with logger.debug.
- Update summarize test: agentType alone no longer short-circuits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address CodeRabbit + claude-review iteration 4 feedback

- SessionRoutes.handleSummarizeByClaudeId: narrow worker-side guard to
  agentId only (matches hook-side). agentType alone = --agent main
  session, which still owns its summary.
- ResponseProcessor: wrap storeObservations in try/finally so
  pendingAgentId/Type clear even if storage throws. Prevents stale
  subagent identity from leaking into the next batch on error.
- SessionStore.importObservation + bulk.importObservation: persist
  agent_type/agent_id so backup/import round-trips preserve subagent
  attribution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish: claude-review iteration 5 cleanup

- Use ?? not || for nullable subagent fields in PendingMessageStore
  (prevents treating empty string as null).
- Simplify observation.ts body spread — include fields unconditionally;
  JSON.stringify drops undefined anyway.
- Narrow any[] to Array<{ name: string }> in migration010 column checks.
- Add trailing newline to migrations.ts.
- Document in observations/store.ts why the dedup hash intentionally
  excludes agent fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish: claude-review iteration 7 feedback

- claude-code adapter: add 128-char safety cap on agent_id/agent_type
  so a malformed Claude Code payload cannot balloon DB rows. Empty
  strings now also treated as absent.
- migration010: state-aware debug log lists only columns actually
  added; idempotent re-runs log "already present; ensured indexes".
- Add 3 adapter tests covering the length cap boundary and empty-string
  rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf: skip subagent summary before worker bootstrap

Move the agentId short-circuit above ensureWorkerRunning() so a Stop
hook fired inside a subagent does not trigger worker startup just to
return early. Addresses CodeRabbit nit on summarize.ts:36-47.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-19 14:58:01 -07:00
committed by GitHub
parent 306a0b1de9
commit 789efe4234
27 changed files with 1381 additions and 361 deletions
+9
View File
@@ -2,6 +2,13 @@ import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.
// Maps Claude Code stdin format (session_id, cwd, tool_name, etc.)
// SessionStart hooks receive no stdin, so we must handle undefined input gracefully
// Defensive cap: Claude Code's agent identifiers are short (e.g., "agent-abc123", "Explore").
// Ignore anything longer than 128 chars so a malformed payload cannot balloon DB rows.
const MAX_AGENT_FIELD_LEN = 128;
const pickAgentField = (v: unknown): string | undefined =>
typeof v === 'string' && v.length > 0 && v.length <= MAX_AGENT_FIELD_LEN ? v : undefined;
export const claudeCodeAdapter: PlatformAdapter = {
normalizeInput(raw) {
const r = (raw ?? {}) as any;
@@ -13,6 +20,8 @@ export const claudeCodeAdapter: PlatformAdapter = {
toolInput: r.tool_input,
toolResponse: r.tool_response,
transcriptPath: r.transcript_path,
agentId: pickAgentField(r.agent_id),
agentType: pickAgentField(r.agent_type),
};
},
formatOutput(result) {
+3 -1
View File
@@ -57,7 +57,9 @@ export const observationHandler: EventHandler = {
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,
cwd
cwd,
agentId: input.agentId,
agentType: input.agentType
})
});
+14
View File
@@ -26,6 +26,20 @@ const MAX_WAIT_FOR_SUMMARY_MS = 110_000; // 110s — fits within Stop hook's 120
export const summarizeHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
// 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
// own their summary. Do this BEFORE ensureWorkerRunning() so a subagent Stop hook
// does not bootstrap the worker.
if (input.agentId) {
logger.debug('HOOK', 'Skipping summary: subagent context detected', {
sessionId: input.sessionId,
agentId: input.agentId,
agentType: input.agentType
});
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
// Ensure worker is running before any other logic
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
+4
View File
@@ -12,6 +12,10 @@ export interface NormalizedHookInput {
edits?: unknown[]; // afterFileEdit
// Platform-specific metadata (source, reason, trigger, mcp_context, etc.)
metadata?: Record<string, unknown>;
// Claude Code subagent identity — present only when hook fires inside a subagent.
// Main session has both undefined. Discriminator for subagent context.
agentId?: string; // Claude Code subagent agent_id (undefined in main session)
agentType?: string; // Claude Code subagent agent_type (undefined in main session)
}
export interface HookResult {
+12 -4
View File
@@ -24,6 +24,9 @@ export interface PersistentPendingMessage {
created_at_epoch: number;
started_processing_at_epoch: number | null;
completed_at_epoch: number | null;
// Claude Code subagent identity — NULL for main-session messages.
agent_type: string | null;
agent_id: string | null;
}
/**
@@ -64,8 +67,9 @@ export class PendingMessageStore {
session_db_id, content_session_id, message_type,
tool_name, tool_input, tool_response, cwd,
last_assistant_message,
prompt_number, status, retry_count, created_at_epoch
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)
prompt_number, status, retry_count, created_at_epoch,
agent_type, agent_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?)
`);
const result = stmt.run(
@@ -78,7 +82,9 @@ export class PendingMessageStore {
message.cwd || null,
message.last_assistant_message || null,
message.prompt_number || null,
now
now,
message.agentType ?? null,
message.agentId ?? null
);
return result.lastInsertRowid as number;
@@ -496,7 +502,9 @@ export class PendingMessageStore {
tool_response: persistent.tool_response ? JSON.parse(persistent.tool_response) : undefined,
prompt_number: persistent.prompt_number || undefined,
cwd: persistent.cwd || undefined,
last_assistant_message: persistent.last_assistant_message || undefined
last_assistant_message: persistent.last_assistant_message || undefined,
agentId: persistent.agent_id ?? undefined,
agentType: persistent.agent_type ?? undefined
};
}
}
+64 -8
View File
@@ -66,6 +66,7 @@ export class SessionStore {
this.addSessionPlatformSourceColumn();
this.addObservationModelColumns();
this.ensureMergedIntoProjectColumns();
this.addObservationSubagentColumns();
}
/**
@@ -975,6 +976,44 @@ export class SessionStore {
);
}
/**
* Add agent_type and agent_id columns to observations and pending_messages (migration 27).
* Mirrors MigrationRunner.addObservationSubagentColumns so bundled artifacts that embed
* SessionStore (e.g. context-generator.cjs) stay schema-consistent.
*/
private addObservationSubagentColumns(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(27) as SchemaVersion | undefined;
const obsCols = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
const obsHasAgentType = obsCols.some(col => col.name === 'agent_type');
const obsHasAgentId = obsCols.some(col => col.name === 'agent_id');
if (!obsHasAgentType) {
this.db.run('ALTER TABLE observations ADD COLUMN agent_type TEXT');
}
if (!obsHasAgentId) {
this.db.run('ALTER TABLE observations ADD COLUMN agent_id TEXT');
}
this.db.run('CREATE INDEX IF NOT EXISTS idx_observations_agent_type ON observations(agent_type)');
this.db.run('CREATE INDEX IF NOT EXISTS idx_observations_agent_id ON observations(agent_id)');
const pendingCols = this.db.query('PRAGMA table_info(pending_messages)').all() as TableColumnInfo[];
if (pendingCols.length > 0) {
const pendingHasAgentType = pendingCols.some(col => col.name === 'agent_type');
const pendingHasAgentId = pendingCols.some(col => col.name === 'agent_id');
if (!pendingHasAgentType) {
this.db.run('ALTER TABLE pending_messages ADD COLUMN agent_type TEXT');
}
if (!pendingHasAgentId) {
this.db.run('ALTER TABLE pending_messages ADD COLUMN agent_id TEXT');
}
}
if (!applied) {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(27, new Date().toISOString());
}
}
/**
* Update the memory session ID for a session
* Called by SDKAgent when it captures the session ID from the first SDK message
@@ -1734,6 +1773,8 @@ export class SessionStore {
concepts: string[];
files_read: string[];
files_modified: string[];
agent_type?: string | null;
agent_id?: string | null;
},
promptNumber?: number,
discoveryTokens: number = 0,
@@ -1754,9 +1795,9 @@ export class SessionStore {
const stmt = this.db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch,
files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id, content_hash, created_at, created_at_epoch,
generated_by_model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -1772,6 +1813,8 @@ export class SessionStore {
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
observation.agent_type ?? null,
observation.agent_id ?? null,
contentHash,
timestampIso,
timestampEpoch,
@@ -1863,6 +1906,8 @@ export class SessionStore {
concepts: string[];
files_read: string[];
files_modified: string[];
agent_type?: string | null;
agent_id?: string | null;
}>,
summary: {
request: string;
@@ -1889,9 +1934,9 @@ export class SessionStore {
const obsStmt = this.db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch,
files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id, content_hash, created_at, created_at_epoch,
generated_by_model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const observation of observations) {
@@ -1916,6 +1961,8 @@ export class SessionStore {
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
observation.agent_type ?? null,
observation.agent_id ?? null,
contentHash,
timestampIso,
timestampEpoch,
@@ -1993,6 +2040,8 @@ export class SessionStore {
concepts: string[];
files_read: string[];
files_modified: string[];
agent_type?: string | null;
agent_id?: string | null;
}>,
summary: {
request: string;
@@ -2021,9 +2070,9 @@ export class SessionStore {
const obsStmt = this.db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch,
files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id, content_hash, created_at, created_at_epoch,
generated_by_model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const observation of observations) {
@@ -2048,6 +2097,8 @@ export class SessionStore {
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
observation.agent_type ?? null,
observation.agent_id ?? null,
contentHash,
timestampIso,
timestampEpoch,
@@ -2608,6 +2659,8 @@ export class SessionStore {
discovery_tokens: number;
created_at: string;
created_at_epoch: number;
agent_type?: string | null;
agent_id?: string | null;
}): { imported: boolean; id: number } {
// Check if observation already exists
const existing = this.db.prepare(`
@@ -2623,8 +2676,9 @@ export class SessionStore {
INSERT INTO observations (
memory_session_id, project, text, type, title, subtitle,
facts, narrative, concepts, files_read, files_modified,
prompt_number, discovery_tokens, created_at, created_at_epoch
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
prompt_number, discovery_tokens, agent_type, agent_id,
created_at, created_at_epoch
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -2641,6 +2695,8 @@ export class SessionStore {
obs.files_modified,
obs.prompt_number,
obs.discovery_tokens || 0,
obs.agent_type ?? null,
obs.agent_id ?? null,
obs.created_at,
obs.created_at_epoch
);
+7 -2
View File
@@ -141,6 +141,8 @@ export function importObservation(
discovery_tokens: number;
created_at: string;
created_at_epoch: number;
agent_type?: string | null;
agent_id?: string | null;
}
): ImportResult {
// Check if observation already exists
@@ -163,8 +165,9 @@ export function importObservation(
INSERT INTO observations (
memory_session_id, project, text, type, title, subtitle,
facts, narrative, concepts, files_read, files_modified,
prompt_number, discovery_tokens, created_at, created_at_epoch
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
prompt_number, discovery_tokens, agent_type, agent_id,
created_at, created_at_epoch
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -181,6 +184,8 @@ export function importObservation(
obs.files_modified,
obs.prompt_number,
obs.discovery_tokens || 0,
obs.agent_type ?? null,
obs.agent_id ?? null,
obs.created_at,
obs.created_at_epoch
);
+59 -2
View File
@@ -1,5 +1,6 @@
import { Database } from 'bun:sqlite';
import { Migration } from './Database.js';
import { logger } from '../../utils/logger.js';
// Re-export MigrationRunner for SessionStore migration extraction
export { MigrationRunner } from './migrations/runner.js';
@@ -572,6 +573,61 @@ export const migration009: Migration = {
}
};
/**
* Migration 010: Label observations (and their queue rows) with the subagent identity.
*
* Claude Code hooks that fire inside a subagent carry agent_id and agent_type on the
* stdin payload. These flow hook → worker → pending_messages → SDK storage so that
* observation rows can be attributed to the originating subagent. Main-session rows
* keep NULL for both columns.
*/
export const migration010: Migration = {
version: 27,
up: (db: Database) => {
const added: string[] = [];
const obsColumns = db.prepare('PRAGMA table_info(observations)').all() as Array<{ name: string }>;
const obsHasAgentType = obsColumns.some(c => c.name === 'agent_type');
const obsHasAgentId = obsColumns.some(c => c.name === 'agent_id');
if (!obsHasAgentType) {
db.run('ALTER TABLE observations ADD COLUMN agent_type TEXT');
added.push('observations.agent_type');
}
if (!obsHasAgentId) {
db.run('ALTER TABLE observations ADD COLUMN agent_id TEXT');
added.push('observations.agent_id');
}
db.run('CREATE INDEX IF NOT EXISTS idx_observations_agent_type ON observations(agent_type)');
db.run('CREATE INDEX IF NOT EXISTS idx_observations_agent_id ON observations(agent_id)');
// Also thread the same fields through the pending_messages queue so the label
// survives worker restarts between enqueue and SDK-agent processing.
const pendingColumns = db.prepare('PRAGMA table_info(pending_messages)').all() as Array<{ name: string }>;
if (pendingColumns.length > 0) {
const pendingHasAgentType = pendingColumns.some(c => c.name === 'agent_type');
const pendingHasAgentId = pendingColumns.some(c => c.name === 'agent_id');
if (!pendingHasAgentType) {
db.run('ALTER TABLE pending_messages ADD COLUMN agent_type TEXT');
added.push('pending_messages.agent_type');
}
if (!pendingHasAgentId) {
db.run('ALTER TABLE pending_messages ADD COLUMN agent_id TEXT');
added.push('pending_messages.agent_id');
}
}
logger.debug(
'DB',
added.length > 0
? `[migration010] Added columns: ${added.join(', ')}`
: '[migration010] Subagent identity columns already present; ensured indexes'
);
},
down: (_db: Database) => {
// SQLite DROP COLUMN not fully supported; no-op
}
};
/**
* All migrations in order
*/
@@ -584,5 +640,6 @@ export const migrations: Migration[] = [
migration006,
migration007,
migration008,
migration009
];
migration009,
migration010
];
+48
View File
@@ -38,6 +38,7 @@ export class MigrationRunner {
this.createObservationFeedbackTable();
this.addSessionPlatformSourceColumn();
this.ensureMergedIntoProjectColumns();
this.addObservationSubagentColumns();
}
/**
@@ -952,4 +953,51 @@ export class MigrationRunner {
'CREATE INDEX IF NOT EXISTS idx_summaries_merged_into ON session_summaries(merged_into_project)'
);
}
/**
* Add agent_type and agent_id columns to observations and pending_messages (migration 27).
*
* Labels observation rows with the originating Claude Code subagent identity so
* downstream queries can distinguish main-session work from subagent work.
* Main-session rows keep NULL for both columns.
*
* Also threads the same columns through pending_messages so the label survives
* between enqueue (hook) and SDK-agent processing (which re-inserts into observations).
*/
private addObservationSubagentColumns(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(27) as SchemaVersion | undefined;
const obsCols = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
const obsHasAgentType = obsCols.some(c => c.name === 'agent_type');
const obsHasAgentId = obsCols.some(c => c.name === 'agent_id');
if (!obsHasAgentType) {
this.db.run('ALTER TABLE observations ADD COLUMN agent_type TEXT');
logger.debug('DB', 'Added agent_type column to observations table');
}
if (!obsHasAgentId) {
this.db.run('ALTER TABLE observations ADD COLUMN agent_id TEXT');
logger.debug('DB', 'Added agent_id column to observations table');
}
this.db.run('CREATE INDEX IF NOT EXISTS idx_observations_agent_type ON observations(agent_type)');
this.db.run('CREATE INDEX IF NOT EXISTS idx_observations_agent_id ON observations(agent_id)');
const pendingCols = this.db.query('PRAGMA table_info(pending_messages)').all() as TableColumnInfo[];
if (pendingCols.length > 0) {
const pendingHasAgentType = pendingCols.some(c => c.name === 'agent_type');
const pendingHasAgentId = pendingCols.some(c => c.name === 'agent_id');
if (!pendingHasAgentType) {
this.db.run('ALTER TABLE pending_messages ADD COLUMN agent_type TEXT');
logger.debug('DB', 'Added agent_type column to pending_messages table');
}
if (!pendingHasAgentId) {
this.db.run('ALTER TABLE pending_messages ADD COLUMN agent_id TEXT');
logger.debug('DB', 'Added agent_id column to pending_messages table');
}
}
if (!applied) {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(27, new Date().toISOString());
}
}
}
+6 -2
View File
@@ -15,6 +15,8 @@ const DEDUP_WINDOW_MS = 30_000;
/**
* Compute a short content hash for deduplication.
* Uses (memory_session_id, title, narrative) as the semantic identity of an observation.
* Subagent fields (agent_type, agent_id) are intentionally excluded so the same work
* described once by a subagent and once by its parent deduplicates across contexts.
*/
export function computeObservationContentHash(
memorySessionId: string,
@@ -75,8 +77,8 @@ export function storeObservation(
const stmt = db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id, content_hash, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -92,6 +94,8 @@ export function storeObservation(
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
observation.agent_type ?? null,
observation.agent_id ?? null,
contentHash,
timestampIso,
timestampEpoch
@@ -16,6 +16,9 @@ export interface ObservationInput {
concepts: string[];
files_read: string[];
files_modified: string[];
// Claude Code subagent identity — NULL for main-session rows.
agent_type?: string | null;
agent_id?: string | null;
}
/**
+8 -4
View File
@@ -68,8 +68,8 @@ export function storeObservationsAndMarkComplete(
const obsStmt = db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id, content_hash, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const observation of observations) {
@@ -93,6 +93,8 @@ export function storeObservationsAndMarkComplete(
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
observation.agent_type ?? null,
observation.agent_id ?? null,
contentHash,
timestampIso,
timestampEpoch
@@ -187,8 +189,8 @@ export function storeObservations(
const obsStmt = db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id, content_hash, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const observation of observations) {
@@ -212,6 +214,8 @@ export function storeObservations(
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
observation.agent_type ?? null,
observation.agent_id ?? null,
contentHash,
timestampIso,
timestampEpoch
+11
View File
@@ -49,6 +49,11 @@ export interface ActiveSession {
// Circuit breaker: track consecutive summary failures to prevent infinite retry loops (#1633).
// When this reaches MAX_CONSECUTIVE_SUMMARY_FAILURES, further summarize requests are skipped.
consecutiveSummaryFailures: number;
// Subagent identity carried forward from the most recent claimed pending message.
// When observations are parsed and stored, these fields label the resulting rows
// so subagent work is attributable. NULL / undefined means the batch came from the main session.
pendingAgentId?: string | null;
pendingAgentType?: string | null;
}
export interface PendingMessage {
@@ -59,6 +64,9 @@ export interface PendingMessage {
prompt_number?: number;
cwd?: string;
last_assistant_message?: string;
// Claude Code subagent identity — present only when the hook fired inside a subagent.
agentId?: string;
agentType?: string;
}
/**
@@ -77,6 +85,9 @@ export interface ObservationData {
tool_response: any;
prompt_number: number;
cwd?: string;
// Claude Code subagent identity — present only when the hook fired inside a subagent.
agentId?: string;
agentType?: string;
}
// ============================================================================
+7
View File
@@ -201,6 +201,13 @@ export class GeminiAgent {
// The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed()
session.processingMessageIds.push(message._persistentId);
// Capture subagent identity from the claimed message so ResponseProcessor
// can label observation rows with the originating Claude Code subagent.
// Always overwrite (even with null) so a main-session message after a subagent
// message clears the stale identity; otherwise mixed batches could mislabel.
session.pendingAgentId = message.agentId ?? null;
session.pendingAgentType = message.agentType ?? null;
// Capture cwd from each message for worktree support
if (message.cwd) {
lastCwd = message.cwd;
+7
View File
@@ -150,6 +150,13 @@ export class OpenRouterAgent {
// The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed()
session.processingMessageIds.push(message._persistentId);
// Capture subagent identity from the claimed message so ResponseProcessor
// can label observation rows with the originating Claude Code subagent.
// Always overwrite (even with null) so a main-session message after a subagent
// message clears the stale identity; otherwise mixed batches could mislabel.
session.pendingAgentId = message.agentId ?? null;
session.pendingAgentType = message.agentType ?? null;
// Capture cwd from messages for proper worktree support
if (message.cwd) {
lastCwd = message.cwd;
+7
View File
@@ -374,6 +374,13 @@ export class SDKAgent {
// The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed()
session.processingMessageIds.push(message._persistentId);
// Capture subagent identity from the claimed message so ResponseProcessor
// can label observation rows with the originating Claude Code subagent.
// Always overwrite (even with null) so a main-session message after a subagent
// message clears the stale identity; otherwise mixed batches could mislabel.
session.pendingAgentId = message.agentId ?? null;
session.pendingAgentType = message.agentType ?? null;
// Capture cwd from each message for worktree support
if (message.cwd) {
cwdTracker.lastCwd = message.cwd;
+6 -2
View File
@@ -221,7 +221,9 @@ export class SessionManager {
consecutiveRestarts: 0, // Track consecutive restart attempts to prevent infinite loops
processingMessageIds: [], // CLAIM-CONFIRM: Track message IDs for confirmProcessed()
lastGeneratorActivity: Date.now(), // Initialize for stale detection (Issue #1099)
consecutiveSummaryFailures: 0 // Circuit breaker for summary retry loop (#1633)
consecutiveSummaryFailures: 0, // Circuit breaker for summary retry loop (#1633)
pendingAgentId: null, // Subagent identity carried from the most recent claimed message
pendingAgentType: null // (null for main-session messages)
};
logger.debug('SESSION', 'Creating new session object (memorySessionId cleared to prevent stale resume)', {
@@ -277,7 +279,9 @@ export class SessionManager {
tool_input: data.tool_input,
tool_response: data.tool_response,
prompt_number: data.prompt_number,
cwd: data.cwd
cwd: data.cwd,
agentId: data.agentId,
agentType: data.agentType
};
try {
+29 -11
View File
@@ -118,18 +118,36 @@ export async function processAgentResponse(
memorySessionId: session.memorySessionId
});
// Label observations with the subagent identity captured from the claimed messages.
// Main-session messages leave these null, so main-session rows stay NULL in the DB.
const labeledObservations = observations.map(obs => ({
...obs,
agent_type: session.pendingAgentType ?? null,
agent_id: session.pendingAgentId ?? null
}));
// ATOMIC TRANSACTION: Store observations + summary ONCE
// Messages are already deleted from queue on claim, so no completion tracking needed
const result = sessionStore.storeObservations(
session.memorySessionId,
session.project,
observations,
summaryForStore,
session.lastPromptNumber,
discoveryTokens,
originalTimestamp ?? undefined,
modelId
);
// Messages are already deleted from queue on claim, so no completion tracking needed.
// Wrap in try/finally so the subagent tracker clears even if storage throws —
// otherwise stale identity could leak into the next batch and mislabel rows.
// Expected invariant: all observations in a batch share the same agent context,
// because ResponseProcessor runs after a single agent-response cycle.
let result: ReturnType<typeof sessionStore.storeObservations>;
try {
result = sessionStore.storeObservations(
session.memorySessionId,
session.project,
labeledObservations,
summaryForStore,
session.lastPromptNumber,
discoveryTokens,
originalTimestamp ?? undefined,
modelId
);
} finally {
session.pendingAgentId = null;
session.pendingAgentType = null;
}
// Log storage result with IDs for end-to-end traceability
logger.info('DB', `STORED | sessionDbId=${session.sessionDbId} | memorySessionId=${session.memorySessionId} | obsCount=${result.observationIds.length} | obsIds=[${result.observationIds.join(',')}] | summaryId=${result.summaryId || 'none'}`, {
@@ -553,7 +553,7 @@ export class SessionRoutes extends BaseRouteHandler {
* Body: { contentSessionId, tool_name, tool_input, tool_response, cwd }
*/
private handleObservationsByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
const { contentSessionId, tool_name, tool_input, tool_response, cwd } = req.body;
const { contentSessionId, tool_name, tool_input, tool_response, cwd, agentId, agentType } = req.body;
const platformSource = normalizePlatformSource(req.body.platformSource);
const project = typeof cwd === 'string' && cwd.trim() ? getProjectContext(cwd).primary : '';
@@ -628,7 +628,9 @@ export class SessionRoutes extends BaseRouteHandler {
tool_name
});
return '';
})()
})(),
agentId: typeof agentId === 'string' ? agentId : undefined,
agentType: typeof agentType === 'string' ? agentType : undefined,
});
// Ensure SDK agent is running
@@ -653,13 +655,21 @@ export class SessionRoutes extends BaseRouteHandler {
* Checks privacy, queues summarize request for SDK agent
*/
private handleSummarizeByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
const { contentSessionId, last_assistant_message } = req.body;
const { contentSessionId, last_assistant_message, agentId } = req.body;
const platformSource = normalizePlatformSource(req.body.platformSource);
if (!contentSessionId) {
return this.badRequest(res, 'Missing contentSessionId');
}
// Belt-and-suspenders: reject summarize requests from subagent context.
// Gate on agentId only — agentType alone indicates a main session started with
// --agent, which still owns its summary. Mirrors the hook-side guard in summarize.ts.
if (agentId) {
res.json({ status: 'skipped', reason: 'subagent_context' });
return;
}
const store = this.dbManager.getSessionStore();
// Get or create session