From d2d07e4a2fe07453f3d86909f51190699a618838 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 4 Mar 2026 18:55:08 +0200 Subject: [PATCH] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/infrastructure/FileWatcher.ts | 19 ++ .../services/team/TeamProvisioningService.ts | 34 +--- .../infrastructure/FileWatcher.test.ts | 189 +++++++++++++++++- 3 files changed, 207 insertions(+), 35 deletions(-) diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 11fca601..308a7a5e 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -97,6 +97,8 @@ export class FileWatcher extends EventEmitter { private pendingReprocess = new Set(); /** Flag to prevent reuse after disposal */ private disposed = false; + /** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files) */ + private readonly instanceCreatedAt = Date.now(); constructor( dataCache: DataCache, @@ -751,6 +753,7 @@ export class FileWatcher extends EventEmitter { return; } + const isFirstRead = lastLineCount === 0 && lastSize === 0; const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize; let newMessages: ParsedMessage[] = []; let currentLineCount: number; @@ -777,6 +780,22 @@ export class FileWatcher extends EventEmitter { return; } + // On first read (after app restart), establish baseline without detecting errors + // for files that existed BEFORE this FileWatcher started. This prevents flooding + // notifications with historical errors from old sessions. + // Files created AFTER startup are new sessions — detect errors normally. + if (isFirstRead) { + const isPreExistingFile = fileStats.birthtimeMs < this.instanceCreatedAt; + if (isPreExistingFile) { + this.lastProcessedLineCount.set(filePath, currentLineCount); + this.lastProcessedSize.set(filePath, processedSize); + logger.info( + `FileWatcher: Baseline established for ${filePath} (${currentLineCount} lines, ${processedSize} bytes)` + ); + return; + } + } + // Detect errors in new messages // Note: We pass the offset-adjusted line numbers to errorDetector const errors = await errorDetector.detectErrors(newMessages, sessionId, projectId, filePath); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 73140601..74da9ae6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1086,20 +1086,10 @@ export class TeamProvisioningService { const warnings: string[] = []; - if (authSource === 'none') { - // No explicit auth found. Still attempt preflight — the CLI may - // authenticate through a mechanism we don't know about (e.g. a - // managed apiKeyHelper, SSO, or a future auth flow). - warnings.push( - 'No explicit auth env var found (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN). ' + - 'Attempting preflight check to verify if CLI can authenticate on its own.' - ); - } - - if (authSource === 'anthropic_auth_token') { - warnings.push( - 'Using ANTHROPIC_AUTH_TOKEN (proxy) mapped to ANTHROPIC_API_KEY for `-p` mode.' - ); + if (authSource === 'anthropic_api_key') { + logger.info('Auth: using explicit ANTHROPIC_API_KEY'); + } else if (authSource === 'anthropic_auth_token') { + logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY'); } const probe = await this.probeClaudeRuntime(claudePath, targetCwd, executionEnv); @@ -1463,13 +1453,7 @@ export class TeamProvisioningService { const prompt = buildProvisioningPrompt(request); let child: ReturnType; - const { env: shellEnv, authSource } = await this.buildProvisioningEnv(); - if (authSource === 'none') { - logger.warn( - 'No explicit auth env var found for `-p` mode. ' + - 'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.' - ); - } + const { env: shellEnv } = await this.buildProvisioningEnv(); const spawnArgs = [ '--input-format', 'stream-json', @@ -1769,13 +1753,7 @@ export class TeamProvisioningService { const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks); let child: ReturnType; - const { env: shellEnv, authSource } = await this.buildProvisioningEnv(); - if (authSource === 'none') { - logger.warn( - 'No explicit auth env var found for `-p` mode (launch). ' + - 'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.' - ); - } + const { env: shellEnv } = await this.buildProvisioningEnv(); const launchArgs = [ '--input-format', 'stream-json', diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts index 003df76a..d6e69e20 100644 --- a/test/main/services/infrastructure/FileWatcher.test.ts +++ b/test/main/services/infrastructure/FileWatcher.test.ts @@ -429,12 +429,6 @@ describe('FileWatcher', () => { const detectPromise = new Promise((resolve) => { detectResolve = resolve; }); - vi.mocked(errorDetector.detectErrors).mockImplementation( - () => - new Promise((resolve) => { - detectPromise.then(() => resolve([])); - }) - ); const watcherAny = watcher as unknown as { detectErrorsInSessionFile: ( @@ -444,9 +438,27 @@ describe('FileWatcher', () => { ) => Promise; processingInProgress: Set; pendingReprocess: Set; + instanceCreatedAt: number; }; + // Ensure watcher treats the file as pre-existing so first call baselines + watcherAny.instanceCreatedAt = Date.now() + 60_000; - // Start first call (will block on detectErrors) + // First call establishes baseline (skips error detection on first read) + vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); + await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); + + // Append new data so subsequent calls have new lines to process + fs.appendFileSync(filePath, jsonlLine('u2', 'world')); + + // Now make detectErrors slow to simulate long processing + vi.mocked(errorDetector.detectErrors).mockImplementation( + () => + new Promise((resolve) => { + detectPromise.then(() => resolve([])); + }) + ); + + // Start call that will block on detectErrors (not first read anymore) const first = watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); // Wait a tick so the first call enters the processing block and reaches detectErrors @@ -512,7 +524,10 @@ describe('FileWatcher', () => { ) => Promise; lastProcessedSize: Map; lastProcessedLineCount: Map; + instanceCreatedAt: number; }; + // Treat file as new (created after watcher) so it goes through the full parse path + watcherAny.instanceCreatedAt = 0; // First call - fallback path (no lastProcessedLineCount) await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); @@ -527,6 +542,166 @@ describe('FileWatcher', () => { }); }); + // =========================================================================== + // First-Read Baseline Tests (prevents old session error flooding) + // =========================================================================== + + describe('first-read baseline behavior', () => { + it('establishes baseline without detecting errors for pre-existing files', async () => { + vi.useRealTimers(); + useRealExistsSync(); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-baseline-')); + const projectsDir = path.join(tempDir, 'projects'); + const projectDir = path.join(projectsDir, 'test-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const filePath = path.join(projectDir, 'session-1.jsonl'); + // Write a file with multiple lines (simulating an existing session with errors) + fs.writeFileSync( + filePath, + jsonlLine('u1', 'hello') + jsonlLine('u2', 'world') + jsonlLine('u3', 'error line'), + 'utf8' + ); + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); + watcher.setNotificationManager(notificationManager); + + // Simulate watcher starting well after the file was created + const watcherAny = watcher as unknown as { + detectErrorsInSessionFile: ( + projectId: string, + sessionId: string, + filePath: string + ) => Promise; + lastProcessedLineCount: Map; + lastProcessedSize: Map; + instanceCreatedAt: number; + }; + watcherAny.instanceCreatedAt = Date.now() + 60_000; // watcher "started" in the future + + vi.mocked(errorDetector.detectErrors).mockClear(); + + // First read should establish baseline, NOT detect errors + await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); + + // errorDetector.detectErrors should NOT have been called + expect(errorDetector.detectErrors).not.toHaveBeenCalled(); + + // Baseline tracking should be established + expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(3); + expect(watcherAny.lastProcessedSize.get(filePath)).toBe(fs.statSync(filePath).size); + + // notificationManager.addError should NOT have been called + expect(notificationManager.addError).not.toHaveBeenCalled(); + + watcher.stop(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('detects errors only in new data after baseline is established', async () => { + vi.useRealTimers(); + useRealExistsSync(); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-post-baseline-')); + const projectsDir = path.join(tempDir, 'projects'); + const projectDir = path.join(projectsDir, 'test-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const filePath = path.join(projectDir, 'session-1.jsonl'); + // Initial content (old session data) + fs.writeFileSync(filePath, jsonlLine('u1', 'hello') + jsonlLine('u2', 'world'), 'utf8'); + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); + watcher.setNotificationManager(notificationManager); + + // Simulate watcher starting well after the file was created + const watcherAny = watcher as unknown as { + detectErrorsInSessionFile: ( + projectId: string, + sessionId: string, + filePath: string + ) => Promise; + lastProcessedLineCount: Map; + instanceCreatedAt: number; + }; + watcherAny.instanceCreatedAt = Date.now() + 60_000; + + vi.mocked(errorDetector.detectErrors).mockClear(); + vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); + + // First read: baseline only + await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); + expect(errorDetector.detectErrors).not.toHaveBeenCalled(); + expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(2); + + // Append new data + fs.appendFileSync(filePath, jsonlLine('u3', 'new error')); + + // Second read: should detect errors in new data only + await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); + + expect(errorDetector.detectErrors).toHaveBeenCalledTimes(1); + // Verify only the new message was passed to detectErrors + const callArgs = vi.mocked(errorDetector.detectErrors).mock.calls[0]; + expect(callArgs[0]).toHaveLength(1); // only 1 new message + + // Tracking should now reflect all 3 lines + expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(3); + + watcher.stop(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('detects errors immediately for files created after watcher startup', async () => { + vi.useRealTimers(); + useRealExistsSync(); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-newfile-')); + const projectsDir = path.join(tempDir, 'projects'); + const projectDir = path.join(projectsDir, 'test-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); + watcher.setNotificationManager(notificationManager); + + vi.mocked(errorDetector.detectErrors).mockClear(); + vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); + + // instanceCreatedAt is already set to "now" by the constructor, + // and the file created below will have birthtimeMs >= instanceCreatedAt, + // so it will be treated as a new file (no baseline skip) + const filePath = path.join(projectDir, 'session-new.jsonl'); + fs.writeFileSync(filePath, jsonlLine('u1', 'hello') + jsonlLine('u2', 'error'), 'utf8'); + + const watcherAny = watcher as unknown as { + detectErrorsInSessionFile: ( + projectId: string, + sessionId: string, + filePath: string + ) => Promise; + lastProcessedLineCount: Map; + }; + + // First read of a NEW file should detect errors (not baseline-skip) + await watcherAny.detectErrorsInSessionFile('test-project', 'session-new', filePath); + + expect(errorDetector.detectErrors).toHaveBeenCalledTimes(1); + const callArgs = vi.mocked(errorDetector.detectErrors).mock.calls[0]; + expect(callArgs[0]).toHaveLength(2); // all messages scanned + expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(2); + + watcher.stop(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + }); + // =========================================================================== // Timer Lifecycle Tests // ===========================================================================