фикс
This commit is contained in:
parent
12e5e95bca
commit
d2d07e4a2f
3 changed files with 207 additions and 35 deletions
|
|
@ -97,6 +97,8 @@ export class FileWatcher extends EventEmitter {
|
||||||
private pendingReprocess = new Set<string>();
|
private pendingReprocess = new Set<string>();
|
||||||
/** Flag to prevent reuse after disposal */
|
/** Flag to prevent reuse after disposal */
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
|
/** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files) */
|
||||||
|
private readonly instanceCreatedAt = Date.now();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
dataCache: DataCache,
|
dataCache: DataCache,
|
||||||
|
|
@ -751,6 +753,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFirstRead = lastLineCount === 0 && lastSize === 0;
|
||||||
const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize;
|
const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize;
|
||||||
let newMessages: ParsedMessage[] = [];
|
let newMessages: ParsedMessage[] = [];
|
||||||
let currentLineCount: number;
|
let currentLineCount: number;
|
||||||
|
|
@ -777,6 +780,22 @@ export class FileWatcher extends EventEmitter {
|
||||||
return;
|
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
|
// Detect errors in new messages
|
||||||
// Note: We pass the offset-adjusted line numbers to errorDetector
|
// Note: We pass the offset-adjusted line numbers to errorDetector
|
||||||
const errors = await errorDetector.detectErrors(newMessages, sessionId, projectId, filePath);
|
const errors = await errorDetector.detectErrors(newMessages, sessionId, projectId, filePath);
|
||||||
|
|
|
||||||
|
|
@ -1086,20 +1086,10 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
if (authSource === 'none') {
|
if (authSource === 'anthropic_api_key') {
|
||||||
// No explicit auth found. Still attempt preflight — the CLI may
|
logger.info('Auth: using explicit ANTHROPIC_API_KEY');
|
||||||
// authenticate through a mechanism we don't know about (e.g. a
|
} else if (authSource === 'anthropic_auth_token') {
|
||||||
// managed apiKeyHelper, SSO, or a future auth flow).
|
logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY');
|
||||||
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.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const probe = await this.probeClaudeRuntime(claudePath, targetCwd, executionEnv);
|
const probe = await this.probeClaudeRuntime(claudePath, targetCwd, executionEnv);
|
||||||
|
|
@ -1463,13 +1453,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const prompt = buildProvisioningPrompt(request);
|
const prompt = buildProvisioningPrompt(request);
|
||||||
let child: ReturnType<typeof spawn>;
|
let child: ReturnType<typeof spawn>;
|
||||||
const { env: shellEnv, authSource } = await this.buildProvisioningEnv();
|
const { env: shellEnv } = 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 spawnArgs = [
|
const spawnArgs = [
|
||||||
'--input-format',
|
'--input-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
|
|
@ -1769,13 +1753,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks);
|
const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks);
|
||||||
let child: ReturnType<typeof spawn>;
|
let child: ReturnType<typeof spawn>;
|
||||||
const { env: shellEnv, authSource } = await this.buildProvisioningEnv();
|
const { env: shellEnv } = 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 launchArgs = [
|
const launchArgs = [
|
||||||
'--input-format',
|
'--input-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
|
|
|
||||||
|
|
@ -429,12 +429,6 @@ describe('FileWatcher', () => {
|
||||||
const detectPromise = new Promise<void>((resolve) => {
|
const detectPromise = new Promise<void>((resolve) => {
|
||||||
detectResolve = resolve;
|
detectResolve = resolve;
|
||||||
});
|
});
|
||||||
vi.mocked(errorDetector.detectErrors).mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
detectPromise.then(() => resolve([]));
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const watcherAny = watcher as unknown as {
|
const watcherAny = watcher as unknown as {
|
||||||
detectErrorsInSessionFile: (
|
detectErrorsInSessionFile: (
|
||||||
|
|
@ -444,9 +438,27 @@ describe('FileWatcher', () => {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
processingInProgress: Set<string>;
|
processingInProgress: Set<string>;
|
||||||
pendingReprocess: 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);
|
const first = watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
|
||||||
|
|
||||||
// Wait a tick so the first call enters the processing block and reaches detectErrors
|
// Wait a tick so the first call enters the processing block and reaches detectErrors
|
||||||
|
|
@ -512,7 +524,10 @@ describe('FileWatcher', () => {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
lastProcessedSize: Map<string, number>;
|
lastProcessedSize: Map<string, number>;
|
||||||
lastProcessedLineCount: 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)
|
// First call - fallback path (no lastProcessedLineCount)
|
||||||
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
|
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
|
// Timer Lifecycle Tests
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue