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
+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