feat: SDK engine native resume with per-tenant HOME isolation

Replace prompt-prefix workaround with SDK's native resume mechanism.
Each tenant gets isolated HOME directory (/data/claude-tenants/{tenantId})
to prevent cross-tenant session file mixing. SDK session IDs are persisted
in session.metadata for cross-request resume support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-25 02:27:38 -08:00
parent 2403ce5636
commit cc0f06e2be
4 changed files with 138 additions and 78 deletions

View File

@ -121,6 +121,7 @@ services:
volumes: volumes:
- ${HOME}/.claude:/home/appuser/.claude - ${HOME}/.claude:/home/appuser/.claude
- ${HOME}/.claude.json:/home/appuser/.claude.json - ${HOME}/.claude.json:/home/appuser/.claude.json
- claude_tenants:/data/claude-tenants
environment: environment:
- DB_HOST=postgres - DB_HOST=postgres
- DB_PORT=5432 - DB_PORT=5432
@ -371,6 +372,7 @@ services:
volumes: volumes:
postgres_data: postgres_data:
claude_tenants:
networks: networks:
it0-network: it0-network:

View File

@ -26,6 +26,8 @@ export interface EngineTaskParams {
role: 'user' | 'assistant'; role: 'user' | 'assistant';
content: string | any[]; content: string | any[];
}>; }>;
/** SDK session ID for native resume (Agent SDK engine only). */
resumeSessionId?: string;
} }
export type EngineStreamEvent = export type EngineStreamEvent =

View File

@ -25,6 +25,8 @@ import { TenantAgentConfigService } from '../../services/tenant-agent-config.ser
import { AllowedToolsResolverService } from '../../../domain/services/allowed-tools-resolver.service'; import { AllowedToolsResolverService } from '../../../domain/services/allowed-tools-resolver.service';
import { TenantContextService } from '@it0/common'; import { TenantContextService } from '@it0/common';
import { ApprovalGate } from './approval-gate'; import { ApprovalGate } from './approval-gate';
import * as fs from 'fs';
import * as path from 'path';
// Dynamic import helper that survives tsc commonjs compilation // Dynamic import helper that survives tsc commonjs compilation
// (tsc converts `await import()` → require() which breaks ESM-only packages) // (tsc converts `await import()` → require() which breaks ESM-only packages)
@ -100,15 +102,11 @@ export class ClaudeAgentSdkEngine implements AgentEnginePort {
try { try {
const { query } = await dynamicImport('@anthropic-ai/claude-agent-sdk'); const { query } = await dynamicImport('@anthropic-ai/claude-agent-sdk');
// Build prompt with conversation history prefix if available // Set tenant-isolated HOME directory so SDK session files are separated per tenant
const promptWithContext = this.buildPromptWithHistory( this.ensureTenantHome(tenantId, env);
params.prompt,
params.conversationHistory,
);
const sdkQuery = query({ // Build SDK query options
prompt: promptWithContext, const sdkOptions: Record<string, any> = {
options: {
systemPrompt: params.systemPrompt || undefined, systemPrompt: params.systemPrompt || undefined,
allowedTools: params.allowedTools?.length ? params.allowedTools : undefined, allowedTools: params.allowedTools?.length ? params.allowedTools : undefined,
maxTurns: params.maxTurns, maxTurns: params.maxTurns,
@ -121,7 +119,7 @@ export class ClaudeAgentSdkEngine implements AgentEnginePort {
stderr: (data: string) => { stderr: (data: string) => {
this.logger.debug(`SDK stderr (${params.sessionId}): ${data.trim()}`); this.logger.debug(`SDK stderr (${params.sessionId}): ${data.trim()}`);
}, },
canUseTool: async (toolName, toolInput, { signal }) => { canUseTool: async (toolName: string, toolInput: any, { signal }: { signal: AbortSignal }) => {
const riskLevel = this.classifyToolRisk(toolName, toolInput); const riskLevel = this.classifyToolRisk(toolName, toolInput);
// L0-L1: auto-approve // L0-L1: auto-approve
@ -158,7 +156,17 @@ export class ClaudeAgentSdkEngine implements AgentEnginePort {
}; };
} }
}, },
}, };
// Native SDK resume: if we have a previous SDK session ID, resume it
if (params.resumeSessionId) {
sdkOptions.resume = params.resumeSessionId;
this.logger.log(`Resuming SDK session: ${params.resumeSessionId} for session ${params.sessionId}`);
}
const sdkQuery = query({
prompt: params.prompt,
options: sdkOptions,
}); });
// Consume SDK messages in the background and push to event queue // Consume SDK messages in the background and push to event queue
@ -273,6 +281,9 @@ export class ClaudeAgentSdkEngine implements AgentEnginePort {
delete env.ANTHROPIC_API_KEY; delete env.ANTHROPIC_API_KEY;
} }
// Set tenant-isolated HOME directory
this.ensureTenantHome(tenantId, env);
const timeoutSec = tenantConfig?.approvalTimeoutSeconds ?? 120; const timeoutSec = tenantConfig?.approvalTimeoutSeconds ?? 120;
const gate = new ApprovalGate(timeoutSec); const gate = new ApprovalGate(timeoutSec);
const abortController = new AbortController(); const abortController = new AbortController();
@ -291,7 +302,7 @@ export class ClaudeAgentSdkEngine implements AgentEnginePort {
abortController, abortController,
allowDangerouslySkipPermissions: true, allowDangerouslySkipPermissions: true,
permissionMode: 'bypassPermissions', permissionMode: 'bypassPermissions',
canUseTool: async (toolName, toolInput) => { canUseTool: async (toolName: string, toolInput: any) => {
const riskLevel = this.classifyToolRisk(toolName, toolInput); const riskLevel = this.classifyToolRisk(toolName, toolInput);
if (riskLevel <= CommandRiskLevel.LOW_RISK_WRITE) { if (riskLevel <= CommandRiskLevel.LOW_RISK_WRITE) {
return { behavior: 'allow' as const, updatedInput: toolInput }; return { behavior: 'allow' as const, updatedInput: toolInput };
@ -351,30 +362,54 @@ export class ClaudeAgentSdkEngine implements AgentEnginePort {
} }
/** /**
* Build a prompt that includes conversation history as a prefix. * Set up a tenant-isolated HOME directory so SDK session files
* Used when SDK session resume is not available (e.g., after process restart). * are stored separately per tenant. Creates the directory structure
* and symlinks shared credentials if needed.
*/ */
private buildPromptWithHistory( private ensureTenantHome(tenantId: string, env: Record<string, string>): void {
prompt: string, const basePath = '/data/claude-tenants';
history?: Array<{ role: 'user' | 'assistant'; content: string | any[] }>, const tenantHome = path.join(basePath, tenantId);
): string { const tenantClaudeDir = path.join(tenantHome, '.claude');
if (!history || history.length === 0) return prompt;
const lines: string[] = ['[Previous conversation]']; // Create tenant directory structure if it doesn't exist
for (const msg of history) { if (!fs.existsSync(tenantClaudeDir)) {
const role = msg.role === 'user' ? 'User' : 'Assistant'; fs.mkdirSync(tenantClaudeDir, { recursive: true });
const content = typeof msg.content === 'string' this.logger.log(`Created tenant HOME directory: ${tenantHome}`);
? msg.content
: JSON.stringify(msg.content);
// Truncate very long messages in the prefix
const truncated = content.length > 500
? content.slice(0, 500) + '...'
: content;
lines.push(`${role}: ${truncated}`);
} }
lines.push('', '[Current request]', prompt);
return lines.join('\n'); // Symlink shared credentials for subscription mode
const credentialsTarget = path.join(tenantClaudeDir, '.credentials.json');
const sharedCredentials = '/home/appuser/.claude/.credentials.json';
if (!fs.existsSync(credentialsTarget) && fs.existsSync(sharedCredentials)) {
try {
fs.symlinkSync(sharedCredentials, credentialsTarget);
this.logger.log(`Symlinked credentials for tenant ${tenantId}`);
} catch (err: any) {
this.logger.warn(`Failed to symlink credentials for tenant ${tenantId}: ${err.message}`);
// Fallback: copy the file instead
try {
fs.copyFileSync(sharedCredentials, credentialsTarget);
this.logger.log(`Copied credentials for tenant ${tenantId} (symlink failed)`);
} catch (copyErr: any) {
this.logger.error(`Failed to copy credentials for tenant ${tenantId}: ${copyErr.message}`);
}
}
}
// Also symlink .claude.json (settings file) if it exists
const settingsTarget = path.join(tenantHome, '.claude.json');
const sharedSettings = '/home/appuser/.claude.json';
if (!fs.existsSync(settingsTarget) && fs.existsSync(sharedSettings)) {
try {
fs.symlinkSync(sharedSettings, settingsTarget);
} catch {
try { fs.copyFileSync(sharedSettings, settingsTarget); } catch { /* ignore */ }
}
}
// Set HOME to tenant-specific directory
env.HOME = tenantHome;
this.logger.debug(`Set HOME=${tenantHome} for tenant ${tenantId}`);
} }
private classifyToolRisk(toolName: string, toolInput: any): CommandRiskLevel { private classifyToolRisk(toolName: string, toolInput: any): CommandRiskLevel {

View File

@ -9,6 +9,7 @@ import { AgentSession } from '../../../domain/entities/agent-session.entity';
import { AgentTask } from '../../../domain/entities/agent-task.entity'; import { AgentTask } from '../../../domain/entities/agent-task.entity';
import { TaskStatus } from '../../../domain/value-objects/task-status.vo'; import { TaskStatus } from '../../../domain/value-objects/task-status.vo';
import { AgentEngineType } from '../../../domain/value-objects/agent-engine-type.vo'; import { AgentEngineType } from '../../../domain/value-objects/agent-engine-type.vo';
import { ClaudeAgentSdkEngine } from '../../../infrastructure/engines/claude-agent-sdk/claude-agent-sdk-engine';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
@Controller('api/v1/agent') @Controller('api/v1/agent')
@ -86,6 +87,16 @@ export class AgentController {
// so we pass the history minus the last user message (it will be added by the engine as params.prompt) // so we pass the history minus the last user message (it will be added by the engine as params.prompt)
const historyForEngine = conversationHistory.slice(0, -1); const historyForEngine = conversationHistory.slice(0, -1);
// For SDK engine: load previous SDK session ID for native resume
const isSdkEngine = engine.engineType === AgentEngineType.CLAUDE_AGENT_SDK;
const resumeSessionId = isSdkEngine
? (session.metadata as any)?.sdkSessionId as string | undefined
: undefined;
if (resumeSessionId) {
this.logger.log(`[Task ${task.id}] Resuming SDK session: ${resumeSessionId}`);
}
const stream = engine.executeTask({ const stream = engine.executeTask({
sessionId: session.id, sessionId: session.id,
prompt: body.prompt, prompt: body.prompt,
@ -93,6 +104,7 @@ export class AgentController {
allowedTools: body.allowedTools || [], allowedTools: body.allowedTools || [],
maxTurns: body.maxTurns || 10, maxTurns: body.maxTurns || 10,
conversationHistory: historyForEngine.length > 0 ? historyForEngine : undefined, conversationHistory: historyForEngine.length > 0 ? historyForEngine : undefined,
resumeSessionId,
}); });
let eventCount = 0; let eventCount = 0;
@ -123,6 +135,15 @@ export class AgentController {
await this.contextService.saveAssistantMessage(session.id, assistantText); await this.contextService.saveAssistantMessage(session.id, assistantText);
} }
// For SDK engine: persist the SDK session ID for future resume
if (isSdkEngine && engine instanceof ClaudeAgentSdkEngine) {
const newSdkSessionId = engine.getSdkSessionId(session.id);
if (newSdkSessionId) {
session.metadata = { ...session.metadata, sdkSessionId: newSdkSessionId };
this.logger.log(`[Task ${task.id}] Persisted SDK session ID: ${newSdkSessionId}`);
}
}
// Keep session active (don't mark completed) so it can be reused // Keep session active (don't mark completed) so it can be reused
session.updatedAt = new Date(); session.updatedAt = new Date();
await this.sessionRepository.save(session); await this.sessionRepository.save(session);