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

@@ -328,7 +328,7 @@ export class MultiProviderAgentManager implements AgentManager {
this.createLogChunkCallback(agentId, alias, 1),
);
await this.repository.update(agentId, { pid, outputFilePath });
await this.repository.update(agentId, { pid, outputFilePath, prompt });
// Write spawn diagnostic file for post-execution verification
const diagnostic = {
@@ -1086,6 +1086,7 @@ export class MultiProviderAgentManager implements AgentManager {
updatedAt: Date;
userDismissedAt?: Date | null;
exitCode?: number | null;
prompt?: string | null;
}): AgentInfo {
return {
id: agent.id,
@@ -1102,6 +1103,7 @@ export class MultiProviderAgentManager implements AgentManager {
updatedAt: agent.updatedAt,
userDismissedAt: agent.userDismissedAt,
exitCode: agent.exitCode ?? null,
prompt: agent.prompt ?? null,
};
}
}

View File

@@ -143,6 +143,7 @@ export class MockAgentManager implements AgentManager {
createdAt: now,
updatedAt: now,
exitCode: null,
prompt: null,
};
const record: MockAgentRecord = {

View File

@@ -95,6 +95,8 @@ export interface AgentInfo {
userDismissedAt?: Date | null;
/** Process exit code — null while running or if not yet exited */
exitCode: number | null;
/** Full assembled prompt passed to the agent process — null for agents spawned before DB persistence */
prompt: string | null;
}
/**

View File

@@ -45,6 +45,7 @@ export interface UpdateAgentData {
accountId?: string | null;
pid?: number | null;
exitCode?: number | null;
prompt?: string | null;
outputFilePath?: string | null;
result?: string | null;
pendingQuestions?: string | null;

View File

@@ -265,6 +265,7 @@ export const agents = sqliteTable('agents', {
.default('execute'),
pid: integer('pid'),
exitCode: integer('exit_code'), // Process exit code for debugging crashes
prompt: text('prompt'), // Full assembled prompt passed to the agent process (persisted for durability after log cleanup)
outputFilePath: text('output_file_path'),
result: text('result'),
pendingQuestions: text('pending_questions'),

View File

@@ -71,6 +71,7 @@ function createMockAgentManager(
createdAt: new Date(),
updatedAt: new Date(),
exitCode: null,
prompt: null,
};
mockAgents.push(newAgent);
return newAgent;
@@ -103,6 +104,7 @@ function createIdleAgent(id: string, name: string): AgentInfo {
createdAt: new Date(),
updatedAt: new Date(),
exitCode: null,
prompt: null,
};
}

View File

@@ -0,0 +1 @@
ALTER TABLE `agents` ADD `prompt` text;

File diff suppressed because it is too large Load Diff

View File

@@ -218,6 +218,13 @@
"when": 1772150400000,
"tag": "0030_remove_task_approval",
"breakpoints": true
},
{
"idx": 31,
"version": "6",
"when": 1772798869413,
"tag": "0031_icy_silvermane",
"breakpoints": true
}
]
}

View File

@@ -32,6 +32,7 @@ interface TestAgent {
initiativeId: string | null;
userDismissedAt: Date | null;
exitCode: number | null;
prompt: string | null;
}
describe('Crash marking race condition', () => {
@@ -72,7 +73,8 @@ describe('Crash marking race condition', () => {
pendingQuestions: null,
initiativeId: 'init-1',
userDismissedAt: null,
exitCode: null
exitCode: null,
prompt: null,
};
// Mock repository that tracks all update calls

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) };
}),
};
}

View File

@@ -70,6 +70,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
| mode | text enum | 'execute' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' |
| pid | integer nullable | OS process ID |
| exitCode | integer nullable | |
| prompt | text nullable | Full assembled prompt passed to agent at spawn; persisted for durability after log cleanup |
| outputFilePath | text nullable | |
| result | text nullable | JSON |
| pendingQuestions | text nullable | JSON |

View File

@@ -64,7 +64,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| getAgentQuestions | query | Pending questions |
| getAgentOutput | query | Full output from DB log chunks |
| getAgentInputFiles | query | Files written to agent's `.cw/input/` dir (text only, sorted, 500 KB cap) |
| getAgentPrompt | query | Content of `.cw/agent-logs/<name>/PROMPT.md` (1 MB cap) |
| getAgentPrompt | query | Assembled prompt — reads from DB (`agents.prompt`) first; falls back to `.cw/agent-logs/<name>/PROMPT.md` for pre-persistence agents (1 MB cap) |
| getActiveRefineAgent | query | Active refine agent for initiative |
| listWaitingAgents | query | Agents waiting for input |
| onAgentOutput | subscription | Live raw JSONL output stream via EventBus |