feat(FN-3173): add SQLite WAL tuning, integrity checks, and agent log batch
This merge brings FN-3173's SQLite stability improvements: WAL tuning pragmas for better concurrency, periodic integrity checks with self-healing recovery, and batched agent log writes to reduce I/O overhead. It also includes a new cron-runner for scheduled maintenance tasks, TUI mouse wheel scrolli Fusion-Task-Id: FN-3173
This commit is contained in:
@@ -51,6 +51,29 @@ describe("Database", () => {
|
||||
expect(row.foreign_keys).toBe(1);
|
||||
});
|
||||
|
||||
it("sets WAL tuning pragmas for disk-backed databases", () => {
|
||||
const synchronous = db.prepare("PRAGMA synchronous").get() as { synchronous: number };
|
||||
const autoCheckpoint = db.prepare("PRAGMA wal_autocheckpoint").get() as { wal_autocheckpoint: number };
|
||||
const journalSizeLimit = db.prepare("PRAGMA journal_size_limit").get() as { journal_size_limit: number };
|
||||
|
||||
expect(synchronous.synchronous).toBe(1); // NORMAL
|
||||
expect(autoCheckpoint.wal_autocheckpoint).toBe(100);
|
||||
expect(journalSizeLimit.journal_size_limit).toBe(4_194_304);
|
||||
});
|
||||
|
||||
it("does not force WAL tuning pragmas for in-memory databases", () => {
|
||||
const memDb = new Database(fusionDir, { inMemory: true });
|
||||
memDb.init();
|
||||
|
||||
const autoCheckpoint = memDb.prepare("PRAGMA wal_autocheckpoint").get() as { wal_autocheckpoint: number };
|
||||
const journalSizeLimit = memDb.prepare("PRAGMA journal_size_limit").get() as { journal_size_limit: number };
|
||||
|
||||
expect(autoCheckpoint.wal_autocheckpoint).toBe(1000);
|
||||
expect(journalSizeLimit.journal_size_limit).toBe(-1);
|
||||
|
||||
memDb.close();
|
||||
});
|
||||
|
||||
it("creates all expected tables", () => {
|
||||
const tables = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
@@ -409,6 +432,28 @@ describe("Database", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("integrity check", () => {
|
||||
it("returns ok for healthy databases and leaves corruption flag false", () => {
|
||||
expect(db.corruptionDetected).toBe(false);
|
||||
expect(db.integrityCheck()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("keeps corruptionDetected false after init for healthy database", () => {
|
||||
const diskDb = new Database(fusionDir);
|
||||
diskDb.init();
|
||||
expect(diskDb.corruptionDetected).toBe(false);
|
||||
diskDb.close();
|
||||
});
|
||||
|
||||
it("skips integrity check side effects for in-memory databases", () => {
|
||||
const memDb = new Database(fusionDir, { inMemory: true });
|
||||
memDb.init();
|
||||
expect(memDb.integrityCheck()).toEqual({ ok: true });
|
||||
expect(memDb.corruptionDetected).toBe(false);
|
||||
memDb.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("foreign key cascade across reopen", () => {
|
||||
it("cascade delete works after closing and reopening the database", () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
@@ -4141,6 +4141,31 @@ Task with acceptance criteria
|
||||
expect(events[0].taskId).toBe(task.id);
|
||||
});
|
||||
|
||||
it("appendAgentLogBatch inserts all entries and emits per-entry events", async () => {
|
||||
const task = await createTestTask();
|
||||
const events: any[] = [];
|
||||
store.on("agent:log", (entry) => events.push(entry));
|
||||
|
||||
await store.appendAgentLogBatch([
|
||||
{ taskId: task.id, text: "batch 1", type: "text" },
|
||||
{ taskId: task.id, text: "tool", type: "tool", detail: "read file", agent: "executor" },
|
||||
]);
|
||||
|
||||
const logs = await store.getAgentLogs(task.id);
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs.map((entry) => entry.text)).toEqual(["batch 1", "tool"]);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[1]).toMatchObject({ text: "tool", type: "tool", detail: "read file", agent: "executor" });
|
||||
});
|
||||
|
||||
it("appendAgentLogBatch with empty entries is a no-op", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
await store.appendAgentLogBatch([]);
|
||||
|
||||
expect(await store.getAgentLogCount(task.id)).toBe(0);
|
||||
});
|
||||
|
||||
it("appendAgentLog writes detail when provided", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { DatabaseSync } from "./sqlite-adapter.js";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { mkdirSync, existsSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
|
||||
import type { SteeringComment, TaskComment } from "./types.js";
|
||||
|
||||
@@ -695,6 +696,8 @@ CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder
|
||||
export class Database {
|
||||
private db: DatabaseSync;
|
||||
private readonly dbPath: string;
|
||||
private readonly inMemory: boolean;
|
||||
corruptionDetected = false;
|
||||
/** Tracks transaction nesting depth for savepoint-based nested transactions. */
|
||||
private transactionDepth = 0;
|
||||
private readonly _fts5Available: boolean;
|
||||
@@ -707,6 +710,7 @@ export class Database {
|
||||
// never sets this — it's plumbed through TaskStore for tests that
|
||||
// don't need cross-instance persistence.
|
||||
const inMemory = options?.inMemory === true;
|
||||
this.inMemory = inMemory;
|
||||
this.dbPath = inMemory ? ":memory:" : join(fusionDir, "fusion.db");
|
||||
|
||||
if (!inMemory && !isAbsolute(fusionDir)) {
|
||||
@@ -741,14 +745,23 @@ export class Database {
|
||||
}
|
||||
|
||||
// WAL is meaningless for `:memory:` connections — SQLite ignores it
|
||||
// and there's no other writer to coordinate with — so we skip it. The
|
||||
// remaining pragmas apply uniformly.
|
||||
// and there's no other writer to coordinate with — so we skip WAL-only
|
||||
// tuning there.
|
||||
if (!inMemory) {
|
||||
// Enable WAL mode for concurrent reader/writer access
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
// Wait up to 5s for locks to clear before returning SQLITE_BUSY
|
||||
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||
// In WAL mode NORMAL is nearly as durable as FULL with much lower fsync cost.
|
||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
// Checkpoint aggressively to avoid large WAL growth under bursty writes.
|
||||
this.db.exec("PRAGMA wal_autocheckpoint = 100");
|
||||
// Bound WAL growth between checkpoints/maintenance cycles.
|
||||
this.db.exec("PRAGMA journal_size_limit = 4194304");
|
||||
} else {
|
||||
// Wait up to 5s for locks to clear before returning SQLITE_BUSY
|
||||
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||
}
|
||||
// Wait up to 5s for locks to clear before returning SQLITE_BUSY
|
||||
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||
// Enable foreign key enforcement
|
||||
this.db.exec("PRAGMA foreign_keys = ON");
|
||||
|
||||
@@ -845,6 +858,47 @@ export class Database {
|
||||
}
|
||||
}
|
||||
|
||||
integrityCheck(): { ok: true } | { ok: false; errors: string[] } {
|
||||
if (this.inMemory) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const rows = this.db
|
||||
.prepare("PRAGMA integrity_check(100)")
|
||||
.all() as Array<Record<string, unknown>>;
|
||||
const errors = rows
|
||||
.map((row) => row.integrity_check)
|
||||
.filter((value): value is string => typeof value === "string" && value !== "ok");
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
recoverDatabase(outputPath: string): boolean {
|
||||
if (this.inMemory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recoveredSql = spawnSync("sqlite3", ["-cmd", ".recover main", this.dbPath], {
|
||||
encoding: "utf-8",
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
});
|
||||
if (recoveredSql.status !== 0 || !recoveredSql.stdout) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rebuilt = spawnSync("sqlite3", [outputPath], {
|
||||
input: recoveredSql.stdout,
|
||||
encoding: "utf-8",
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
});
|
||||
|
||||
return rebuilt.status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database: create tables if they don't exist
|
||||
* and seed meta values.
|
||||
@@ -871,6 +925,12 @@ export class Database {
|
||||
this.db.exec(
|
||||
`INSERT OR IGNORE INTO config (id, nextId, nextWorkflowStepId, settings, workflowSteps, updatedAt) VALUES (1, 1, 1, '${JSON.stringify(DEFAULT_PROJECT_SETTINGS)}', '[]', '${configNow}')`,
|
||||
);
|
||||
|
||||
const integrity = this.integrityCheck();
|
||||
if (!integrity.ok) {
|
||||
this.corruptionDetected = true;
|
||||
console.warn("[fusion:db] Database integrity check FAILED — corruption detected");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* @module migration
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { isAbsolute, join, resolve, basename, dirname } from "node:path";
|
||||
import type { CentralCore } from "./central-core.js";
|
||||
@@ -191,17 +191,45 @@ export class FirstRunDetector {
|
||||
const visited = new Set<string>();
|
||||
|
||||
let current = resolve(startDir);
|
||||
const home = getHomeDir();
|
||||
const home = resolve(getHomeDir());
|
||||
const root = dirname(current) === current ? current : "/"; // Handle Windows vs Unix root
|
||||
// Also stop at the OS temp directory — it is a shared system boundary and
|
||||
// should never itself host a project; stopping here prevents the walk from
|
||||
// picking up stale .fusion/ directories left by other processes in /tmp.
|
||||
const systemTmp = resolve(tmpdir());
|
||||
|
||||
while (current !== home && current !== root && current !== systemTmp) {
|
||||
const normalizePath = (path: string): string => {
|
||||
try {
|
||||
return realpathSync(path);
|
||||
} catch {
|
||||
return resolve(path);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedHome = normalizePath(home);
|
||||
const normalizedSystemTmp = normalizePath(systemTmp);
|
||||
|
||||
const normalizedStartDir = normalizePath(current);
|
||||
|
||||
while (true) {
|
||||
if (visited.has(current)) break;
|
||||
visited.add(current);
|
||||
|
||||
const normalizedCurrent = normalizePath(current);
|
||||
const isRootBoundary = current === root;
|
||||
const isHomeBoundary = normalizedCurrent === normalizedHome;
|
||||
const isTmpBoundary = normalizedCurrent === normalizedSystemTmp;
|
||||
const isStopBoundary = isRootBoundary || isHomeBoundary || isTmpBoundary;
|
||||
|
||||
// Never inspect root/tmp boundaries. Home is checked only when it is the
|
||||
// explicit starting directory (covers cwd===home tests).
|
||||
if (
|
||||
(isRootBoundary || isTmpBoundary) ||
|
||||
(isHomeBoundary && normalizedCurrent !== normalizedStartDir)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.hasFusionProject(current)) {
|
||||
const name = await this.generateProjectName(current);
|
||||
projects.push({
|
||||
@@ -213,6 +241,10 @@ export class FirstRunDetector {
|
||||
break;
|
||||
}
|
||||
|
||||
if (isStopBoundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
|
||||
@@ -204,7 +204,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
maxPostReviewFixes: 1,
|
||||
maxSpawnedAgentsPerParent: 5,
|
||||
maxSpawnedAgentsGlobal: 20,
|
||||
maintenanceIntervalMs: 900_000,
|
||||
// Run maintenance (including WAL checkpointing) every 5 minutes by default.
|
||||
maintenanceIntervalMs: 300_000,
|
||||
autoArchiveDoneTasksEnabled: true,
|
||||
autoArchiveDoneAfterMs: 48 * 60 * 60 * 1000,
|
||||
archiveAgentLogMode: "compact",
|
||||
|
||||
@@ -4525,6 +4525,51 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
this.emit("agent:log", entry);
|
||||
}
|
||||
|
||||
async appendAgentLogBatch(
|
||||
entries: Array<{
|
||||
taskId: string;
|
||||
text: string;
|
||||
type: AgentLogEntry["type"];
|
||||
detail?: string;
|
||||
agent?: AgentLogEntry["agent"];
|
||||
}>,
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.db.transaction(() => {
|
||||
for (const entry of entries) {
|
||||
stmt.run(
|
||||
entry.taskId,
|
||||
timestamp,
|
||||
entry.text,
|
||||
entry.type,
|
||||
entry.detail ?? null,
|
||||
entry.agent ?? null,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.db.bumpLastModified();
|
||||
for (const entry of entries) {
|
||||
this.emit("agent:log", {
|
||||
timestamp,
|
||||
taskId: entry.taskId,
|
||||
text: entry.text,
|
||||
type: entry.type,
|
||||
...(entry.detail !== undefined && { detail: entry.detail }),
|
||||
...(entry.agent !== undefined && { agent: entry.agent }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private mapAgentLogRow(row: Record<string, unknown>): AgentLogEntry {
|
||||
return {
|
||||
timestamp: row.timestamp as string,
|
||||
|
||||
@@ -53,9 +53,10 @@ describe("summarizeToolArgs", () => {
|
||||
|
||||
// ── AgentLogger tests ────────────────────────────────────────────────
|
||||
|
||||
function createMockStore() {
|
||||
function createMockStore(withBatch = false) {
|
||||
return {
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
...(withBatch ? { appendAgentLogBatch: vi.fn().mockResolvedValue(undefined) } : {}),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
@@ -69,6 +70,24 @@ describe("AgentLogger", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses appendAgentLogBatch when available", async () => {
|
||||
const store = createMockStore(true) as unknown as TaskStore & { appendAgentLogBatch: ReturnType<typeof vi.fn> };
|
||||
const logger = new AgentLogger({
|
||||
store,
|
||||
taskId: "FN-BATCH",
|
||||
flushSizeBytes: 4,
|
||||
flushIntervalMs: 500,
|
||||
});
|
||||
|
||||
logger.onText("hello");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(store.appendAgentLogBatch).toHaveBeenCalledWith([
|
||||
{ taskId: "FN-BATCH", text: "hello", type: "text", detail: undefined, agent: undefined },
|
||||
]);
|
||||
expect((store.appendAgentLog as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("buffers text and flushes on size threshold", async () => {
|
||||
const store = createMockStore();
|
||||
const logger = new AgentLogger({
|
||||
@@ -383,7 +402,7 @@ describe("AgentLogger", () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(loggerWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to log tool start \"Bash\" for FN-2090-TOOL-START"),
|
||||
expect.stringContaining("Failed to flush agent log entry for FN-2090-TOOL-START"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -397,7 +416,7 @@ describe("AgentLogger", () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(loggerWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to log tool end \"Bash\" (tool_result) for FN-2090-TOOL-END"),
|
||||
expect.stringContaining("Failed to flush agent log entry for FN-2090-TOOL-END"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -415,7 +434,7 @@ describe("AgentLogger", () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(loggerWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to flush text buffer for FN-2090-TEXT"),
|
||||
expect.stringContaining("Failed to flush agent log entry for FN-2090-TEXT"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -433,7 +452,7 @@ describe("AgentLogger", () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(loggerWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to flush thinking buffer for FN-2090-THINKING"),
|
||||
expect.stringContaining("Failed to flush agent log entry for FN-2090-THINKING"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLogger } from "./logger.js";
|
||||
const FLUSH_SIZE_BYTES = 1024;
|
||||
/** Default timer interval (ms) for periodic flush of small writes. */
|
||||
const FLUSH_INTERVAL_MS = 500;
|
||||
const ENTRY_BATCH_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Produce a human-readable summary from tool arguments.
|
||||
@@ -95,6 +96,8 @@ export class AgentLogger {
|
||||
private thinkingBuffer = "";
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private thinkingFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private entryFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingEntries: AgentLogEntry[] = [];
|
||||
private readonly flushSizeBytes: number;
|
||||
private readonly flushIntervalMs: number;
|
||||
private readonly store?: TaskStore;
|
||||
@@ -190,8 +193,10 @@ export class AgentLogger {
|
||||
async flush(): Promise<void> {
|
||||
if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; }
|
||||
if (this.thinkingFlushTimer) { clearTimeout(this.thinkingFlushTimer); this.thinkingFlushTimer = null; }
|
||||
if (this.entryFlushTimer) { clearTimeout(this.entryFlushTimer); this.entryFlushTimer = null; }
|
||||
await this.flushTextBuffer();
|
||||
await this.flushThinkingBuffer();
|
||||
await this.flushPendingEntries();
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────────────────────
|
||||
@@ -202,7 +207,7 @@ export class AgentLogger {
|
||||
* When only `appendLogCb` is set (no store/taskId), only the callback is used.
|
||||
* @param storeWarnMsg - Warning message prefix used when the task-store write fails.
|
||||
*/
|
||||
private writeEntry(text: string, type: AgentLogEntry["type"], detail: string | undefined, storeWarnMsg: string): void {
|
||||
private writeEntry(text: string, type: AgentLogEntry["type"], detail: string | undefined, _storeWarnMsg: string, immediate = false): void {
|
||||
const entry: AgentLogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId: this.taskId,
|
||||
@@ -212,83 +217,42 @@ export class AgentLogger {
|
||||
...(this.agent !== undefined && { agent: this.agent }),
|
||||
};
|
||||
|
||||
if (this.store && this.taskId) {
|
||||
this.store.appendAgentLog(this.taskId, text, type, detail, this.agent).catch((err) => {
|
||||
this.log.warn(`${storeWarnMsg}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
this.pendingEntries.push(entry);
|
||||
if (immediate || (type !== "text" && type !== "thinking")) {
|
||||
if (this.entryFlushTimer) {
|
||||
clearTimeout(this.entryFlushTimer);
|
||||
this.entryFlushTimer = null;
|
||||
}
|
||||
void this.flushPendingEntries();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.appendLogCb) {
|
||||
this.appendLogCb(entry).catch((err) => {
|
||||
this.log.warn(`appendLog callback failed for entry (${type}): ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
if (this.pendingEntries.length >= ENTRY_BATCH_SIZE) {
|
||||
if (this.entryFlushTimer) {
|
||||
clearTimeout(this.entryFlushTimer);
|
||||
this.entryFlushTimer = null;
|
||||
}
|
||||
void this.flushPendingEntries();
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleEntryFlush();
|
||||
}
|
||||
|
||||
private flushTextBuffer(): Promise<void> {
|
||||
if (this.textBuffer.length === 0) return Promise.resolve();
|
||||
const chunk = this.textBuffer;
|
||||
this.textBuffer = "";
|
||||
const entry: AgentLogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId: this.taskId,
|
||||
text: chunk,
|
||||
type: "text",
|
||||
...(this.agent !== undefined && { agent: this.agent }),
|
||||
};
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if (this.store && this.taskId) {
|
||||
promises.push(
|
||||
this.store.appendAgentLog(this.taskId, chunk, "text", undefined, this.agent).catch((err) => {
|
||||
this.log.warn(`Failed to flush text buffer for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.appendLogCb) {
|
||||
promises.push(
|
||||
this.appendLogCb(entry).catch((err) => {
|
||||
this.log.warn(`appendLog callback failed for text flush: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(promises).then(() => undefined);
|
||||
this.writeEntry(chunk, "text", undefined, `Failed to flush text buffer for ${this.taskId}`, true);
|
||||
return this.flushPendingEntries();
|
||||
}
|
||||
|
||||
private flushThinkingBuffer(): Promise<void> {
|
||||
if (this.thinkingBuffer.length === 0) return Promise.resolve();
|
||||
const chunk = this.thinkingBuffer;
|
||||
this.thinkingBuffer = "";
|
||||
const entry: AgentLogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId: this.taskId,
|
||||
text: chunk,
|
||||
type: "thinking",
|
||||
...(this.agent !== undefined && { agent: this.agent }),
|
||||
};
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if (this.store && this.taskId) {
|
||||
promises.push(
|
||||
this.store.appendAgentLog(this.taskId, chunk, "thinking", undefined, this.agent).catch((err) => {
|
||||
this.log.warn(`Failed to flush thinking buffer for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.appendLogCb) {
|
||||
promises.push(
|
||||
this.appendLogCb(entry).catch((err) => {
|
||||
this.log.warn(`appendLog callback failed for thinking flush: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(promises).then(() => undefined);
|
||||
this.writeEntry(chunk, "thinking", undefined, `Failed to flush thinking buffer for ${this.taskId}`, true);
|
||||
return this.flushPendingEntries();
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
@@ -306,4 +270,57 @@ export class AgentLogger {
|
||||
this.flushThinkingBuffer();
|
||||
}, this.flushIntervalMs);
|
||||
}
|
||||
|
||||
private scheduleEntryFlush(): void {
|
||||
if (this.entryFlushTimer) return;
|
||||
this.entryFlushTimer = setTimeout(() => {
|
||||
this.entryFlushTimer = null;
|
||||
void this.flushPendingEntries();
|
||||
}, this.flushIntervalMs);
|
||||
}
|
||||
|
||||
private async flushPendingEntries(): Promise<void> {
|
||||
if (this.pendingEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = this.pendingEntries;
|
||||
this.pendingEntries = [];
|
||||
|
||||
if (this.store && this.taskId) {
|
||||
if (typeof (this.store as TaskStore & { appendAgentLogBatch?: unknown }).appendAgentLogBatch === "function") {
|
||||
await this.store
|
||||
.appendAgentLogBatch(
|
||||
entries.map((entry) => ({
|
||||
taskId: entry.taskId,
|
||||
text: entry.text,
|
||||
type: entry.type,
|
||||
detail: entry.detail,
|
||||
agent: entry.agent,
|
||||
})),
|
||||
)
|
||||
.catch((err) => {
|
||||
this.log.warn(`Failed to flush agent log batch for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
} else {
|
||||
await Promise.all(
|
||||
entries.map((entry) =>
|
||||
this.store!.appendAgentLog(entry.taskId, entry.text, entry.type, entry.detail, entry.agent).catch((err) => {
|
||||
this.log.warn(`Failed to flush agent log entry for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.appendLogCb) {
|
||||
await Promise.all(
|
||||
entries.map((entry) =>
|
||||
this.appendLogCb!(entry).catch((err) => {
|
||||
this.log.warn(`appendLog callback failed for entry (${entry.type}): ${err instanceof Error ? err.message : String(err)}`);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user