feat: Persist agent prompt in DB so getAgentPrompt survives log cleanup

The `getAgentPrompt` tRPC procedure previously read exclusively from
`.cw/agent-logs/<name>/PROMPT.md`. Once the cleanup-manager removes
that directory, the prompt is gone forever.

Adds a `prompt` text column to the `agents` table and writes the fully
assembled prompt (including workspace layout, inter-agent comms, and
preview sections) to the DB in the same `repository.update()` call
that saves pid/outputFilePath after spawn.

`getAgentPrompt` now reads from DB first (`agent.prompt`) and falls
back to the filesystem only for agents spawned before this change.

Addresses review comment [MMcmVlEK16bBfkJuXvG6h].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 13:13:01 +01:00
parent 269a2d2616
commit b2f4004191
14 changed files with 1239 additions and 11 deletions

View File

@@ -50,6 +50,7 @@ function makeAgentInfo(overrides: Record<string, unknown> = {}) {
updatedAt: new Date('2026-01-01T00:00:00Z'),
userDismissedAt: null,
exitCode: null,
prompt: null,
...overrides,
};
}
@@ -273,7 +274,7 @@ describe('getAgentPrompt', () => {
await fs.writeFile(path.join(promptDir, 'PROMPT.md'), promptContent);
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName })),
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })),
};
const ctx = createTestContext({
@@ -285,4 +286,42 @@ describe('getAgentPrompt', () => {
expect(result).toEqual({ content: promptContent });
});
it('returns prompt from DB when agent.prompt is set (no file needed)', async () => {
const dbPromptContent = '# DB Prompt\nThis is persisted in the database';
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: 'test-agent', prompt: dbPromptContent })),
};
// workspaceRoot has no PROMPT.md — but DB value takes precedence
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentPrompt({ id: 'agent-1' });
expect(result).toEqual({ content: dbPromptContent });
});
it('falls back to PROMPT.md when agent.prompt is null in DB', async () => {
const agentName = 'test-agent';
const promptDir = path.join(tmpDir, '.cw', 'agent-logs', agentName);
await fs.mkdir(promptDir, { recursive: true });
const fileContent = '# File Prompt\nThis is from the file (legacy)';
await fs.writeFile(path.join(promptDir, 'PROMPT.md'), fileContent);
const mockManager = {
get: vi.fn().mockResolvedValue(makeAgentInfo({ name: agentName, prompt: null })),
};
const ctx = createTestContext({
agentManager: mockManager as any,
workspaceRoot: tmpDir,
});
const caller = createCaller(ctx);
const result = await caller.getAgentPrompt({ id: 'agent-1' });
expect(result).toEqual({ content: fileContent });
});
});

View File

@@ -342,6 +342,22 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
.query(async ({ ctx, input }) => {
const agent = await resolveAgent(ctx, { id: input.id });
const MAX_BYTES = 1024 * 1024; // 1 MB
function truncateIfNeeded(text: string): string {
if (Buffer.byteLength(text, 'utf-8') > MAX_BYTES) {
const buf = Buffer.from(text, 'utf-8');
return buf.slice(0, MAX_BYTES).toString('utf-8') + '\n\n[truncated — prompt exceeds 1 MB]';
}
return text;
}
// Prefer DB-persisted prompt (durable even after log file cleanup)
if (agent.prompt !== null) {
return { content: truncateIfNeeded(agent.prompt) };
}
// Fall back to filesystem for agents spawned before DB persistence was added
const promptPath = path.join(ctx.workspaceRoot!, '.cw', 'agent-logs', agent.name, 'PROMPT.md');
let raw: string;
@@ -357,13 +373,7 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
});
}
const MAX_BYTES = 1024 * 1024; // 1 MB
if (Buffer.byteLength(raw, 'utf-8') > MAX_BYTES) {
const buf = Buffer.from(raw, 'utf-8');
raw = buf.slice(0, MAX_BYTES).toString('utf-8') + '\n\n[truncated — prompt exceeds 1 MB]';
}
return { content: raw };
return { content: truncateIfNeeded(raw) };
}),
};
}