This commit is contained in:
iliya 2026-03-04 18:55:08 +02:00
parent 12e5e95bca
commit d2d07e4a2f
3 changed files with 207 additions and 35 deletions

View file

@ -97,6 +97,8 @@ export class FileWatcher extends EventEmitter {
private pendingReprocess = new Set<string>();
/** 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);

View file

@ -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<typeof spawn>;
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<typeof spawn>;
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',

View file

@ -429,12 +429,6 @@ describe('FileWatcher', () => {
const detectPromise = new Promise<void>((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<void>;
processingInProgress: Set<string>;
pendingReprocess: Set<string>;
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<void>;
lastProcessedSize: Map<string, number>;
lastProcessedLineCount: Map<string, number>;
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<void>;
lastProcessedLineCount: Map<string, number>;
lastProcessedSize: Map<string, number>;
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<void>;
lastProcessedLineCount: Map<string, number>;
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<void>;
lastProcessedLineCount: Map<string, number>;
};
// 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
// ===========================================================================