feat(HAI-091): add agent log persistence to triage and merger agents
- Add agent log persistence to triage agent with log capture and storage - Add agent log persistence to merger agent with log capture and storage - Add unit tests for triage agent log persistence - Add unit tests for merger agent log persistence
This commit is contained in:
@@ -45,6 +45,7 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
|
|||||||
updateTask: vi.fn().mockResolvedValue(baseTask),
|
updateTask: vi.fn().mockResolvedValue(baseTask),
|
||||||
moveTask: vi.fn().mockResolvedValue(baseTask),
|
moveTask: vi.fn().mockResolvedValue(baseTask),
|
||||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||||
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
||||||
emit: vi.fn(),
|
emit: vi.fn(),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
@@ -183,3 +184,92 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
|
|||||||
expect(result.merged).toBe(true);
|
expect(result.merged).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("aiMergeTask — agent log persistence", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedExistsSync.mockReturnValue(true);
|
||||||
|
setupHappyPathExecSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs text deltas to store.appendAgentLog", async () => {
|
||||||
|
let capturedOnText: ((delta: string) => void) | undefined;
|
||||||
|
|
||||||
|
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||||
|
capturedOnText = opts.onText;
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
capturedOnText?.("Hello ");
|
||||||
|
capturedOnText?.("merge");
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
||||||
|
const store = createMockStore(
|
||||||
|
{ id: "HAI-050", worktree: worktreePath },
|
||||||
|
[{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task],
|
||||||
|
);
|
||||||
|
|
||||||
|
await aiMergeTask(store, "/tmp/root", "HAI-050");
|
||||||
|
|
||||||
|
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-050", "Hello merge", "text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs tool invocations to store.appendAgentLog", async () => {
|
||||||
|
let capturedOnToolStart: ((name: string, args: any) => void) | undefined;
|
||||||
|
|
||||||
|
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||||
|
capturedOnToolStart = opts.onToolStart;
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
capturedOnToolStart?.("Bash", { command: "git status" });
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
||||||
|
const store = createMockStore(
|
||||||
|
{ id: "HAI-050", worktree: worktreePath },
|
||||||
|
[{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task],
|
||||||
|
);
|
||||||
|
|
||||||
|
await aiMergeTask(store, "/tmp/root", "HAI-050");
|
||||||
|
|
||||||
|
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-050", "Bash", "tool");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still fires onAgentText callback alongside logging", async () => {
|
||||||
|
const onAgentText = vi.fn();
|
||||||
|
let capturedOnText: ((delta: string) => void) | undefined;
|
||||||
|
|
||||||
|
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||||
|
capturedOnText = opts.onText;
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
capturedOnText?.("hi");
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
||||||
|
const store = createMockStore(
|
||||||
|
{ id: "HAI-050", worktree: worktreePath },
|
||||||
|
[{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task],
|
||||||
|
);
|
||||||
|
|
||||||
|
await aiMergeTask(store, "/tmp/root", "HAI-050", { onAgentText });
|
||||||
|
|
||||||
|
expect(onAgentText).toHaveBeenCalledWith("hi");
|
||||||
|
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-050", "hi", "text");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -185,12 +185,49 @@ export async function aiMergeTask(
|
|||||||
`[merger] ${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`,
|
`[merger] ${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Agent log buffering ──────────────────────────────────────────
|
||||||
|
let textBuffer = "";
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const FLUSH_INTERVAL_MS = 500;
|
||||||
|
const FLUSH_SIZE_BYTES = 1024;
|
||||||
|
|
||||||
|
const flushTextBuffer = async () => {
|
||||||
|
if (textBuffer.length === 0) return;
|
||||||
|
const chunk = textBuffer;
|
||||||
|
textBuffer = "";
|
||||||
|
try {
|
||||||
|
await store.appendAgentLog(taskId, chunk, "text");
|
||||||
|
} catch { /* best-effort persistence */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleFlush = () => {
|
||||||
|
if (flushTimer) return;
|
||||||
|
flushTimer = setTimeout(async () => {
|
||||||
|
flushTimer = null;
|
||||||
|
await flushTextBuffer();
|
||||||
|
}, FLUSH_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
const { session } = await createHaiAgent({
|
const { session } = await createHaiAgent({
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
systemPrompt: MERGE_SYSTEM_PROMPT,
|
systemPrompt: MERGE_SYSTEM_PROMPT,
|
||||||
tools: "coding",
|
tools: "coding",
|
||||||
onText: (delta) => options.onAgentText?.(delta),
|
onText: (delta) => {
|
||||||
onToolStart: (name, _args) => options.onAgentTool?.(name),
|
options.onAgentText?.(delta);
|
||||||
|
textBuffer += delta;
|
||||||
|
if (textBuffer.length >= FLUSH_SIZE_BYTES) {
|
||||||
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||||
|
flushTextBuffer();
|
||||||
|
} else {
|
||||||
|
scheduleFlush();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onToolStart: (name, _args) => {
|
||||||
|
options.onAgentTool?.(name);
|
||||||
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||||
|
flushTextBuffer();
|
||||||
|
store.appendAgentLog(taskId, name, "tool").catch(() => {});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -221,6 +258,8 @@ export async function aiMergeTask(
|
|||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
throw new Error(`AI merge failed for ${taskId}: ${err.message}`);
|
throw new Error(`AI merge failed for ${taskId}: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||||
|
await flushTextBuffer();
|
||||||
session.dispose();
|
session.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function createMockStore(tasks: any[] = []) {
|
|||||||
}),
|
}),
|
||||||
updateTask: vi.fn().mockResolvedValue({}),
|
updateTask: vi.fn().mockResolvedValue({}),
|
||||||
moveTask: vi.fn().mockResolvedValue({}),
|
moveTask: vi.fn().mockResolvedValue({}),
|
||||||
|
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
@@ -420,3 +421,113 @@ describe("TriageProcessor deleted task handling", () => {
|
|||||||
expect(mockedCreateHaiAgent).toHaveBeenCalled();
|
expect(mockedCreateHaiAgent).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("TriageProcessor agent log persistence", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs text deltas to store.appendAgentLog", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
let capturedOnText: ((delta: string) => void) | undefined;
|
||||||
|
|
||||||
|
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||||
|
capturedOnText = opts.onText;
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
// Simulate text deltas from the agent
|
||||||
|
capturedOnText?.("Hello ");
|
||||||
|
capturedOnText?.("world");
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const triage = new TriageProcessor(store, "/tmp/test", {});
|
||||||
|
await triage.specifyTask({
|
||||||
|
id: "HAI-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Text buffer is flushed in finally block
|
||||||
|
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-001", "Hello world", "text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs tool invocations to store.appendAgentLog", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
let capturedOnToolStart: ((name: string, args: any) => void) | undefined;
|
||||||
|
|
||||||
|
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||||
|
capturedOnToolStart = opts.onToolStart;
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
capturedOnToolStart?.("Read", { path: "foo.ts" });
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const triage = new TriageProcessor(store, "/tmp/test", {});
|
||||||
|
await triage.specifyTask({
|
||||||
|
id: "HAI-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-001", "Read", "tool");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still fires onAgentText callback alongside logging", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const onAgentText = vi.fn();
|
||||||
|
let capturedOnText: ((delta: string) => void) | undefined;
|
||||||
|
|
||||||
|
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||||
|
capturedOnText = opts.onText;
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
capturedOnText?.("hi");
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const triage = new TriageProcessor(store, "/tmp/test", { onAgentText });
|
||||||
|
await triage.specifyTask({
|
||||||
|
id: "HAI-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onAgentText).toHaveBeenCalledWith("HAI-001", "hi");
|
||||||
|
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-001", "hi", "text");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -251,14 +251,50 @@ export class TriageProcessor {
|
|||||||
const promptPath = `.hai/tasks/${task.id}/PROMPT.md`;
|
const promptPath = `.hai/tasks/${task.id}/PROMPT.md`;
|
||||||
|
|
||||||
const agentWork = async () => {
|
const agentWork = async () => {
|
||||||
|
// ── Agent log buffering ──────────────────────────────────────────
|
||||||
|
let textBuffer = "";
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const FLUSH_INTERVAL_MS = 500;
|
||||||
|
const FLUSH_SIZE_BYTES = 1024;
|
||||||
|
|
||||||
|
const flushTextBuffer = async () => {
|
||||||
|
if (textBuffer.length === 0) return;
|
||||||
|
const chunk = textBuffer;
|
||||||
|
textBuffer = "";
|
||||||
|
try {
|
||||||
|
await this.store.appendAgentLog(task.id, chunk, "text");
|
||||||
|
} catch { /* best-effort persistence */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleFlush = () => {
|
||||||
|
if (flushTimer) return;
|
||||||
|
flushTimer = setTimeout(async () => {
|
||||||
|
flushTimer = null;
|
||||||
|
await flushTextBuffer();
|
||||||
|
}, FLUSH_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
const { session } = await createHaiAgent({
|
const { session } = await createHaiAgent({
|
||||||
cwd: this.rootDir,
|
cwd: this.rootDir,
|
||||||
systemPrompt: TRIAGE_SYSTEM_PROMPT,
|
systemPrompt: TRIAGE_SYSTEM_PROMPT,
|
||||||
tools: "coding",
|
tools: "coding",
|
||||||
customTools: this.createTriageTools(),
|
customTools: this.createTriageTools(),
|
||||||
onText: (delta) => this.options.onAgentText?.(task.id, delta),
|
onText: (delta) => {
|
||||||
onToolStart: (name, _args) =>
|
this.options.onAgentText?.(task.id, delta);
|
||||||
console.log(`[triage] ${task.id} tool: ${name}`),
|
textBuffer += delta;
|
||||||
|
if (textBuffer.length >= FLUSH_SIZE_BYTES) {
|
||||||
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||||
|
flushTextBuffer();
|
||||||
|
} else {
|
||||||
|
scheduleFlush();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onToolStart: (name, _args) => {
|
||||||
|
console.log(`[triage] ${task.id} tool: ${name}`);
|
||||||
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||||
|
flushTextBuffer();
|
||||||
|
this.store.appendAgentLog(task.id, name, "tool").catch(() => {});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -290,6 +326,8 @@ export class TriageProcessor {
|
|||||||
this.options.onSpecifyComplete?.(task);
|
this.options.onSpecifyComplete?.(task);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||||
|
await flushTextBuffer();
|
||||||
session.dispose();
|
session.dispose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user