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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ export class MockAgentManager implements AgentManager {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
exitCode: null,
|
||||
prompt: null,
|
||||
};
|
||||
|
||||
const record: MockAgentRecord = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
1
apps/server/drizzle/0031_icy_silvermane.sql
Normal file
1
apps/server/drizzle/0031_icy_silvermane.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `agents` ADD `prompt` text;
|
||||
1159
apps/server/drizzle/meta/0031_snapshot.json
Normal file
1159
apps/server/drizzle/meta/0031_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user