refactor: DB-driven agent output events with single emission point
DB log chunk insertion is now the sole trigger for agent:output events. Eliminates triple emission (FileTailer, handleStreamEvent, output buffer) in favor of: FileTailer.onRawContent → DB insert → EventBus emit. - createLogChunkCallback emits agent:output after successful DB insert - spawnInternal now wires onRawContent callback (fixes session 1 gap) - Remove eventBus from FileTailer (no longer touches EventBus) - Remove eventBus from ProcessManager constructor (dead parameter) - Remove agent:output emission from handleStreamEvent text_delta - Remove outputBuffers map and all buffer helpers from manager/handler - Remove getOutputBuffer from AgentManager interface and implementations - getAgentOutput tRPC: DB-only, no file fallback - onAgentOutput subscription: no initial buffer yield, events only - AgentOutputViewer: accumulates raw JSONL chunks, parses uniformly
This commit is contained in:
@@ -355,7 +355,6 @@ export class CleanupManager {
|
||||
filePath: agent.outputFilePath,
|
||||
agentId: agent.id,
|
||||
parser,
|
||||
eventBus: this.eventBus,
|
||||
onEvent: (event) => onStreamEvent(agent.id, event),
|
||||
startFromBeginning: false,
|
||||
onRawContent: onRawContent
|
||||
|
||||
@@ -13,7 +13,6 @@ import { watch, type FSWatcher } from 'node:fs';
|
||||
import { open, stat } from 'node:fs/promises';
|
||||
import type { FileHandle } from 'node:fs/promises';
|
||||
import type { StreamParser, StreamEvent } from './providers/stream-types.js';
|
||||
import type { EventBus, AgentOutputEvent } from '../events/index.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('file-tailer');
|
||||
@@ -27,17 +26,15 @@ const READ_BUFFER_SIZE = 64 * 1024;
|
||||
export interface FileTailerOptions {
|
||||
/** Path to the output file to watch */
|
||||
filePath: string;
|
||||
/** Agent ID for event emission */
|
||||
/** Agent ID for logging */
|
||||
agentId: string;
|
||||
/** Parser to convert lines to stream events */
|
||||
parser: StreamParser;
|
||||
/** Optional event bus for emitting agent:output events */
|
||||
eventBus?: EventBus;
|
||||
/** Optional callback for each stream event */
|
||||
onEvent?: (event: StreamEvent) => void;
|
||||
/** If true, read from beginning of file; otherwise tail only new content (default: false) */
|
||||
startFromBeginning?: boolean;
|
||||
/** Callback for raw file content chunks (for DB persistence) */
|
||||
/** Callback for raw file content chunks (for DB persistence + event emission) */
|
||||
onRawContent?: (content: string) => void;
|
||||
}
|
||||
|
||||
@@ -63,7 +60,6 @@ export class FileTailer {
|
||||
private readonly filePath: string;
|
||||
private readonly agentId: string;
|
||||
private readonly parser: StreamParser;
|
||||
private readonly eventBus?: EventBus;
|
||||
private readonly onEvent?: (event: StreamEvent) => void;
|
||||
private readonly startFromBeginning: boolean;
|
||||
private readonly onRawContent?: (content: string) => void;
|
||||
@@ -72,7 +68,6 @@ export class FileTailer {
|
||||
this.filePath = options.filePath;
|
||||
this.agentId = options.agentId;
|
||||
this.parser = options.parser;
|
||||
this.eventBus = options.eventBus;
|
||||
this.onEvent = options.onEvent;
|
||||
this.startFromBeginning = options.startFromBeginning ?? false;
|
||||
this.onRawContent = options.onRawContent;
|
||||
@@ -193,24 +188,9 @@ export class FileTailer {
|
||||
const events = this.parser.parseLine(line);
|
||||
|
||||
for (const event of events) {
|
||||
// Call user callback if provided
|
||||
if (this.onEvent) {
|
||||
this.onEvent(event);
|
||||
}
|
||||
|
||||
// Emit agent:output for text_delta events
|
||||
if (event.type === 'text_delta' && this.eventBus) {
|
||||
const outputEvent: AgentOutputEvent = {
|
||||
type: 'agent:output',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
agentId: this.agentId,
|
||||
stream: 'stdout',
|
||||
data: event.text,
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(outputEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
private static readonly MAX_COMMIT_RETRIES = 1;
|
||||
|
||||
private activeAgents: Map<string, ActiveAgent> = new Map();
|
||||
private outputBuffers: Map<string, string[]> = new Map();
|
||||
private commitRetryCount: Map<string, number> = new Map();
|
||||
private processManager: ProcessManager;
|
||||
private credentialHandler: CredentialHandler;
|
||||
@@ -83,7 +82,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
private debug: boolean = false,
|
||||
) {
|
||||
this.signalManager = new FileSystemSignalManager();
|
||||
this.processManager = new ProcessManager(workspaceRoot, projectRepository, eventBus);
|
||||
this.processManager = new ProcessManager(workspaceRoot, projectRepository);
|
||||
this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager);
|
||||
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager);
|
||||
this.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager);
|
||||
@@ -105,13 +104,12 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
|
||||
/**
|
||||
* Centralized cleanup of all in-memory state for an agent.
|
||||
* Cancels polling timer, removes from activeAgents, outputBuffers, and commitRetryCount.
|
||||
* Cancels polling timer, removes from activeAgents and commitRetryCount.
|
||||
*/
|
||||
private cleanupAgentState(agentId: string): void {
|
||||
const active = this.activeAgents.get(agentId);
|
||||
if (active?.cancelPoll) active.cancelPoll();
|
||||
this.activeAgents.delete(agentId);
|
||||
this.outputBuffers.delete(agentId);
|
||||
this.commitRetryCount.delete(agentId);
|
||||
}
|
||||
|
||||
@@ -129,6 +127,15 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
|
||||
return (content) => {
|
||||
repo.insertChunk({ agentId, agentName, sessionNumber, content })
|
||||
.then(() => {
|
||||
if (this.eventBus) {
|
||||
this.eventBus.emit({
|
||||
type: 'agent:output' as const,
|
||||
timestamp: new Date(),
|
||||
payload: { agentId, stream: 'stdout', data: content },
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to persist log chunk'));
|
||||
};
|
||||
}
|
||||
@@ -301,7 +308,8 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
// 6. Spawn detached subprocess
|
||||
const { pid, outputFilePath, tailer } = this.processManager.spawnDetached(
|
||||
agentId, alias, command, args, cwd ?? agentCwd, processEnv, providerName, prompt,
|
||||
(event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId), this.outputBuffers),
|
||||
(event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)),
|
||||
this.createLogChunkCallback(agentId, alias, 1),
|
||||
);
|
||||
|
||||
await this.repository.update(agentId, { pid, outputFilePath });
|
||||
@@ -452,7 +460,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
|
||||
const { pid, outputFilePath, tailer } = this.processManager.spawnDetached(
|
||||
agentId, agent.name, command, args, agentCwd, processEnv, provider.name, commitPrompt,
|
||||
(event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId), this.outputBuffers),
|
||||
(event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)),
|
||||
this.createLogChunkCallback(agentId, agent.name, commitSessionNumber),
|
||||
);
|
||||
|
||||
@@ -625,7 +633,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
|
||||
const { pid, outputFilePath, tailer } = this.processManager.spawnDetached(
|
||||
agentId, agent.name, command, args, agentCwd, processEnv, provider.name, prompt,
|
||||
(event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId), this.outputBuffers),
|
||||
(event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)),
|
||||
this.createLogChunkCallback(agentId, agent.name, resumeSessionNumber),
|
||||
);
|
||||
|
||||
@@ -666,13 +674,6 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
return this.outputHandler.getPendingQuestions(agentId, this.activeAgents.get(agentId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the buffered output for an agent.
|
||||
*/
|
||||
getOutputBuffer(agentId: string): string[] {
|
||||
return this.outputHandler.getOutputBufferCopy(this.outputBuffers, agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an agent and clean up all associated resources.
|
||||
*/
|
||||
@@ -759,7 +760,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
const reconcileLogChunkRepo = this.logChunkRepository;
|
||||
await this.cleanupManager.reconcileAfterRestart(
|
||||
this.activeAgents,
|
||||
(agentId, event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId), this.outputBuffers),
|
||||
(agentId, event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)),
|
||||
(agentId, rawOutput, provider) => this.outputHandler.processAgentOutput(agentId, rawOutput, provider, (alias) => this.processManager.getAgentWorkdir(alias)),
|
||||
(agentId, pid) => {
|
||||
const { cancel } = this.processManager.pollForCompletion(
|
||||
|
||||
@@ -457,14 +457,6 @@ export class MockAgentManager implements AgentManager {
|
||||
return record?.pendingQuestions ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the buffered output for an agent.
|
||||
* Mock implementation returns empty array.
|
||||
*/
|
||||
getOutputBuffer(_agentId: string): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss an agent.
|
||||
* Mock implementation just marks the agent as dismissed.
|
||||
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
AgentStoppedEvent,
|
||||
AgentCrashedEvent,
|
||||
AgentWaitingEvent,
|
||||
AgentOutputEvent,
|
||||
} from '../events/index.js';
|
||||
import type {
|
||||
AgentResult,
|
||||
@@ -45,9 +44,6 @@ import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('output-handler');
|
||||
|
||||
/** Max number of output chunks to buffer per agent */
|
||||
const MAX_OUTPUT_BUFFER_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* Tracks an active agent with its PID and file tailer.
|
||||
*/
|
||||
@@ -159,7 +155,6 @@ export class OutputHandler {
|
||||
agentId: string,
|
||||
event: StreamEvent,
|
||||
active: ActiveAgent | undefined,
|
||||
outputBuffers: Map<string, string[]>,
|
||||
): void {
|
||||
switch (event.type) {
|
||||
case 'init':
|
||||
@@ -172,15 +167,7 @@ export class OutputHandler {
|
||||
break;
|
||||
|
||||
case 'text_delta':
|
||||
this.pushToOutputBuffer(outputBuffers, agentId, event.text);
|
||||
if (this.eventBus) {
|
||||
const outputEvent: AgentOutputEvent = {
|
||||
type: 'agent:output',
|
||||
timestamp: new Date(),
|
||||
payload: { agentId, stream: 'stdout', data: event.text },
|
||||
};
|
||||
this.eventBus.emit(outputEvent);
|
||||
}
|
||||
// Text deltas are now streamed via DB log chunks + EventBus in manager.createLogChunkCallback
|
||||
break;
|
||||
|
||||
case 'tool_use_start':
|
||||
@@ -887,30 +874,6 @@ export class OutputHandler {
|
||||
return agent?.pendingQuestions ? JSON.parse(agent.pendingQuestions) : null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Output Buffer Management
|
||||
// =========================================================================
|
||||
|
||||
pushToOutputBuffer(buffers: Map<string, string[]>, agentId: string, chunk: string): void {
|
||||
let buffer = buffers.get(agentId);
|
||||
if (!buffer) {
|
||||
buffer = [];
|
||||
buffers.set(agentId, buffer);
|
||||
}
|
||||
buffer.push(chunk);
|
||||
while (buffer.length > MAX_OUTPUT_BUFFER_SIZE) {
|
||||
buffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
clearOutputBuffer(buffers: Map<string, string[]>, agentId: string): void {
|
||||
buffers.delete(agentId);
|
||||
}
|
||||
|
||||
getOutputBufferCopy(buffers: Map<string, string[]>, agentId: string): string[] {
|
||||
return [...(buffers.get(agentId) ?? [])];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private Helpers
|
||||
// =========================================================================
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ProcessManager } from './process-manager.js';
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||
import type { EventBus } from '../events/index.js';
|
||||
|
||||
// Mock child_process.spawn
|
||||
vi.mock('node:child_process', () => ({
|
||||
@@ -70,7 +69,6 @@ const mockCloseSync = vi.mocked(closeSync);
|
||||
describe('ProcessManager', () => {
|
||||
let processManager: ProcessManager;
|
||||
let mockProjectRepository: ProjectRepository;
|
||||
let mockEventBus: EventBus;
|
||||
|
||||
const workspaceRoot = '/test/workspace';
|
||||
|
||||
@@ -100,15 +98,7 @@ describe('ProcessManager', () => {
|
||||
removeProjectFromInitiative: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock event bus
|
||||
mockEventBus = {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
|
||||
processManager = new ProcessManager(workspaceRoot, mockProjectRepository, mockEventBus);
|
||||
processManager = new ProcessManager(workspaceRoot, mockProjectRepository);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { spawn } from 'node:child_process';
|
||||
import { writeFileSync, mkdirSync, openSync, closeSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||
import type { EventBus } from '../events/index.js';
|
||||
import type { AgentProviderConfig } from './providers/types.js';
|
||||
import type { StreamEvent } from './providers/parsers/index.js';
|
||||
import { getStreamParser } from './providers/parsers/index.js';
|
||||
@@ -37,7 +36,6 @@ export class ProcessManager {
|
||||
constructor(
|
||||
private workspaceRoot: string,
|
||||
private projectRepository: ProjectRepository,
|
||||
private eventBus?: EventBus,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -312,7 +310,6 @@ export class ProcessManager {
|
||||
filePath: outputFilePath,
|
||||
agentId,
|
||||
parser,
|
||||
eventBus: this.eventBus,
|
||||
onEvent: onEvent ?? (() => {}),
|
||||
startFromBeginning: true,
|
||||
onRawContent,
|
||||
|
||||
@@ -214,17 +214,6 @@ export interface AgentManager {
|
||||
*/
|
||||
getPendingQuestions(agentId: string): Promise<PendingQuestions | null>;
|
||||
|
||||
/**
|
||||
* Get the buffered output for an agent.
|
||||
*
|
||||
* Returns recent output chunks from the agent's stdout stream.
|
||||
* Buffer is limited to MAX_OUTPUT_BUFFER_SIZE chunks (ring buffer).
|
||||
*
|
||||
* @param agentId - Agent ID
|
||||
* @returns Array of output chunks (newest last)
|
||||
*/
|
||||
getOutputBuffer(agentId: string): string[];
|
||||
|
||||
/**
|
||||
* Delete an agent and clean up all associated resources.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user