diff --git a/docs/deployment/CLOUD_RUN_DEPLOYMENT.md b/docs/deployment/CLOUD_RUN_DEPLOYMENT.md index 16d7be5..8f12058 100644 --- a/docs/deployment/CLOUD_RUN_DEPLOYMENT.md +++ b/docs/deployment/CLOUD_RUN_DEPLOYMENT.md @@ -5,30 +5,36 @@ This guide walks you through deploying Jiva as a stateless, auto-scaling service ## Architecture Overview ``` -┌─────────────┐ -│ Client │ -│ (React UI) │ -└──────┬──────┘ - │ WebSocket/HTTP - ▼ +┌─────────────┐ ┌─────────────┐ +│ Tenant A │ │ Tenant B │ +│ (React UI) │ │ (React UI) │ +└──────┬──────┘ └──────┬──────┘ + │ WebSocket/HTTP │ + ▼ ▼ ┌──────────────────────────────────────┐ │ Cloud Run Service │ │ ┌────────────────────────────────┐ │ │ │ Jiva HTTP/WebSocket Server │ │ -│ │ - Session Manager │ │ -│ │ - DualAgent Instances │ │ -│ │ - StorageProvider │ │ +│ │ - SessionManager │ │ +│ │ ├─ Session(tenantA) │ │ +│ │ │ ├─ ScopedStorageProvider│ │ ← isolated per tenant +│ │ │ ├─ OrchestrationLogger │ │ ← isolated per session +│ │ │ └─ DualAgent │ │ +│ │ └─ Session(tenantB) │ │ +│ │ ├─ ScopedStorageProvider│ │ +│ │ ├─ OrchestrationLogger │ │ +│ │ └─ DualAgent │ │ │ └────────────┬───────────────────┘ │ └───────────────┼──────────────────────┘ │ ▼ - ┌───────────────────────┐ - │ GCS Bucket │ - │ - Conversations │ - │ - Configuration │ - │ - Workspace Files │ - │ - Logs │ - └───────────────────────┘ + ┌───────────────────────────────────┐ + │ GCS Bucket │ + │ {tenantId}/config.json │ ← per-tenant MCP + LLM config + │ {tenantId}/conversations/ │ + │ {tenantId}/directives/ │ + │ {tenantId}/logs/ │ + └───────────────────────────────────┘ ``` ## Prerequisites @@ -138,7 +144,7 @@ Key environment variables are set in `cloud-run.yaml`: | Variable | Description | Default | |----------|-------------|---------| -| `JIVA_STORAGE_PROVIDER` | Storage backend | `gcp` | +| `JIVA_STORAGE_PROVIDER` | Storage backend | `gcp-bucket` | | `JIVA_GCP_BUCKET` | GCS bucket name | `jiva-state-{project}` | | `MAX_CONCURRENT_SESSIONS` | Max sessions per instance | `100` | | `SESSION_IDLE_TIMEOUT_MS` | Session timeout | `1800000` (30 min) | @@ -369,20 +375,67 @@ Common causes: - Configure VPC connector if needed - Use Cloud Armor for DDoS protection +## Multi-Tenant Configuration + +Each tenant's MCP servers, LLM model, and directives are driven by a single JSON file stored in GCS at `{tenantId}/config.json`. The file is read fresh on every new session creation (the in-memory cache is invalidated per tenant). If the file is missing, the service falls back to the server-level defaults from environment variables. + +### Per-Tenant config.json + +Upload to `gs://your-bucket/{tenantId}/config.json`: + +```json +{ + "models": { + "reasoning": { + "endpoint": "https://api.groq.com/openai/v1/chat/completions", + "apiKey": "gsk_...", + "model": "openai/gpt-oss-120b" + }, + "multimodal": null + }, + "mcpServers": [ + { + "name": "tavily-mcp", + "command": "npx", + "args": ["-y", "tavily-mcp@latest"], + "env": { "TAVILY_API_KEY": "tvly-..." } + }, + { + "name": "filesystem", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + ] +} +``` + +```bash +gsutil cp config.json gs://your-bucket/my-tenant/config.json +``` + +### Session Isolation Model + +Every HTTP session gets its own storage provider instance that shares the stateless GCS client and per-tenant config cache, but holds a fixed, immutable context (`tenantId` + `sessionId`). Concurrent requests from different tenants can never corrupt each other's GCS paths. See [release notes v0.3.46](../release_notes/v0.3.46.md) for full details. + +### Per-Tenant Directives + +Store a workspace directive at `{tenantId}/directives/{hash}.md`. It is loaded automatically on session start and shapes agent behaviour for that tenant. Upload via: + +```bash +gsutil cp my-directive.md gs://your-bucket/my-tenant/directives/$(echo -n "$(cat my-directive.md)" | md5sum | cut -d' ' -f1).md +``` + +--- + ## Next Steps 1. **React UI Integration** - See `docs/REACT_INTEGRATION.md` (TODO) - -2. **Custom MCP Servers** - - Add MCP server configs to GCS - - Upload to `gs://bucket/config/mcpServers.json` -3. **Multi-tenancy** - - Configure tenantId extraction from JWT - - Implement per-tenant quotas +2. **Per-Tenant Quotas** + - Implement rate limiting / token budget checks in `SessionManager.getOrCreateSession()` -4. **Monitoring & Alerting** +3. **Monitoring & Alerting** - Setup Cloud Monitoring dashboards - Configure alerting policies diff --git a/docs/guides/CONFIGURATION.md b/docs/guides/CONFIGURATION.md index cb16d97..98b499a 100644 --- a/docs/guides/CONFIGURATION.md +++ b/docs/guides/CONFIGURATION.md @@ -662,18 +662,44 @@ For the full reference see [Code Mode Architecture](../architecture/CODE_MODE.md ## Cloud Run Configuration -For Cloud Run deployments, configuration is loaded dynamically from GCS bucket using the StorageProvider. See [CLOUD_RUN_DEPLOYMENT.md](./CLOUD_RUN_DEPLOYMENT.md) for details. +For Cloud Run deployments, configuration is loaded dynamically from GCS using a per-session scoped `StorageProvider`. See [CLOUD_RUN_DEPLOYMENT.md](../deployment/CLOUD_RUN_DEPLOYMENT.md) for full deployment details. + +### Configuration storage path + +All per-tenant configuration lives in a **single JSON file** per tenant: -Configuration storage path: ``` -gs://your-bucket/{tenantId}/config/models.json -gs://your-bucket/{tenantId}/config/mcpServers.json +gs://your-bucket/{tenantId}/config.json ``` -The SessionManager automatically: -1. Loads config from storage on session creation -2. Falls back to environment variables if not found -3. Saves default config back to storage +The file combines model config and MCP server list — there is no separate `models.json` / `mcpServers.json` split. + +### What SessionManager does on session creation + +1. Calls `storageProvider.createSessionScoped({ tenantId, sessionId })` to get an isolated provider instance with a fixed, immutable GCS path prefix — concurrent tenants can never corrupt each other's paths. +2. Reads `{tenantId}/config.json` from GCS (cache is invalidated per-tenant on each new session). +3. Falls back to server-level environment variables (`JIVA_MODEL_*`, etc.) if no per-tenant config file is found. +4. Saves the resolved config back to `{tenantId}/config.json` so subsequent sessions inherit it. + +### Uploading per-tenant config + +```bash +cat > config.json <<'EOF' +{ + "models": { + "reasoning": { + "endpoint": "https://api.groq.com/openai/v1/chat/completions", + "apiKey": "gsk_...", + "model": "openai/gpt-oss-120b" + } + }, + "mcpServers": [ + { "name": "tavily-mcp", "command": "npx", "args": ["-y", "tavily-mcp@latest"], "env": { "TAVILY_API_KEY": "tvly-..." } } + ] +} +EOF +gsutil cp config.json gs://your-bucket/my-tenant/config.json +``` ### Programmatic Configuration diff --git a/docs/release_notes/v0.3.45.md b/docs/release_notes/v0.3.45.md new file mode 100644 index 0000000..7bea5bc --- /dev/null +++ b/docs/release_notes/v0.3.45.md @@ -0,0 +1,44 @@ +# Jiva v0.3.45 — Windows Compatibility Fixes for Code Mode + +**Release Date:** May 8, 2026 + +--- + +## Bug Fixes + +### Code mode wrote files to `/workspace/` instead of the actual workspace directory on Windows + +**Symptom:** On Windows, Jiva code mode correctly displayed the workspace directory (e.g. `C:\Users\\myproject`) at startup, but then wrote and edited files under `C:\workspace\...` instead. + +**Root cause:** The `read_file` tool description contained hardcoded example paths: +``` +Example: {"path": "/workspace/src/index.ts"} +``` +The LLM pattern-matched against these examples when generating tool calls, producing `/workspace/...` paths rather than paths relative to the actual workspace. On Windows, Node.js treats `/workspace/src/index.ts` as an absolute path (root of the current drive), so `path.isAbsolute()` returned `true` and `workspaceDir` was bypassed entirely. + +**Fix:** The example paths in the `read_file` tool description are now relative (`src/index.ts`), which the tool resolves correctly against the workspace directory on all platforms. (`src/code/tools/read.ts`) + +--- + +### Shell commands failed on Windows due to `cmd.exe` being used instead of PowerShell + +**Symptom:** The `bash` tool executed commands via `cmd.exe` on Windows. Commands using common shell syntax (`&&` chaining, `$VAR` substitution, pipes) failed or produced unexpected results. + +**Fix:** On Windows, the `bash` tool now explicitly uses `powershell.exe` as the shell, which supports the developer workflows (npm, git, build tools) that code mode relies on. (`src/code/tools/bash.ts`) + +--- + +### `grep` results showed Windows-style backslash paths, causing path inconsistency + +**Symptom:** On Windows, `grep` returned match locations like `src\file.ts:5: content` (backslashes), while other tools returned forward-slash paths. This inconsistency could cause the LLM to mix path separator styles across tool calls. + +**Fix:** `grep` output now normalises relative paths to forward slashes on all platforms. (`src/code/tools/grep.ts`) + +--- + +## Upgrade + +```bash +# This bug fix version was only released as a dev release and will be bundled into v0.3.46 +npm install -g jiva-core@0.3.45-dev.53be99d +``` diff --git a/docs/release_notes/v0.3.46.md b/docs/release_notes/v0.3.46.md new file mode 100644 index 0000000..6da832f --- /dev/null +++ b/docs/release_notes/v0.3.46.md @@ -0,0 +1,166 @@ +# Jiva v0.3.46 — Multi-Tenant Concurrency Fixes + +**Release Date:** May 15, 2026 + +--- + +## Summary + +This release fixes a set of concurrency bugs that made the HTTP/Cloud Run interface unsafe for parallel multi-tenant use. The root cause was a shared mutable context on the `StorageProvider` singleton: every HTTP session called `setContext()` on the same object, so concurrent requests from different tenants would corrupt each other's GCS paths. Three related singleton problems in `OrchestrationLogger` and `SessionManager` compounded the issue. + +--- + +## Bug Fixes + +### 1. `JIVA_STORAGE_PROVIDER=gcp` fell through to `LocalStorageProvider` + +**Symptom:** With `JIVA_STORAGE_PROVIDER=gcp` set in Cloud Run, the service silently used `LocalStorageProvider` (ephemeral container filesystem) instead of GCS. Per-tenant MCP server lists, directives, and conversation history were never read from GCS. All GCS diagnostic logs were absent because the `GCPBucketProvider` code never ran. + +**Root cause:** `StorageProviderType.GCP_BUCKET` has the value `'gcp-bucket'`, but the documented/common short form `'gcp'` did not match any switch case, so the factory fell through to the `default: LocalStorageProvider` branch. + +**Fix:** `src/storage/factory.ts` now normalises well-known aliases before the switch: +``` +'gcp' → StorageProviderType.GCP_BUCKET ('gcp-bucket') +'s3' | 'aws' → StorageProviderType.AWS_S3 ('aws-s3') +``` +Both the short and canonical forms are now accepted. The Cloud Run env var `JIVA_STORAGE_PROVIDER` has been updated to `gcp-bucket` for clarity. + +--- + +### 2. Shared `StorageProvider.context` caused cross-tenant GCS path corruption + +**Symptom:** Under concurrent load, tenant A's storage operations (reading config, saving conversations, loading directives) would silently use tenant B's GCS path. Data appeared to go missing or be saved in the wrong location. + +**Root cause:** A single `GCPBucketProvider` instance is created at server startup and shared across all sessions via `SessionConfig.storageProvider`. Its `this.context` field (set by `setContext()`) was overwritten by each new session creation. Because `createSession()` is async and makes several GCS calls, a concurrent `setContext()` call from another tenant could corrupt the path mid-flight. + +**Fix:** Added `StorageProvider.createSessionScoped(context)` — a new method that returns an isolated provider instance with a fixed, immutable context. `GCPBucketProvider` overrides it to share the GCS `Bucket` client (stateless, safe for concurrent use) and the per-tenant `configCache` (a `Map` keyed by `tenantId`, so reads from different tenants are isolated), but gives each session its own `context`, `logBuffer`, and other mutable state. `LocalStorageProvider` overrides it trivially (creates a new instance with the same `basePath`). + +`SessionManager.createSession()` now calls `createSessionScoped()` instead of `setContext()` and stores the returned instance in `ActiveSession.storageProvider`. Every storage operation for that session (config reads, MCP server loading, workspace directive, conversation history, log flushing) goes through this per-session instance. The shared singleton's `context` field is never mutated during request handling. + +**Files changed:** +- `src/storage/provider.ts` — added `createSessionScoped()` +- `src/storage/gcp-bucket-provider.ts` — overrode `createSessionScoped()` +- `src/storage/local-provider.ts` — overrode `createSessionScoped()` +- `src/interfaces/http/session-manager.ts` — uses per-session provider throughout + +--- + +### 3. `GCPBucketProvider.setConfig()` captured context after an `await` + +**Symptom:** Sporadic config writes going to the wrong tenant's GCS path, particularly during session initialisation when model config was being saved for the first time. + +**Root cause:** `setConfig()` called `this.requireContext()` and `this.getConfigPath()` after `await this.loadConfigCache()`. If a concurrent `setContext()` call arrived during that await, the path used for the write reflected the new tenant. + +**Fix:** Context and config path are now captured at the very top of `setConfig()`, before the first `await`. (`src/storage/gcp-bucket-provider.ts`) + +--- + +### 4. `SessionManager.getOrCreateSession()` had a TOCTOU race creating duplicate sessions + +**Symptom:** Under concurrent requests for the same `(tenantId, sessionId)` — e.g. two simultaneous HTTP requests arriving on a cold-start session — two separate `DualAgent` instances and two sets of MCP sub-processes could be created for the same session key. The second one silently overwrote the first in the sessions map, leaking the sub-processes. + +**Fix:** `SessionManager` now maintains a `pendingSessions: Map>` alongside the `sessions` map. When a creation is in progress, subsequent concurrent callers await the same `Promise` instead of starting a second creation. The pending entry is removed (in a `finally` block) whether creation succeeds or fails. (`src/interfaces/http/session-manager.ts`) + +--- + +### 5. `SessionManager.destroySession()` saved conversation to the wrong tenant path + +**Symptom:** When a session was destroyed (idle timeout or graceful shutdown), `saveConversation()` and `flushLogs()` were called on the shared singleton provider, which held whatever context the most recently created session had set. Under concurrent load, conversations could be saved to the wrong tenant. + +**Fix:** `destroySession()` now retrieves `session.storageProvider` (the session-scoped instance stored in `ActiveSession`) and uses it for all teardown writes. (`src/interfaces/http/session-manager.ts`) + +--- + +### 6. `OrchestrationLogger` singleton mixed events across concurrent sessions + +**Symptom:** All Manager/Worker/Client orchestration events from all concurrent sessions were logged to the most-recently-registered session's GCS path. Debug logs from tenant A appeared in tenant B's orchestration log. + +**Root cause:** `OrchestrationLogger` was a strict singleton. `setStorageProvider(provider, sessionId)` mutated shared `this.storageProvider` and `this.sessionId` fields; concurrent sessions overwrote each other. + +**Fix:** The `private` constructor restriction is removed. The class now accepts optional `(storageProvider, sessionId)` constructor arguments: when both are provided it enters cloud mode immediately (no filesystem log file); when omitted it falls back to CLI mode (filesystem log in `~/.jiva/logs/`). `static getInstance()` and the module-level `orchestrationLogger` singleton export are kept for CLI backward compatibility. + +`DualAgentConfig` gains an optional `orchestrationLogger?: OrchestrationLogger` field. `SessionManager.createSession()` instantiates a fresh `new OrchestrationLogger(storageProvider, sessionId)` and passes it to `DualAgent` via this field. `DualAgent` stores it as `this.orchLogger` and passes it down to `ManagerAgent`, `WorkerAgent`, and `ClientAgent` constructors (each accepts an optional last parameter, defaulting to the singleton for CLI use). All `orchestrationLogger.logXxx()` calls in agent code became `this.orchLogger.logXxx()`. The deprecated `setStorageProvider()` method is kept (marked deprecated) so any existing integrations continue to compile. + +### 7. Additional bug fixes from v0.3.45 +The dev release v0.3.45 included additional bug fixes as documented in [v0.3.45](v0.3.45.md) + +**Files changed:** +- `src/utils/orchestration-logger.ts` +- `src/core/dual-agent.ts` +- `src/core/manager-agent.ts` +- `src/core/worker-agent.ts` +- `src/core/client-agent.ts` +- `src/interfaces/http/session-manager.ts` + +--- + +## Architecture Changes + +### Per-session provider isolation model + +``` +bootstrap() + └─ createStorageProvider() ← one shared GCPBucketProvider (parent) + └─ shares: Bucket client, configCache Map + +getOrCreateSession(tenantA, sessionX) + └─ parent.createSessionScoped({tenantA, sessionX}) + └─ returns new GCPBucketProvider + context = {tenantA, sessionX} ← fixed, never changes + bucket = parent.bucket ← shared (stateless) + configCache = parent.configCache ← shared (keyed by tenantId) + logBuffer = [] ← own + └─ stored as ActiveSession.storageProvider + └─ new OrchestrationLogger(provider, sessionX) + └─ stored as ActiveSession.orchestrationLogger + └─ DualAgent({ orchestrationLogger: session.orchestrationLogger }) + └─ ManagerAgent(..., orchLogger) + └─ WorkerAgent(..., orchLogger) + +destroySession(tenantA, sessionX) + └─ session.storageProvider.saveConversation(...) ← always tenantA path + └─ session.orchestrationLogger.flush() ← always sessionX buffer +``` + +### Backward compatibility + +- **CLI mode**: unchanged. `orchestrationLogger` singleton continues to work. `DualAgent` constructed without `orchestrationLogger` in config falls back to singleton. +- **`JIVA_STORAGE_PROVIDER=gcp`**: still accepted (now aliased). `gcp-bucket` is the preferred value. +- All public APIs (`StorageProvider`, `GCPBucketProvider`, agent constructors) are additive changes only — no removals. + +--- + +## Upgrade + +```bash +npm install -g jiva-core@0.3.46 +``` + +### Cloud Run: update `JIVA_STORAGE_PROVIDER` + +The environment variable value `gcp` now works correctly (it is aliased), but update to the canonical value for clarity: + +```yaml +- name: JIVA_STORAGE_PROVIDER + value: gcp-bucket # was: gcp (still works, but gcp-bucket is canonical) +``` + +### Cloud Run: per-tenant MCP and LLM config + +Each tenant's configuration lives at `gs://{bucket}/{tenantId}/config.json` and is now guaranteed to be read from GCS on every new session (the in-memory cache is invalidated via `setContext()` override). To configure per-tenant MCP servers or a different LLM, upload: + +```json +{ + "models": { + "reasoning": { "endpoint": "...", "apiKey": "...", "model": "..." }, + "multimodal": null + }, + "mcpServers": [ + { "name": "tavily-mcp", "command": "npx", "args": ["-y", "tavily-mcp@latest"], "env": { "TAVILY_API_KEY": "..." } } + ] +} +``` + +```bash +gsutil cp config.json gs://your-bucket/my-tenant/config.json +``` diff --git a/package-lock.json b/package-lock.json index 4aa0e77..cbc3d3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jiva-core", - "version": "0.3.43", + "version": "0.3.46", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jiva-core", - "version": "0.3.43", + "version": "0.3.46", "license": "MIT", "dependencies": { "@google-cloud/storage": "^7.0.0", diff --git a/package.json b/package.json index 392d918..22d83bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jiva-core", - "version": "0.3.44", + "version": "0.3.46", "description": "Versatile autonomous AI agent with three-agent architecture (Manager, Worker, Client) powered by gpt-oss-120b. Adaptive validation, full MCP support, and intelligent quality control.", "main": "dist/index.js", "bin": { diff --git a/src/code/tools/bash.ts b/src/code/tools/bash.ts index 601c339..2e86ba3 100644 --- a/src/code/tools/bash.ts +++ b/src/code/tools/bash.ts @@ -47,6 +47,7 @@ Output is truncated at ${MAX_OUTPUT_LINES} lines / ${MAX_OUTPUT_BYTES / 1024}KB. async execute(args, ctx: CodeToolContext): Promise { const command = args.command as string; const timeout = Math.min((args.timeout_ms as number) || DEFAULT_TIMEOUT, MAX_TIMEOUT); + const isWindows = process.platform === 'win32'; return new Promise((resolve) => { const proc = exec(command, { @@ -54,6 +55,7 @@ Output is truncated at ${MAX_OUTPUT_LINES} lines / ${MAX_OUTPUT_BYTES / 1024}KB. timeout, maxBuffer: 10 * 1024 * 1024, // 10MB raw buffer; we truncate before returning env: { ...process.env }, + ...(isWindows && { shell: 'powershell.exe' }), }, (error, stdout, stderr) => { const output: string[] = []; diff --git a/src/code/tools/grep.ts b/src/code/tools/grep.ts index 3d7fbbe..0913c49 100644 --- a/src/code/tools/grep.ts +++ b/src/code/tools/grep.ts @@ -113,7 +113,7 @@ function searchFile( let content: string; try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return; } - const relPath = path.relative(workspaceDir, filePath); + const relPath = path.relative(workspaceDir, filePath).replace(/\\/g, '/'); const lines = content.split('\n'); for (let i = 0; i < lines.length && results.length < opts.max; i++) { diff --git a/src/code/tools/read.ts b/src/code/tools/read.ts index cdc6b9a..ab22848 100644 --- a/src/code/tools/read.ts +++ b/src/code/tools/read.ts @@ -10,8 +10,8 @@ export const ReadFileTool: ICodeTool = { description: `Read the contents of a file or list a directory. REQUIRED parameter: "path" (string) — absolute path to file or directory. -Example: {"path": "/workspace/src/index.ts"} -Example: {"path": "/workspace/src/index.ts", "offset": 100, "limit": 50} +Example: {"path": "src/index.ts"} +Example: {"path": "src/index.ts", "offset": 100, "limit": 50} Do NOT pass "command", "query", or "pattern" to this tool — use bash or grep for those. diff --git a/src/core/client-agent.ts b/src/core/client-agent.ts index 9dfea6b..1338a46 100644 --- a/src/core/client-agent.ts +++ b/src/core/client-agent.ts @@ -16,7 +16,7 @@ import { AgentContext } from './types/agent-context.js'; import { CompletionSignal } from './types/completion-signal.js'; import { serializeAgentContext } from './utils/serialize-agent-context.js'; import { logger } from '../utils/logger.js'; -import { orchestrationLogger } from '../utils/orchestration-logger.js'; +import { OrchestrationLogger, orchestrationLogger } from '../utils/orchestration-logger.js'; import { MCPClient } from '../mcp/client.js'; import { WorkerResult } from './worker-agent.js'; @@ -71,14 +71,20 @@ export class ClientAgent { private mcpManager: MCPServerManager; private mcpClient: MCPClient; private failureCount: number = 0; + private readonly orchLogger: OrchestrationLogger; // Lazily cached list of all available tool names (populated on first use) private _availableTools: string[] | null = null; - constructor(orchestrator: ModelOrchestrator, mcpManager: MCPServerManager) { + constructor( + orchestrator: ModelOrchestrator, + mcpManager: MCPServerManager, + orchLogger?: OrchestrationLogger, + ) { this.orchestrator = orchestrator; this.mcpManager = mcpManager; this.mcpClient = mcpManager.getClient(); + this.orchLogger = orchLogger ?? orchestrationLogger; } // ─── Tool Discovery ─────────────────────────────────────────────────────── @@ -293,7 +299,7 @@ CRITICAL RULES for requirements: logger.info(`[Client] Validating with ${level.toUpperCase()} involvement`); // Log the analysis for orchestration tracing - orchestrationLogger.logClientAnalysis( + this.orchLogger.logClientAnalysis( level, requirements.length, `Requirements: ${requirements.map(r => r.type).join(', ')}` ); @@ -315,7 +321,7 @@ CRITICAL RULES for requirements: // Layer 0.5: Result-vs-Evidence Coherence Check (always done, catches hallucinated accomplishments) // This detects when the Worker claims to have done things its tool usage doesn't support const coherenceAnalysis = await this.analyzeResultCoherence(userMessage, workerResult, agentContext); - orchestrationLogger.logClientCoherenceCheck( + this.orchLogger.logClientCoherenceCheck( coherenceAnalysis.isCoherent, coherenceAnalysis.unsupportedClaims, coherenceAnalysis.reasoning @@ -371,7 +377,7 @@ CRITICAL RULES for requirements: ); // Log the validation outcome - orchestrationLogger.logClientValidation( + this.orchLogger.logClientValidation( result.approved, result.issues, result.nextAction ); diff --git a/src/core/dual-agent.ts b/src/core/dual-agent.ts index 4b55f03..f62012d 100644 --- a/src/core/dual-agent.ts +++ b/src/core/dual-agent.ts @@ -17,7 +17,7 @@ import { WorkerAgent } from './worker-agent.js'; import { AgentContext } from './types/agent-context.js'; import { serializeAgentContext } from './utils/serialize-agent-context.js'; import { logger } from '../utils/logger.js'; -import { orchestrationLogger } from '../utils/orchestration-logger.js'; +import { OrchestrationLogger, orchestrationLogger } from '../utils/orchestration-logger.js'; import { Message } from '../models/base.js'; export interface DualAgentConfig { @@ -43,6 +43,12 @@ export interface DualAgentConfig { */ maxMessagesBeforeCondense?: number; maxToolCalls?: number; // Maximum tool calls per subtask + /** + * Per-session OrchestrationLogger instance. When provided (HTTP mode) each + * session owns its own logger so events never cross-contaminate between + * tenants. Omit to use the module-level singleton (CLI mode). + */ + orchestrationLogger?: OrchestrationLogger; } export interface DualAgentResponse { @@ -74,6 +80,7 @@ export class DualAgent { private condensingThreshold: number; private compactionThreshold: number; private maxMessagesBeforeCondense: number; + private readonly orchLogger: OrchestrationLogger; private userConversationHistory: Message[] = []; private _stopped = false; @@ -96,9 +103,13 @@ export class DualAgent { logger.warn('[DualAgent] condensingThreshold is deprecated — use compactionThreshold + maxMessagesBeforeCondense instead'); } + // Use the per-session logger when running under the HTTP server, fall back + // to the module-level singleton for CLI (backward compatible). + this.orchLogger = config.orchestrationLogger ?? orchestrationLogger; + // Initialize agents (Manager + Worker architecture) - this.manager = new ManagerAgent(this.orchestrator, this.workspace, this.personaManager || undefined); - this.worker = new WorkerAgent(this.orchestrator, this.mcpManager, this.workspace, this.maxIterations, this.personaManager || undefined); + this.manager = new ManagerAgent(this.orchestrator, this.workspace, this.personaManager || undefined, this.orchLogger); + this.worker = new WorkerAgent(this.orchestrator, this.mcpManager, this.workspace, this.maxIterations, this.personaManager || undefined, this.orchLogger); // Initialize AgentSpawner - always available as a baseline tool // Create a PersonaManager if one wasn't provided @@ -239,7 +250,7 @@ VALIDATION GUIDANCE: logger.info(`>> User: ${userMessage}`); logger.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - orchestrationLogger.logUserMessage(userMessage); + this.orchLogger.logUserMessage(userMessage); // Check if conversation needs condensing BEFORE adding new message. // Token-based trigger: compacts when the last prompt was close to the context limit. @@ -284,14 +295,14 @@ VALIDATION GUIDANCE: logger.info('─────────────────────────────────────────'); const phaseStartTime = Date.now(); - orchestrationLogger.logPhaseStart('PLANNING'); + this.orchLogger.logPhaseStart('PLANNING'); const plan = await this.manager.createPlan({ userRequest: userMessage, context: serializeAgentContext(agentContext, 'manager'), }, agentContext); - orchestrationLogger.logPhaseEnd('PLANNING', Date.now() - phaseStartTime); + this.orchLogger.logPhaseEnd('PLANNING', Date.now() - phaseStartTime); // Conversational short-circuit: skip execution entirely for greetings, thank-yous, etc. if (plan.conversational) { @@ -331,7 +342,7 @@ VALIDATION GUIDANCE: logger.info('─────────────────────────────────────────'); const executionStartTime = Date.now(); - orchestrationLogger.logPhaseStart('EXECUTION'); + this.orchLogger.logPhaseStart('EXECUTION'); const results: { subtask: string; result: string; accepted: boolean }[] = []; const subtasksToExecute = plan.subtasks.slice(0, this.maxSubtasks); @@ -387,19 +398,19 @@ VALIDATION GUIDANCE: } } - orchestrationLogger.logPhaseEnd('EXECUTION', Date.now() - executionStartTime); + this.orchLogger.logPhaseEnd('EXECUTION', Date.now() - executionStartTime); // PHASE 3: Synthesize final response logger.info('\n[PHASE 3: Synthesis]'); logger.info('─────────────────────────────────────────'); const synthesisStartTime = Date.now(); - orchestrationLogger.logPhaseStart('SYNTHESIS'); + this.orchLogger.logPhaseStart('SYNTHESIS'); const finalResponse = await this.synthesizeResponse(plan, results, agentContext); totalIterations += 1; - orchestrationLogger.logPhaseEnd('SYNTHESIS', Date.now() - synthesisStartTime); + this.orchLogger.logPhaseEnd('SYNTHESIS', Date.now() - synthesisStartTime); // Add assistant response to user conversation history this.userConversationHistory.push({ @@ -423,7 +434,7 @@ VALIDATION GUIDANCE: logger.info(`[+] Complete: ${totalIterations} iterations, ${allToolsUsed.length} tools used`); logger.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); - orchestrationLogger.logFinalResponse(finalResponse, totalIterations, allToolsUsed); + this.orchLogger.logFinalResponse(finalResponse, totalIterations, allToolsUsed); return { content: finalResponse, diff --git a/src/core/manager-agent.ts b/src/core/manager-agent.ts index 5ed34c4..9daee9c 100644 --- a/src/core/manager-agent.ts +++ b/src/core/manager-agent.ts @@ -17,7 +17,7 @@ import { AgentContext } from './types/agent-context.js'; import { WorkerResult } from './worker-agent.js'; import { Message } from '../models/base.js'; import { logger } from '../utils/logger.js'; -import { orchestrationLogger } from '../utils/orchestration-logger.js'; +import { OrchestrationLogger, orchestrationLogger } from '../utils/orchestration-logger.js'; export interface ManagerTask { userRequest: string; @@ -42,11 +42,18 @@ export class ManagerAgent { private workspace: WorkspaceManager; private personaManager?: PersonaManager; private conversationHistory: Message[] = []; - - constructor(orchestrator: ModelOrchestrator, workspace: WorkspaceManager, personaManager?: PersonaManager) { + private readonly orchLogger: OrchestrationLogger; + + constructor( + orchestrator: ModelOrchestrator, + workspace: WorkspaceManager, + personaManager?: PersonaManager, + orchLogger?: OrchestrationLogger, + ) { this.orchestrator = orchestrator; this.workspace = workspace; this.personaManager = personaManager; + this.orchLogger = orchLogger ?? orchestrationLogger; // Set persona context for logging if (personaManager) { @@ -135,7 +142,7 @@ IMPORTANT: */ async createPlan(task: ManagerTask, agentContext?: AgentContext): Promise { logger.info('[Manager] Creating plan...'); - orchestrationLogger.logManagerCreatePlan(task.userRequest, task.context || ''); + this.orchLogger.logManagerCreatePlan(task.userRequest, task.context || ''); const planPrompt = `User Request: ${task.userRequest} ${task.context ? `\nContext: ${task.context}` : ''} @@ -219,7 +226,7 @@ Respond ONLY with valid JSON in this exact format (no other text before or after plan.subtasks.forEach((task, i) => logger.info(` ${i + 1}. ${task}`)); } - orchestrationLogger.logManagerPlanCreated(plan.subtasks, plan.reasoning); + this.orchLogger.logManagerPlanCreated(plan.subtasks, plan.reasoning); return { subtasks: plan.subtasks, reasoning: plan.reasoning, conversational: isConversational }; } @@ -229,7 +236,7 @@ Respond ONLY with valid JSON in this exact format (no other text before or after */ async reviewResults(subtask: string, workerResult: string): Promise { logger.info(`[Manager] Reviewing: "${subtask}"`); - orchestrationLogger.logManagerReview(subtask, workerResult); + this.orchLogger.logManagerReview(subtask, workerResult); const reviewPrompt = `The Worker completed this subtask: Subtask: ${subtask} @@ -270,7 +277,7 @@ NEXT_ACTION: `; logger.info(`[Manager] Decision: ${decision}`); const isComplete = decision.toUpperCase().includes('COMPLETE'); - orchestrationLogger.logManagerDecision(isComplete, reasoning, nextAction || undefined); + this.orchLogger.logManagerDecision(isComplete, reasoning, nextAction || undefined); return { isComplete, @@ -354,7 +361,7 @@ REJECT: `; */ async synthesizeResponse(allResults: { subtask: string; result: string; accepted?: boolean }[], agentContext?: AgentContext): Promise { logger.info('[Manager] Synthesizing final response...'); - orchestrationLogger.logManagerSynthesize(allResults.length); + this.orchLogger.logManagerSynthesize(allResults.length); const acceptedResults = allResults.filter(r => r.accepted !== false); const failedResults = allResults.filter(r => r.accepted === false); diff --git a/src/core/worker-agent.ts b/src/core/worker-agent.ts index fb159e7..c59b253 100644 --- a/src/core/worker-agent.ts +++ b/src/core/worker-agent.ts @@ -17,7 +17,7 @@ import { AgentContext } from './types/agent-context.js'; import { Message, MessageContent, ModelResponse, Tool } from '../models/base.js'; import { formatToolResult } from '../models/harmony.js'; import { logger } from '../utils/logger.js'; -import { orchestrationLogger } from '../utils/orchestration-logger.js'; +import { OrchestrationLogger, orchestrationLogger } from '../utils/orchestration-logger.js'; /** Max consecutive empty responses before breaking out */ const MAX_EMPTY_RESPONSES = 4; @@ -101,19 +101,22 @@ export class WorkerAgent { private agentSpawner?: AgentSpawner; private maxIterations: number; private contextMemory: WorkerContextMemory; + private readonly orchLogger: OrchestrationLogger; constructor( orchestrator: ModelOrchestrator, mcpManager: MCPServerManager, workspace: WorkspaceManager, maxIterations: number = 20, - personaManager?: PersonaManager + personaManager?: PersonaManager, + orchLogger?: OrchestrationLogger, ) { this.orchestrator = orchestrator; this.mcpManager = mcpManager; this.workspace = workspace; this.personaManager = personaManager; this.maxIterations = maxIterations; + this.orchLogger = orchLogger ?? orchestrationLogger; this.contextMemory = { recentFileReads: new Map(), filesJustModified: new Set(), @@ -140,7 +143,7 @@ export class WorkerAgent { */ async executeSubtask(subtask: WorkerSubtask, agentContext?: AgentContext): Promise { logger.info(`[Worker] Starting: "${subtask.instruction}"`); - orchestrationLogger.logWorkerStart(subtask.instruction, subtask.context || ''); + this.orchLogger.logWorkerStart(subtask.instruction, subtask.context || ''); // Reset context memory for new subtask this.contextMemory = { @@ -291,7 +294,7 @@ Please complete this subtask and report your findings.`, for (let iteration = 0; iteration < this.maxIterations; iteration++) { iterationCount = iteration + 1; logger.debug(` [Worker] Iteration ${iteration + 1}/${this.maxIterations}`); - orchestrationLogger.logWorkerIteration(iteration + 1, this.maxIterations); + this.orchLogger.logWorkerIteration(iteration + 1, this.maxIterations); // Two-phase nudges: keep-going at 70%, wrap-up at 90% const iterPct = iteration / this.maxIterations; @@ -668,7 +671,7 @@ Please complete this subtask and report your findings.`, } try { - orchestrationLogger.logWorkerToolCall(toolName, args); + this.orchLogger.logWorkerToolCall(toolName, args); // Handle spawn_agent specially if (toolName === 'spawn_agent') { @@ -694,7 +697,7 @@ ${spawnResult.result} Iterations: ${spawnResult.iterations} Tools used: ${spawnResult.toolsUsed.join(', ')}`; - orchestrationLogger.logWorkerToolResult(toolName, true, false); + this.orchLogger.logWorkerToolResult(toolName, true, false); const toolMessage = formatToolResult(toolCall.id, toolName, resultText); conversationHistory.push(toolMessage); @@ -735,7 +738,7 @@ Tools used: ${spawnResult.toolsUsed.join(', ')}`; toolResultText = typeof result === 'string' ? result : JSON.stringify(result); } - orchestrationLogger.logWorkerToolResult(toolName, true, hasImages); + this.orchLogger.logWorkerToolResult(toolName, true, hasImages); const toolMessage = formatToolResult(toolCall.id, toolName, toolResultText); conversationHistory.push(toolMessage); @@ -754,7 +757,7 @@ Tools used: ${spawnResult.toolsUsed.join(', ')}`; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); logger.error(` ✗ [Worker] Tool ${toolName} failed:`, error); - orchestrationLogger.logWorkerToolResult(toolName, false, false); + this.orchLogger.logWorkerToolResult(toolName, false, false); // Always push the raw tool error so the LLM sees what happened conversationHistory.push({ @@ -968,7 +971,7 @@ Tools used: ${spawnResult.toolsUsed.join(', ')}`; !finalResult.includes('encountered errors') && !finalResult.includes('Max iterations reached') && !finalResult.includes('Validation required'); - orchestrationLogger.logWorkerComplete(success, toolsUsed, iterationCount); + this.orchLogger.logWorkerComplete(success, toolsUsed, iterationCount); return { success, diff --git a/src/interfaces/http/session-manager.ts b/src/interfaces/http/session-manager.ts index b4d01d0..730c764 100644 --- a/src/interfaces/http/session-manager.ts +++ b/src/interfaces/http/session-manager.ts @@ -20,7 +20,7 @@ import { WorkspaceManager } from '../../core/workspace.js'; import { ConversationManager } from '../../core/conversation-manager.js'; import { StorageProvider } from '../../storage/provider.js'; import { logger } from '../../utils/logger.js'; -import { orchestrationLogger } from '../../utils/orchestration-logger.js'; +import { OrchestrationLogger } from '../../utils/orchestration-logger.js'; import { createModelClient } from '../../models/model-client.js'; import { Message } from '../../models/base.js'; import { PersonaManager } from '../../personas/persona-manager.js'; @@ -48,12 +48,19 @@ interface ActiveSession { workspace: WorkspaceManager; conversationManager: ConversationManager; personaManager: PersonaManager; + /** Session-scoped storage provider — has a fixed (tenantId, sessionId) context + * and does NOT share mutable state with other sessions. */ + storageProvider: StorageProvider; + /** Per-session orchestration logger — never shared across tenants. */ + orchestrationLogger: OrchestrationLogger; info: SessionInfo; idleTimer?: NodeJS.Timeout; } export class SessionManager extends EventEmitter { private sessions: Map = new Map(); + /** Deduplicates concurrent creation requests for the same session key. */ + private pendingSessions: Map> = new Map(); private config: SessionConfig; constructor(config: SessionConfig) { @@ -63,12 +70,16 @@ export class SessionManager extends EventEmitter { } /** - * Get or create a session + * Get or create a session. + * + * Concurrent calls for the same (tenantId, sessionId) are deduplicated: + * the second caller awaits the same creation Promise so only one session + * object (and its MCP sub-processes) is ever created per key. */ async getOrCreateSession(tenantId: string, sessionId: string): Promise { const key = this.getSessionKey(tenantId, sessionId); - // Return existing session + // Fast path — session already active if (this.sessions.has(key)) { const session = this.sessions.get(key)!; this.resetIdleTimer(key); @@ -78,29 +89,41 @@ export class SessionManager extends EventEmitter { return session.agent; } + // Deduplication — a concurrent request is already creating this session + if (this.pendingSessions.has(key)) { + logger.debug(`[SessionManager] Awaiting pending session creation: ${key}`); + const session = await this.pendingSessions.get(key)!; + return session.agent; + } + // Check session limit if (this.sessions.size >= this.config.maxConcurrentSessions) { - // Try to clean up idle sessions await this.cleanupIdleSessions(); - if (this.sessions.size >= this.config.maxConcurrentSessions) { throw new Error(`Maximum concurrent sessions reached (${this.config.maxConcurrentSessions})`); } } - // Create new session + // Create new session; register the Promise before awaiting so concurrent + // callers that arrive while we await find it in pendingSessions. logger.info(`[SessionManager] Creating session: ${key}`); const codeModeEnabled = process.env.JIVA_CODE_MODE === 'true'; - const session = await this.createSession(tenantId, sessionId, codeModeEnabled); - this.sessions.set(key, session); - this.resetIdleTimer(key); + const creationPromise = this.createSession(tenantId, sessionId, codeModeEnabled); + this.pendingSessions.set(key, creationPromise); + + try { + const session = await creationPromise; + this.sessions.set(key, session); + this.resetIdleTimer(key); - // Configure logger and orchestration logger for this session - logger.setSessionId(sessionId); - orchestrationLogger.setStorageProvider(this.config.storageProvider, sessionId); + logger.setSessionId(sessionId); - this.emit('sessionCreated', { tenantId, sessionId }); - return session.agent; + this.emit('sessionCreated', { tenantId, sessionId }); + return session.agent; + } finally { + // Always remove from pending, even on error + this.pendingSessions.delete(key); + } } /** @@ -117,11 +140,21 @@ export class SessionManager extends EventEmitter { }; try { - // Set storage context - this.config.storageProvider.setContext({ tenantId, sessionId }); + // Obtain a session-scoped storage provider. This is a new, isolated + // instance whose context (tenantId / sessionId) is fixed for the lifetime + // of this session. It shares the underlying GCS bucket connection and + // per-tenant config cache with the singleton parent, but has its own + // mutable state (logBuffer, context), so concurrent sessions from + // different tenants never clobber each other's GCS paths. + const storageProvider = this.config.storageProvider.createSessionScoped({ tenantId, sessionId }); + + // Per-session orchestration logger. Instantiated here (not via the + // module-level singleton) so every session writes to its own buffer and + // GCS path — concurrent tenants no longer cross-contaminate log output. + const orchLogger = new OrchestrationLogger(storageProvider, sessionId); // Load or create config - let modelConfig = await this.config.storageProvider.getConfig<{ + let modelConfig = await storageProvider.getConfig<{ reasoning: { provider: string; apiKey: string; @@ -147,7 +180,7 @@ export class SessionManager extends EventEmitter { }, multimodal: null, // Optional }; - await this.config.storageProvider.setConfig('models', modelConfig); + await storageProvider.setConfig('models', modelConfig); } else { // Override stored config with environment variables if present if (envEndpoint) modelConfig.reasoning.endpoint = envEndpoint; @@ -215,7 +248,7 @@ export class SessionManager extends EventEmitter { } // Load MCP server config from storage - const mcpConfig = await this.config.storageProvider.getConfig 0) { const tokenUsage = session.agent.getTokenUsage(); - await this.config.storageProvider.saveConversation({ + await storageProvider.saveConversation({ metadata: { id: sessionId, created: session.info.createdAt.toISOString(), @@ -356,11 +397,11 @@ export class SessionManager extends EventEmitter { logger.debug(`[SessionManager] Persisted ${conversationHistory.length} messages`); } - // Flush orchestration logs - await orchestrationLogger.flush(); + // Flush orchestration logs for this session + await orchLogger.flush(); // Flush structured logs - await this.config.storageProvider.flushLogs(); + await storageProvider.flushLogs(); // Clean up session-specific logger context logger.clearSessionContext(sessionId); diff --git a/src/storage/factory.ts b/src/storage/factory.ts index 61b0334..c84a6b3 100644 --- a/src/storage/factory.ts +++ b/src/storage/factory.ts @@ -60,6 +60,9 @@ function determineProviderType(infraConfig?: StorageInfraConfig): StorageProvide // Explicit environment variable takes precedence const envProvider = process.env[ENV_VARS.PROVIDER]; if (envProvider) { + // Normalise common aliases so ops can use short names in Cloud Run env vars + if (envProvider === 'gcp') return StorageProviderType.GCP_BUCKET; + if (envProvider === 's3' || envProvider === 'aws') return StorageProviderType.AWS_S3; return envProvider as StorageProviderType; } diff --git a/src/storage/gcp-bucket-provider.ts b/src/storage/gcp-bucket-provider.ts index 1c67a6c..fd72664 100644 --- a/src/storage/gcp-bucket-provider.ts +++ b/src/storage/gcp-bucket-provider.ts @@ -20,6 +20,7 @@ */ import { StorageProvider } from './provider.js'; +import { logger } from '../utils/logger.js'; import { StorageInfraConfig, SavedConversation, @@ -77,15 +78,20 @@ export class GCPBucketProvider extends StorageProvider { private async readJson(path: string): Promise { if (!this.bucket) throw new Error('Provider not initialized'); - + try { const file = this.bucket.file(path); const [exists] = await file.exists(); - if (!exists) return null; - + if (!exists) { + logger.debug(`GCS readJson: file not found: ${path}`); + return null; + } const [content] = await file.download(); - return JSON.parse(content.toString('utf-8')); + const parsed = JSON.parse(content.toString('utf-8')); + logger.debug(`GCS readJson: loaded ${path}`); + return parsed; } catch (error) { + logger.warn(`GCS readJson error for ${path}: ${error instanceof Error ? error.message : String(error)}`); return null; } } @@ -110,15 +116,20 @@ export class GCPBucketProvider extends StorageProvider { private async readText(path: string): Promise { if (!this.bucket) throw new Error('Provider not initialized'); - + try { const file = this.bucket.file(path); const [exists] = await file.exists(); - if (!exists) return null; - + if (!exists) { + logger.debug(`GCS readText: file not found: ${path}`); + return null; + } const [content] = await file.download(); - return content.toString('utf-8'); + const text = content.toString('utf-8'); + logger.debug(`GCS readText: read ${text.length} chars from ${path}`); + return text; } catch (error) { + logger.warn(`GCS readText error for ${path}: ${error instanceof Error ? error.message : String(error)}`); return null; } } @@ -144,21 +155,66 @@ export class GCPBucketProvider extends StorageProvider { return createHash('sha256').update(workspacePath).digest('hex').substring(0, 16); } + // ───────────────────────────────────────────────────────────── + // Session scoping — return an isolated provider per HTTP session + // ───────────────────────────────────────────────────────────── + + /** + * Returns a new GCPBucketProvider instance that shares the underlying GCS + * bucket connection and the per-tenant config cache with this parent, but has + * its own immutable context (tenantId / sessionId). + * + * This means concurrent sessions from different tenants each read/write their + * own GCS paths without ever touching this.context on the shared singleton. + */ + override createSessionScoped(context: import('./types.js').StorageContext): GCPBucketProvider { + const scoped = new GCPBucketProvider(this.infraConfig); + // Share the GCS Bucket client — it is stateless and safe for concurrent use + scoped.bucket = this.bucket; + // Share the config cache — it is keyed by tenantId, so reads/writes from + // different tenants are naturally isolated. Same-tenant concurrent writes + // are idempotent (same GCS source → same value). + scoped.configCache = this.configCache; + scoped.initialized = true; + scoped.setContext(context); + return scoped; + } + + // ───────────────────────────────────────────────────────────── + // Context override — invalidate config cache on context change + // ───────────────────────────────────────────────────────────── + + override setContext(context: import('./types.js').StorageContext): void { + super.setContext(context); + // Invalidate config cache whenever a new context is set so that + // updated GCS config (e.g. new mcpServers) is always picked up + // on the next session rather than serving a stale in-memory copy. + const hadCache = this.configCache.has(context.tenantId); + this.configCache.delete(context.tenantId); + // Use process.stderr to bypass logger for guaranteed output + process.stderr.write(`[GCS-DIAG] setContext called: tenant=${context.tenantId}, hadCache=${hadCache}\n`); + logger.info(`[GCS] setContext: tenant=${context.tenantId}, cacheInvalidated=${hadCache}`); + } + // ───────────────────────────────────────────────────────────── // Configuration (per-tenant, cached) // ───────────────────────────────────────────────────────────── private async loadConfigCache(): Promise> { const ctx = this.requireContext(); - + // Check memory cache first if (this.configCache.has(ctx.tenantId)) { + logger.info(`[GCS] loadConfigCache: using cached config for ${ctx.tenantId}`); return this.configCache.get(ctx.tenantId)!; } - + // Load from storage const configPath = this.getConfigPath(); + logger.info(`[GCS] loadConfigCache: reading from GCS: ${configPath}`); const config = (await this.readJson>(configPath)) || {}; + const keys = Object.keys(config); + logger.info(`[GCS] loadConfigCache: loaded keys=[${keys.join(',')}] from ${configPath}`); this.configCache.set(ctx.tenantId, config); return config; } @@ -169,12 +225,15 @@ export class GCPBucketProvider extends StorageProvider { } async setConfig(key: string, value: T): Promise { + // Capture context and derived paths BEFORE any await so that a concurrent + // setContext() call on a shared parent instance cannot corrupt them. + const ctx = this.requireContext(); + const configPath = this.getConfigPath(); + const config = await this.loadConfigCache(); config[key] = value; - - const ctx = this.requireContext(); this.configCache.set(ctx.tenantId, config); - await this.writeJson(this.getConfigPath(), config); + await this.writeJson(configPath, config); } async getAllConfig(): Promise> { diff --git a/src/storage/local-provider.ts b/src/storage/local-provider.ts index b2211af..c179933 100644 --- a/src/storage/local-provider.ts +++ b/src/storage/local-provider.ts @@ -59,6 +59,19 @@ export class LocalStorageProvider extends StorageProvider { this.initialized = true; } + /** + * For local storage each session is cheap to create — just fork a new + * instance with the same basePath and a fixed context. The in-memory + * configCache is not shared intentionally: local provider is single-session + * (CLI), so sharing is unnecessary and isolation keeps the semantics clean. + */ + override createSessionScoped(context: import('./types.js').StorageContext): LocalStorageProvider { + const scoped = new LocalStorageProvider(this.infraConfig); + scoped.initialized = this.initialized; + scoped.setContext(context); + return scoped; + } + /** * Update session ID (useful for CLI when starting new conversation) */ diff --git a/src/storage/provider.ts b/src/storage/provider.ts index 926dd4d..9bfce49 100644 --- a/src/storage/provider.ts +++ b/src/storage/provider.ts @@ -42,6 +42,26 @@ export abstract class StorageProvider { return this.initialized; } + /** + * Create an isolated, session-scoped copy of this provider with a fixed context. + * + * IMPORTANT for multi-tenancy: the shared provider singleton holds a single + * mutable `context` field. If many concurrent requests each call setContext() + * on it, they corrupt each other's GCS paths. Callers (SessionManager) must + * call this method instead and use the returned instance for all session I/O. + * + * The base implementation just sets context on `this` and returns `this` – + * which is only safe for single-session scenarios (e.g. CLI). Subclasses + * that serve concurrent HTTP sessions MUST override this to return a new, + * context-isolated instance that shares connection/cache resources with the + * parent but has its own immutable context. + */ + createSessionScoped(context: StorageContext): StorageProvider { + // Base/CLI fallback: mutate self (safe for single-session use). + this.setContext(context); + return this; + } + // ───────────────────────────────────────────────────────────── // Context Management (CRITICAL for multi-tenancy) // ───────────────────────────────────────────────────────────── diff --git a/src/utils/orchestration-logger.ts b/src/utils/orchestration-logger.ts index 9df3746..1b31389 100644 --- a/src/utils/orchestration-logger.ts +++ b/src/utils/orchestration-logger.ts @@ -17,23 +17,41 @@ interface OrchestrationEvent { details: Record; } -class OrchestrationLogger { +export class OrchestrationLogger { private static instance: OrchestrationLogger; private logFilePath: string | null = null; private logStream: fs.WriteStream | null = null; private sessionStart: Date; - + // Cloud-aware: buffer logs and flush to storage provider private storageProvider: StorageProvider | null = null; private sessionId: string | null = null; private logBuffer: string[] = []; private maxBufferSize: number = 100; - private constructor() { + /** + * Create an OrchestrationLogger. + * + * Called with no arguments → CLI mode: writes to a timestamped file in + * ~/.jiva/logs/ (backward-compatible singleton behaviour). + * + * Called with (storageProvider, sessionId) → HTTP/cloud mode: buffers events + * in memory and flushes them to the session-scoped storage provider. No + * filesystem log file is created. Each HTTP session should own its own + * instance so events never cross-contaminate between tenants. + */ + constructor(storageProvider?: StorageProvider, sessionId?: string) { this.sessionStart = new Date(); - this.initializeLogFile(); + if (storageProvider && sessionId) { + this.storageProvider = storageProvider; + this.sessionId = sessionId; + // Cloud mode: skip filesystem logging + } else { + this.initializeLogFile(); + } } + /** @deprecated Use per-session constructor instead — see class jsdoc. */ static getInstance(): OrchestrationLogger { if (!OrchestrationLogger.instance) { OrchestrationLogger.instance = new OrchestrationLogger(); @@ -42,7 +60,8 @@ class OrchestrationLogger { } /** - * Configure for cloud/HTTP mode with storage provider + * @deprecated Pass storageProvider + sessionId to the constructor instead. + * Kept for backward compatibility; will be removed in a future version. */ setStorageProvider(storageProvider: StorageProvider, sessionId: string) { this.storageProvider = storageProvider;