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:
parent
2403ce5636
commit
cc0f06e2be
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue