Merge branch 'refs/heads/main' into cw/agent-details-conflict-1772799979862
# Conflicts: # apps/server/drizzle/meta/_journal.json
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
import { promisify } from 'node:util';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile, readdir, rm, cp, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||
@@ -49,10 +49,35 @@ export class CleanupManager {
|
||||
*/
|
||||
private resolveAgentCwd(worktreeId: string): string {
|
||||
const base = this.getAgentWorkdir(worktreeId);
|
||||
|
||||
// Fast path: .cw/output exists at the base level
|
||||
if (existsSync(join(base, '.cw', 'output'))) {
|
||||
return base;
|
||||
}
|
||||
|
||||
// Standalone agents use a workspace/ subdirectory
|
||||
const workspaceSub = join(base, 'workspace');
|
||||
if (!existsSync(join(base, '.cw', 'output')) && existsSync(join(workspaceSub, '.cw'))) {
|
||||
if (existsSync(join(workspaceSub, '.cw'))) {
|
||||
return workspaceSub;
|
||||
}
|
||||
|
||||
// Initiative-based agents may have written .cw/ inside a project
|
||||
// subdirectory (e.g. agent-workdirs/<name>/codewalk-district/.cw/).
|
||||
// Probe immediate children for a .cw/output directory.
|
||||
try {
|
||||
const entries = readdirSync(base, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name !== '.cw') {
|
||||
const projectSub = join(base, entry.name);
|
||||
if (existsSync(join(projectSub, '.cw', 'output'))) {
|
||||
return projectSub;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// base dir may not exist
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('writeInputFiles', () => {
|
||||
name: 'Phase One',
|
||||
content: 'First phase',
|
||||
status: 'pending',
|
||||
mergeBase: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as Phase;
|
||||
|
||||
@@ -397,6 +397,34 @@ export async function readDecisionFiles(agentWorkdir: string): Promise<ParsedDec
|
||||
});
|
||||
}
|
||||
|
||||
export interface ParsedCommentResponse {
|
||||
commentId: string;
|
||||
body: string;
|
||||
resolved?: boolean;
|
||||
}
|
||||
|
||||
export async function readCommentResponses(agentWorkdir: string): Promise<ParsedCommentResponse[]> {
|
||||
const filePath = join(agentWorkdir, '.cw', 'output', 'comment-responses.json');
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.filter((entry: unknown) => {
|
||||
if (typeof entry !== 'object' || entry === null) return false;
|
||||
const e = entry as Record<string, unknown>;
|
||||
return typeof e.commentId === 'string' && typeof e.body === 'string';
|
||||
})
|
||||
.map((entry: Record<string, unknown>) => ({
|
||||
commentId: String(entry.commentId),
|
||||
body: String(entry.body),
|
||||
resolved: typeof entry.resolved === 'boolean' ? entry.resolved : undefined,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPageFiles(agentWorkdir: string): Promise<ParsedPageFile[]> {
|
||||
const dirPath = join(agentWorkdir, '.cw', 'output', 'pages');
|
||||
return readFrontmatterDir(dirPath, (data, body, filename) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { PageRepository } from '../db/repositories/page-repository.js';
|
||||
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
|
||||
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
|
||||
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
|
||||
import { generateUniqueAlias } from './alias.js';
|
||||
import type {
|
||||
EventBus,
|
||||
@@ -42,7 +43,7 @@ import { getProvider } from './providers/registry.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
import { getProjectCloneDir } from '../git/project-clones.js';
|
||||
import { join } from 'node:path';
|
||||
import { unlink, readFile, writeFile as writeFileAsync } from 'node:fs/promises';
|
||||
import { unlink, readFile, writeFile as writeFileAsync, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import type { AccountCredentialManager } from './credentials/types.js';
|
||||
import { ProcessManager } from './process-manager.js';
|
||||
@@ -84,11 +85,12 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
private debug: boolean = false,
|
||||
processManagerOverride?: ProcessManager,
|
||||
private chatSessionRepository?: ChatSessionRepository,
|
||||
private reviewCommentRepository?: ReviewCommentRepository,
|
||||
) {
|
||||
this.signalManager = new FileSystemSignalManager();
|
||||
this.processManager = processManagerOverride ?? new ProcessManager(workspaceRoot, projectRepository);
|
||||
this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager);
|
||||
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager, chatSessionRepository);
|
||||
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager, chatSessionRepository, reviewCommentRepository);
|
||||
this.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager);
|
||||
this.lifecycleController = createLifecycleController({
|
||||
repository,
|
||||
@@ -295,6 +297,10 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
if (options.inputContext) {
|
||||
await writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext, agentId, agentName: alias });
|
||||
log.debug({ alias }, 'input files written');
|
||||
} else {
|
||||
// Always create .cw/output/ at the agent workdir root so the agent
|
||||
// writes signal.json here rather than in a project subdirectory.
|
||||
await mkdir(join(agentCwd, '.cw', 'output'), { recursive: true });
|
||||
}
|
||||
|
||||
// 4. Build spawn command
|
||||
@@ -330,32 +336,10 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
|
||||
await this.repository.update(agentId, { pid, outputFilePath, prompt });
|
||||
|
||||
// Write spawn diagnostic file for post-execution verification
|
||||
const diagnostic = {
|
||||
timestamp: new Date().toISOString(),
|
||||
agentId,
|
||||
alias,
|
||||
intendedCwd: finalCwd,
|
||||
worktreeId: agent.worktreeId,
|
||||
provider: providerName,
|
||||
command,
|
||||
args,
|
||||
env: processEnv,
|
||||
cwdExistsAtSpawn: existsSync(finalCwd),
|
||||
initiativeId: initiativeId || null,
|
||||
customCwdProvided: !!cwd,
|
||||
accountId: accountId || null,
|
||||
};
|
||||
|
||||
await writeFileAsync(
|
||||
join(finalCwd, '.cw', 'spawn-diagnostic.json'),
|
||||
JSON.stringify(diagnostic, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Register agent and start polling BEFORE non-critical I/O so that a
|
||||
// diagnostic-write failure can never orphan a running process.
|
||||
const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath, agentCwd: finalCwd };
|
||||
this.activeAgents.set(agentId, activeEntry);
|
||||
log.info({ agentId, alias, pid, diagnosticWritten: true }, 'detached subprocess started with diagnostic');
|
||||
|
||||
// Emit spawned event
|
||||
if (this.eventBus) {
|
||||
@@ -375,6 +359,37 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
);
|
||||
activeEntry.cancelPoll = cancel;
|
||||
|
||||
// Write spawn diagnostic file (non-fatal — .cw/ may not exist yet for
|
||||
// agents spawned without inputContext, e.g. conflict-resolution agents)
|
||||
try {
|
||||
const diagnosticDir = join(finalCwd, '.cw');
|
||||
await mkdir(diagnosticDir, { recursive: true });
|
||||
const diagnostic = {
|
||||
timestamp: new Date().toISOString(),
|
||||
agentId,
|
||||
alias,
|
||||
intendedCwd: finalCwd,
|
||||
worktreeId: agent.worktreeId,
|
||||
provider: providerName,
|
||||
command,
|
||||
args,
|
||||
env: processEnv,
|
||||
cwdExistsAtSpawn: existsSync(finalCwd),
|
||||
initiativeId: initiativeId || null,
|
||||
customCwdProvided: !!cwd,
|
||||
accountId: accountId || null,
|
||||
};
|
||||
await writeFileAsync(
|
||||
join(diagnosticDir, 'spawn-diagnostic.json'),
|
||||
JSON.stringify(diagnostic, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn({ agentId, alias, err: err instanceof Error ? err.message : String(err) }, 'failed to write spawn diagnostic');
|
||||
}
|
||||
|
||||
log.info({ agentId, alias, pid }, 'detached subprocess started');
|
||||
|
||||
return this.toAgentInfo(agent);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
||||
import type { ChangeSetRepository, CreateChangeSetEntryData } from '../db/repositories/change-set-repository.js';
|
||||
@@ -15,6 +15,7 @@ import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { PageRepository } from '../db/repositories/page-repository.js';
|
||||
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
|
||||
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
|
||||
import type {
|
||||
EventBus,
|
||||
AgentStoppedEvent,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
readDecisionFiles,
|
||||
readPageFiles,
|
||||
readFrontmatterFile,
|
||||
readCommentResponses,
|
||||
} from './file-io.js';
|
||||
import { getProvider } from './providers/registry.js';
|
||||
import { markdownToTiptapJson } from './markdown-to-tiptap.js';
|
||||
@@ -92,6 +94,7 @@ export class OutputHandler {
|
||||
private pageRepository?: PageRepository,
|
||||
private signalManager?: SignalManager,
|
||||
private chatSessionRepository?: ChatSessionRepository,
|
||||
private reviewCommentRepository?: ReviewCommentRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -230,10 +233,10 @@ export class OutputHandler {
|
||||
|
||||
log.debug({ agentId }, 'detached agent completed');
|
||||
|
||||
// Resolve actual agent working directory — standalone agents run in a
|
||||
// "workspace/" subdirectory inside getAgentWorkdir, so prefer agentCwd
|
||||
// recorded at spawn time when available.
|
||||
const agentWorkdir = active?.agentCwd ?? getAgentWorkdir(agent.worktreeId);
|
||||
// Resolve actual agent working directory.
|
||||
// The recorded agentCwd may be the parent dir (agent-workdirs/<name>/) while
|
||||
// the agent actually writes .cw/output/ inside a project subdirectory.
|
||||
const agentWorkdir = this.resolveAgentWorkdir(active?.agentCwd ?? getAgentWorkdir(agent.worktreeId));
|
||||
const outputDir = join(agentWorkdir, '.cw', 'output');
|
||||
const expectedPwdFile = join(agentWorkdir, '.cw', 'expected-pwd.txt');
|
||||
const diagnosticFile = join(agentWorkdir, '.cw', 'spawn-diagnostic.json');
|
||||
@@ -851,6 +854,28 @@ export class OutputHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Process comment responses from agent (for review/execute tasks)
|
||||
if (this.reviewCommentRepository) {
|
||||
try {
|
||||
const commentResponses = await readCommentResponses(agentWorkdir);
|
||||
for (const resp of commentResponses) {
|
||||
try {
|
||||
await this.reviewCommentRepository.createReply(resp.commentId, resp.body, 'agent');
|
||||
if (resp.resolved) {
|
||||
await this.reviewCommentRepository.resolve(resp.commentId);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ agentId, commentId: resp.commentId, err: err instanceof Error ? err.message : String(err) }, 'failed to process comment response');
|
||||
}
|
||||
}
|
||||
if (commentResponses.length > 0) {
|
||||
log.info({ agentId, count: commentResponses.length }, 'processed agent comment responses');
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to read comment responses');
|
||||
}
|
||||
}
|
||||
|
||||
const resultPayload: AgentResult = {
|
||||
success: true,
|
||||
message: resultMessage,
|
||||
@@ -1133,6 +1158,31 @@ export class OutputHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the actual agent working directory. The recorded agentCwd may be
|
||||
* the parent (agent-workdirs/<name>/) but .cw/output/ could be inside a
|
||||
* project subdirectory (e.g. codewalk-district/.cw/output/).
|
||||
*/
|
||||
private resolveAgentWorkdir(base: string): string {
|
||||
if (existsSync(join(base, '.cw', 'output'))) return base;
|
||||
|
||||
// Standalone agents: workspace/ subdirectory
|
||||
const workspaceSub = join(base, 'workspace');
|
||||
if (existsSync(join(workspaceSub, '.cw'))) return workspaceSub;
|
||||
|
||||
// Initiative-based agents: probe project subdirectories
|
||||
try {
|
||||
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name !== '.cw') {
|
||||
const sub = join(base, entry.name);
|
||||
if (existsSync(join(sub, '.cw', 'output'))) return sub;
|
||||
}
|
||||
}
|
||||
} catch { /* base may not exist */ }
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
private emitCrashed(agent: { id: string; name: string; taskId: string | null }, error: string): void {
|
||||
if (this.eventBus) {
|
||||
const event: AgentCrashedEvent = {
|
||||
|
||||
77
apps/server/agent/prompts/conflict-resolution.ts
Normal file
77
apps/server/agent/prompts/conflict-resolution.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Conflict resolution prompt — spawned when initiative branch has merge conflicts
|
||||
* with the target branch.
|
||||
*/
|
||||
|
||||
import {
|
||||
SIGNAL_FORMAT,
|
||||
SESSION_STARTUP,
|
||||
GIT_WORKFLOW,
|
||||
CONTEXT_MANAGEMENT,
|
||||
} from './shared.js';
|
||||
|
||||
export function buildConflictResolutionPrompt(
|
||||
sourceBranch: string,
|
||||
targetBranch: string,
|
||||
conflicts: string[],
|
||||
): string {
|
||||
const conflictList = conflicts.map(f => `- \`${f}\``).join('\n');
|
||||
|
||||
return `<role>
|
||||
You are a Conflict Resolution agent. Your job is to merge \`${targetBranch}\` into the initiative branch \`${sourceBranch}\` and resolve all merge conflicts. You are working on a temporary branch created from \`${sourceBranch}\`. After resolving conflicts and committing, you must advance the initiative branch pointer using \`git update-ref\`.
|
||||
</role>
|
||||
|
||||
<conflict_details>
|
||||
**Source branch (initiative):** \`${sourceBranch}\`
|
||||
**Target branch (default):** \`${targetBranch}\`
|
||||
|
||||
**Conflicting files:**
|
||||
${conflictList}
|
||||
</conflict_details>
|
||||
${SIGNAL_FORMAT}
|
||||
${SESSION_STARTUP}
|
||||
|
||||
<resolution_protocol>
|
||||
Follow these steps in order:
|
||||
|
||||
1. **Inspect divergence**: Run \`git log --oneline ${targetBranch}..${sourceBranch}\` and \`git log --oneline ${sourceBranch}..${targetBranch}\` to understand what each side changed.
|
||||
|
||||
2. **Review conflicting files**: For each conflicting file, read both versions:
|
||||
- \`git show ${sourceBranch}:<file>\`
|
||||
- \`git show ${targetBranch}:<file>\`
|
||||
|
||||
3. **Merge**: Run \`git merge ${targetBranch} --no-edit\`. This will produce conflict markers.
|
||||
|
||||
4. **Resolve each file**: For each conflicting file:
|
||||
- Read the file to see conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`)
|
||||
- Understand both sides' intent from step 1-2
|
||||
- Choose the correct resolution — keep both changes when they don't overlap, prefer the more complete version when they do
|
||||
- If you genuinely cannot determine the correct resolution, signal "questions" explaining the ambiguity
|
||||
|
||||
5. **Verify**: Run \`git diff --check\` to confirm no conflict markers remain. Run the test suite to confirm nothing is broken.
|
||||
|
||||
6. **Commit**: Stage resolved files with \`git add <file>\` (never \`git add .\`), then \`git commit --no-edit\` to complete the merge commit.
|
||||
|
||||
7. **Update initiative branch**: Run \`git update-ref refs/heads/${sourceBranch} HEAD\` to advance the initiative branch to include the merge result. This is necessary because you are working on a temporary branch — this command propagates the merge commit to the actual initiative branch.
|
||||
|
||||
8. **Signal done**: Write signal.json with status "done".
|
||||
</resolution_protocol>
|
||||
${GIT_WORKFLOW}
|
||||
${CONTEXT_MANAGEMENT}
|
||||
|
||||
<important>
|
||||
- You are on a temporary branch created from ${sourceBranch}. You are merging ${targetBranch} INTO this branch — bringing it up to date, NOT the other way around.
|
||||
- After committing the merge, you MUST run \`git update-ref refs/heads/${sourceBranch} HEAD\` to advance the initiative branch pointer. Without this step, the initiative branch will not reflect the merge.
|
||||
- Do NOT force-push or rebase. A merge commit is the correct approach.
|
||||
- If tests fail after resolution, fix the code — don't skip tests.
|
||||
- If a conflict is genuinely ambiguous (e.g., both sides rewrote the same function differently), signal "questions" with the specific ambiguity and your proposed resolution.
|
||||
</important>`;
|
||||
}
|
||||
|
||||
export function buildConflictResolutionDescription(
|
||||
sourceBranch: string,
|
||||
targetBranch: string,
|
||||
conflicts: string[],
|
||||
): string {
|
||||
return `Resolve ${conflicts.length} merge conflict(s) between ${sourceBranch} and ${targetBranch}: ${conflicts.join(', ')}`;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ ${CODEBASE_EXPLORATION}
|
||||
|
||||
<output_format>
|
||||
Write one file per task to \`.cw/output/tasks/{id}.md\`:
|
||||
- Frontmatter: \`title\`, \`category\` (execute|research|discuss|plan|detail|refine|verify|merge|review), \`type\` (auto|checkpoint:human-verify|checkpoint:decision|checkpoint:human-action), \`dependencies\` (list of task IDs that must complete before this task can start)
|
||||
- Frontmatter: \`title\`, \`category\` (execute|research|discuss|plan|detail|refine|verify|merge|review), \`dependencies\` (list of task IDs that must complete before this task can start)
|
||||
- Body: Detailed task description
|
||||
</output_format>
|
||||
|
||||
@@ -92,14 +92,6 @@ Each task is handled by a separate agent that must load the full codebase contex
|
||||
Bundle related changes into one task. "Add user validation" + "Add user API route" + "Add user route tests" is ONE task ("Add user creation endpoint with validation and tests"), not three.
|
||||
</task_sizing>
|
||||
|
||||
<checkpoint_tasks>
|
||||
- \`checkpoint:human-verify\`: Visual changes, migrations, API contracts
|
||||
- \`checkpoint:decision\`: Architecture choices affecting multiple phases
|
||||
- \`checkpoint:human-action\`: External setup (DNS, credentials, third-party config)
|
||||
|
||||
~90% of tasks should be \`auto\`.
|
||||
</checkpoint_tasks>
|
||||
|
||||
<existing_context>
|
||||
- Read ALL \`context/tasks/\` files before generating output
|
||||
- Only create tasks for THIS phase (\`phase.md\`)
|
||||
|
||||
@@ -14,13 +14,26 @@ import {
|
||||
} from './shared.js';
|
||||
|
||||
export function buildExecutePrompt(taskDescription?: string): string {
|
||||
const hasReviewComments = taskDescription?.includes('[comment:');
|
||||
const reviewCommentsSection = hasReviewComments
|
||||
? `
|
||||
<review_comments>
|
||||
You are addressing review feedback. Each comment is tagged with [comment:ID].
|
||||
For EACH comment you address:
|
||||
1. Fix the issue in code, OR explain why no change is needed.
|
||||
2. Write \`.cw/output/comment-responses.json\`:
|
||||
[{"commentId": "abc123", "body": "Fixed: added try-catch around token validation", "resolved": true}]
|
||||
Set resolved:true when you fixed it, false when you're explaining why you didn't.
|
||||
</review_comments>`
|
||||
: '';
|
||||
|
||||
const taskSection = taskDescription
|
||||
? `
|
||||
<task>
|
||||
${taskDescription}
|
||||
|
||||
Read \`.cw/input/task.md\` for the full structured task with metadata, priority, and dependencies.
|
||||
</task>`
|
||||
</task>${reviewCommentsSection}`
|
||||
: '';
|
||||
|
||||
return `<role>
|
||||
|
||||
@@ -15,3 +15,4 @@ export { buildChatPrompt } from './chat.js';
|
||||
export type { ChatHistoryEntry } from './chat.js';
|
||||
export { buildWorkspaceLayout } from './workspace.js';
|
||||
export { buildPreviewInstructions } from './preview.js';
|
||||
export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js';
|
||||
|
||||
@@ -36,5 +36,7 @@ This is an isolated git worktree. Other agents may be working in parallel on sep
|
||||
The following project directories contain the source code (git worktrees):
|
||||
|
||||
${lines.join('\n')}
|
||||
|
||||
**IMPORTANT**: All \`.cw/output/\` paths (signal.json, progress.md, etc.) are relative to this working directory (\`${agentCwd}\`), NOT to any project subdirectory. Always write to \`${join(agentCwd, '.cw/output/')}\` regardless of your current \`cd\` location.
|
||||
</workspace>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user