Merge branch 'refs/heads/main' into cw/agent-details-conflict-1772799979862

# Conflicts:
#	apps/server/drizzle/meta/_journal.json
This commit is contained in:
Lukas May
2026-03-06 13:34:28 +01:00
82 changed files with 7164 additions and 537 deletions

View File

@@ -24,7 +24,7 @@ Pre-implementation design docs are archived in `docs/archive/`.
## Key Rules ## Key Rules
- **Database**: Never use raw SQL for schema initialization. Use `drizzle-kit generate` and the migration system. See [docs/database-migrations.md](docs/database-migrations.md). - **Database migrations**: Edit `apps/server/db/schema.ts`, then run `npx drizzle-kit generate`. Multi-statement migrations need `--> statement-breakpoint` between statements. See [docs/database-migrations.md](docs/database-migrations.md).
- **Logging**: Use `createModuleLogger()` from `apps/server/logger/index.ts`. Keep `console.log` for CLI user-facing output only. - **Logging**: Use `createModuleLogger()` from `apps/server/logger/index.ts`. Keep `console.log` for CLI user-facing output only.
- **Hexagonal architecture**: Repository ports in `apps/server/db/repositories/*.ts`, Drizzle adapters in `apps/server/db/repositories/drizzle/*.ts`. All re-exported from `apps/server/db/index.ts`. - **Hexagonal architecture**: Repository ports in `apps/server/db/repositories/*.ts`, Drizzle adapters in `apps/server/db/repositories/drizzle/*.ts`. All re-exported from `apps/server/db/index.ts`.
- **tRPC context**: Optional repos accessed via `require*Repository()` helpers in `apps/server/trpc/routers/_helpers.ts`. - **tRPC context**: Optional repos accessed via `require*Repository()` helpers in `apps/server/trpc/routers/_helpers.ts`.

View File

@@ -8,7 +8,7 @@
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { readFile, readdir, rm, cp, mkdir } from 'node:fs/promises'; 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 { join } from 'node:path';
import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js';
@@ -49,10 +49,35 @@ export class CleanupManager {
*/ */
private resolveAgentCwd(worktreeId: string): string { private resolveAgentCwd(worktreeId: string): string {
const base = this.getAgentWorkdir(worktreeId); 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'); const workspaceSub = join(base, 'workspace');
if (!existsSync(join(base, '.cw', 'output')) && existsSync(join(workspaceSub, '.cw'))) { if (existsSync(join(workspaceSub, '.cw'))) {
return workspaceSub; 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; return base;
} }

View File

@@ -68,6 +68,7 @@ describe('writeInputFiles', () => {
name: 'Phase One', name: 'Phase One',
content: 'First phase', content: 'First phase',
status: 'pending', status: 'pending',
mergeBase: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
} as Phase; } as Phase;

View File

@@ -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[]> { export async function readPageFiles(agentWorkdir: string): Promise<ParsedPageFile[]> {
const dirPath = join(agentWorkdir, '.cw', 'output', 'pages'); const dirPath = join(agentWorkdir, '.cw', 'output', 'pages');
return readFrontmatterDir(dirPath, (data, body, filename) => { return readFrontmatterDir(dirPath, (data, body, filename) => {

View File

@@ -27,6 +27,7 @@ import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js'; import type { PageRepository } from '../db/repositories/page-repository.js';
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js'; import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-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 { generateUniqueAlias } from './alias.js';
import type { import type {
EventBus, EventBus,
@@ -42,7 +43,7 @@ import { getProvider } from './providers/registry.js';
import { createModuleLogger } from '../logger/index.js'; import { createModuleLogger } from '../logger/index.js';
import { getProjectCloneDir } from '../git/project-clones.js'; import { getProjectCloneDir } from '../git/project-clones.js';
import { join } from 'node:path'; 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 { existsSync } from 'node:fs';
import type { AccountCredentialManager } from './credentials/types.js'; import type { AccountCredentialManager } from './credentials/types.js';
import { ProcessManager } from './process-manager.js'; import { ProcessManager } from './process-manager.js';
@@ -84,11 +85,12 @@ export class MultiProviderAgentManager implements AgentManager {
private debug: boolean = false, private debug: boolean = false,
processManagerOverride?: ProcessManager, processManagerOverride?: ProcessManager,
private chatSessionRepository?: ChatSessionRepository, private chatSessionRepository?: ChatSessionRepository,
private reviewCommentRepository?: ReviewCommentRepository,
) { ) {
this.signalManager = new FileSystemSignalManager(); this.signalManager = new FileSystemSignalManager();
this.processManager = processManagerOverride ?? new ProcessManager(workspaceRoot, projectRepository); this.processManager = processManagerOverride ?? new ProcessManager(workspaceRoot, projectRepository);
this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager); 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.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager);
this.lifecycleController = createLifecycleController({ this.lifecycleController = createLifecycleController({
repository, repository,
@@ -295,6 +297,10 @@ export class MultiProviderAgentManager implements AgentManager {
if (options.inputContext) { if (options.inputContext) {
await writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext, agentId, agentName: alias }); await writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext, agentId, agentName: alias });
log.debug({ alias }, 'input files written'); 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 // 4. Build spawn command
@@ -330,32 +336,10 @@ export class MultiProviderAgentManager implements AgentManager {
await this.repository.update(agentId, { pid, outputFilePath, prompt }); await this.repository.update(agentId, { pid, outputFilePath, prompt });
// Write spawn diagnostic file for post-execution verification // Register agent and start polling BEFORE non-critical I/O so that a
const diagnostic = { // diagnostic-write failure can never orphan a running process.
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'
);
const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath, agentCwd: finalCwd }; const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath, agentCwd: finalCwd };
this.activeAgents.set(agentId, activeEntry); this.activeAgents.set(agentId, activeEntry);
log.info({ agentId, alias, pid, diagnosticWritten: true }, 'detached subprocess started with diagnostic');
// Emit spawned event // Emit spawned event
if (this.eventBus) { if (this.eventBus) {
@@ -375,6 +359,37 @@ export class MultiProviderAgentManager implements AgentManager {
); );
activeEntry.cancelPoll = cancel; 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); return this.toAgentInfo(agent);
} }

View File

@@ -7,7 +7,7 @@
*/ */
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { ChangeSetRepository, CreateChangeSetEntryData } from '../db/repositories/change-set-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 { TaskRepository } from '../db/repositories/task-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js'; import type { PageRepository } from '../db/repositories/page-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js'; import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
import type { import type {
EventBus, EventBus,
AgentStoppedEvent, AgentStoppedEvent,
@@ -37,6 +38,7 @@ import {
readDecisionFiles, readDecisionFiles,
readPageFiles, readPageFiles,
readFrontmatterFile, readFrontmatterFile,
readCommentResponses,
} from './file-io.js'; } from './file-io.js';
import { getProvider } from './providers/registry.js'; import { getProvider } from './providers/registry.js';
import { markdownToTiptapJson } from './markdown-to-tiptap.js'; import { markdownToTiptapJson } from './markdown-to-tiptap.js';
@@ -92,6 +94,7 @@ export class OutputHandler {
private pageRepository?: PageRepository, private pageRepository?: PageRepository,
private signalManager?: SignalManager, private signalManager?: SignalManager,
private chatSessionRepository?: ChatSessionRepository, private chatSessionRepository?: ChatSessionRepository,
private reviewCommentRepository?: ReviewCommentRepository,
) {} ) {}
/** /**
@@ -230,10 +233,10 @@ export class OutputHandler {
log.debug({ agentId }, 'detached agent completed'); log.debug({ agentId }, 'detached agent completed');
// Resolve actual agent working directory — standalone agents run in a // Resolve actual agent working directory.
// "workspace/" subdirectory inside getAgentWorkdir, so prefer agentCwd // The recorded agentCwd may be the parent dir (agent-workdirs/<name>/) while
// recorded at spawn time when available. // the agent actually writes .cw/output/ inside a project subdirectory.
const agentWorkdir = active?.agentCwd ?? getAgentWorkdir(agent.worktreeId); const agentWorkdir = this.resolveAgentWorkdir(active?.agentCwd ?? getAgentWorkdir(agent.worktreeId));
const outputDir = join(agentWorkdir, '.cw', 'output'); const outputDir = join(agentWorkdir, '.cw', 'output');
const expectedPwdFile = join(agentWorkdir, '.cw', 'expected-pwd.txt'); const expectedPwdFile = join(agentWorkdir, '.cw', 'expected-pwd.txt');
const diagnosticFile = join(agentWorkdir, '.cw', 'spawn-diagnostic.json'); 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 = { const resultPayload: AgentResult = {
success: true, success: true,
message: resultMessage, 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 { private emitCrashed(agent: { id: string; name: string; taskId: string | null }, error: string): void {
if (this.eventBus) { if (this.eventBus) {
const event: AgentCrashedEvent = { const event: AgentCrashedEvent = {

View 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(', ')}`;
}

View File

@@ -13,7 +13,7 @@ ${CODEBASE_EXPLORATION}
<output_format> <output_format>
Write one file per task to \`.cw/output/tasks/{id}.md\`: 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 - Body: Detailed task description
</output_format> </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. 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> </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> <existing_context>
- Read ALL \`context/tasks/\` files before generating output - Read ALL \`context/tasks/\` files before generating output
- Only create tasks for THIS phase (\`phase.md\`) - Only create tasks for THIS phase (\`phase.md\`)

View File

@@ -14,13 +14,26 @@ import {
} from './shared.js'; } from './shared.js';
export function buildExecutePrompt(taskDescription?: string): string { 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 const taskSection = taskDescription
? ` ? `
<task> <task>
${taskDescription} ${taskDescription}
Read \`.cw/input/task.md\` for the full structured task with metadata, priority, and dependencies. Read \`.cw/input/task.md\` for the full structured task with metadata, priority, and dependencies.
</task>` </task>${reviewCommentsSection}`
: ''; : '';
return `<role> return `<role>

View File

@@ -15,3 +15,4 @@ export { buildChatPrompt } from './chat.js';
export type { ChatHistoryEntry } from './chat.js'; export type { ChatHistoryEntry } from './chat.js';
export { buildWorkspaceLayout } from './workspace.js'; export { buildWorkspaceLayout } from './workspace.js';
export { buildPreviewInstructions } from './preview.js'; export { buildPreviewInstructions } from './preview.js';
export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js';

View File

@@ -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): The following project directories contain the source code (git worktrees):
${lines.join('\n')} ${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>`; </workspace>`;
} }

View File

@@ -183,13 +183,10 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
options?.debug ?? false, options?.debug ?? false,
undefined, // processManagerOverride undefined, // processManagerOverride
repos.chatSessionRepository, repos.chatSessionRepository,
repos.reviewCommentRepository,
); );
log.info('agent manager created'); log.info('agent manager created');
// Reconcile agent state from any previous server session
await agentManager.reconcileAfterRestart();
log.info('agent reconciliation complete');
// Branch manager // Branch manager
const branchManager = new SimpleGitBranchManager(); const branchManager = new SimpleGitBranchManager();
log.info('branch manager created'); log.info('branch manager created');
@@ -249,10 +246,17 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
conflictResolutionService, conflictResolutionService,
eventBus, eventBus,
workspaceRoot, workspaceRoot,
repos.agentRepository,
); );
executionOrchestrator.start(); executionOrchestrator.start();
log.info('execution orchestrator started'); log.info('execution orchestrator started');
// Reconcile agent state from any previous server session.
// Must run AFTER orchestrator.start() so event listeners are registered
// and agent:stopped / agent:crashed events are not lost.
await agentManager.reconcileAfterRestart();
log.info('agent reconciliation complete');
// Preview manager // Preview manager
const previewManager = new PreviewManager( const previewManager = new PreviewManager(
repos.projectRepository, repos.projectRepository,

View File

@@ -33,4 +33,10 @@ export interface ChangeSetRepository {
findByInitiativeId(initiativeId: string): Promise<ChangeSet[]>; findByInitiativeId(initiativeId: string): Promise<ChangeSet[]>;
findByAgentId(agentId: string): Promise<ChangeSet[]>; findByAgentId(agentId: string): Promise<ChangeSet[]>;
markReverted(id: string): Promise<ChangeSet>; markReverted(id: string): Promise<ChangeSet>;
/**
* Find applied changesets that have a 'create' entry for the given entity.
* Used to reconcile changeset status when entities are manually deleted.
*/
findAppliedByCreatedEntity(entityType: string, entityId: string): Promise<ChangeSetWithEntries[]>;
} }

View File

@@ -4,7 +4,7 @@
* Implements ChangeSetRepository interface using Drizzle ORM. * Implements ChangeSetRepository interface using Drizzle ORM.
*/ */
import { eq, desc, asc } from 'drizzle-orm'; import { eq, desc, asc, and } from 'drizzle-orm';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js'; import type { DrizzleDatabase } from '../../index.js';
import { changeSets, changeSetEntries, type ChangeSet } from '../../schema.js'; import { changeSets, changeSetEntries, type ChangeSet } from '../../schema.js';
@@ -94,6 +94,32 @@ export class DrizzleChangeSetRepository implements ChangeSetRepository {
.orderBy(desc(changeSets.createdAt)); .orderBy(desc(changeSets.createdAt));
} }
async findAppliedByCreatedEntity(entityType: string, entityId: string): Promise<ChangeSetWithEntries[]> {
// Find changeset entries matching the entity
const matchingEntries = await this.db
.select({ changeSetId: changeSetEntries.changeSetId })
.from(changeSetEntries)
.where(
and(
eq(changeSetEntries.entityType, entityType as any),
eq(changeSetEntries.entityId, entityId),
eq(changeSetEntries.action, 'create'),
),
);
const results: ChangeSetWithEntries[] = [];
const seen = new Set<string>();
for (const { changeSetId } of matchingEntries) {
if (seen.has(changeSetId)) continue;
seen.add(changeSetId);
const cs = await this.findByIdWithEntries(changeSetId);
if (cs && cs.status === 'applied') {
results.push(cs);
}
}
return results;
}
async markReverted(id: string): Promise<ChangeSet> { async markReverted(id: string): Promise<ChangeSet> {
const [updated] = await this.db const [updated] = await this.db
.update(changeSets) .update(changeSets)

View File

@@ -0,0 +1,336 @@
/**
* DrizzleErrandRepository Tests
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleErrandRepository } from './errand.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
import { projects, agents, errands } from '../../schema.js';
import { nanoid } from 'nanoid';
import { eq } from 'drizzle-orm';
describe('DrizzleErrandRepository', () => {
let db: DrizzleDatabase;
let repo: DrizzleErrandRepository;
beforeEach(() => {
db = createTestDatabase();
repo = new DrizzleErrandRepository(db);
});
// Helper: create a project record
async function createProject(name = 'Test Project', suffix = '') {
const id = nanoid();
const now = new Date();
const [project] = await db.insert(projects).values({
id,
name: name + suffix + id,
url: `https://github.com/test/${id}`,
defaultBranch: 'main',
createdAt: now,
updatedAt: now,
}).returning();
return project;
}
// Helper: create an agent record
async function createAgent(name?: string) {
const id = nanoid();
const now = new Date();
const agentName = name ?? `agent-${id}`;
const [agent] = await db.insert(agents).values({
id,
name: agentName,
worktreeId: `agent-workdirs/${agentName}`,
provider: 'claude',
status: 'idle',
mode: 'execute',
createdAt: now,
updatedAt: now,
}).returning();
return agent;
}
// Helper: create an errand
async function createErrand(overrides: Partial<{
id: string;
description: string;
branch: string;
baseBranch: string;
agentId: string | null;
projectId: string | null;
status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned';
createdAt: Date;
}> = {}) {
const project = await createProject();
const id = overrides.id ?? nanoid();
return repo.create({
id,
description: overrides.description ?? 'Test errand',
branch: overrides.branch ?? 'feature/test',
baseBranch: overrides.baseBranch ?? 'main',
agentId: overrides.agentId !== undefined ? overrides.agentId : null,
projectId: overrides.projectId !== undefined ? overrides.projectId : project.id,
status: overrides.status ?? 'active',
});
}
describe('create + findById', () => {
it('should create errand and find by id with all fields', async () => {
const project = await createProject();
const id = nanoid();
await repo.create({
id,
description: 'Fix the bug',
branch: 'fix/bug-123',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
});
const found = await repo.findById(id);
expect(found).toBeDefined();
expect(found!.id).toBe(id);
expect(found!.description).toBe('Fix the bug');
expect(found!.branch).toBe('fix/bug-123');
expect(found!.baseBranch).toBe('main');
expect(found!.status).toBe('active');
expect(found!.projectId).toBe(project.id);
expect(found!.agentId).toBeNull();
expect(found!.agentAlias).toBeNull();
});
});
describe('findAll', () => {
it('should return all errands ordered by createdAt desc', async () => {
const project = await createProject();
const t1 = new Date('2024-01-01T00:00:00Z');
const t2 = new Date('2024-01-02T00:00:00Z');
const t3 = new Date('2024-01-03T00:00:00Z');
const id1 = nanoid();
const id2 = nanoid();
const id3 = nanoid();
await db.insert(errands).values([
{ id: id1, description: 'Errand 1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t1, updatedAt: t1 },
{ id: id2, description: 'Errand 2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t2, updatedAt: t2 },
{ id: id3, description: 'Errand 3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: t3, updatedAt: t3 },
]);
const result = await repo.findAll();
expect(result.length).toBeGreaterThanOrEqual(3);
// Find our three in the results
const ids = result.map((e) => e.id);
expect(ids.indexOf(id3)).toBeLessThan(ids.indexOf(id2));
expect(ids.indexOf(id2)).toBeLessThan(ids.indexOf(id1));
});
it('should filter by projectId', async () => {
const projectA = await createProject('A');
const projectB = await createProject('B');
const now = new Date();
const idA1 = nanoid();
const idA2 = nanoid();
const idB1 = nanoid();
await db.insert(errands).values([
{ id: idA1, description: 'A1', branch: 'b-a1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now },
{ id: idA2, description: 'A2', branch: 'b-a2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now },
{ id: idB1, description: 'B1', branch: 'b-b1', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now },
]);
const result = await repo.findAll({ projectId: projectA.id });
expect(result).toHaveLength(2);
expect(result.map((e) => e.id).sort()).toEqual([idA1, idA2].sort());
});
it('should filter by status', async () => {
const project = await createProject();
const now = new Date();
const id1 = nanoid();
const id2 = nanoid();
const id3 = nanoid();
await db.insert(errands).values([
{ id: id1, description: 'E1', branch: 'b1', baseBranch: 'main', agentId: null, projectId: project.id, status: 'active', createdAt: now, updatedAt: now },
{ id: id2, description: 'E2', branch: 'b2', baseBranch: 'main', agentId: null, projectId: project.id, status: 'pending_review', createdAt: now, updatedAt: now },
{ id: id3, description: 'E3', branch: 'b3', baseBranch: 'main', agentId: null, projectId: project.id, status: 'merged', createdAt: now, updatedAt: now },
]);
const result = await repo.findAll({ status: 'pending_review' });
expect(result).toHaveLength(1);
expect(result[0].id).toBe(id2);
});
it('should filter by both projectId and status', async () => {
const projectA = await createProject('PA');
const projectB = await createProject('PB');
const now = new Date();
const idMatch = nanoid();
const idOtherStatus = nanoid();
const idOtherProject = nanoid();
const idNeither = nanoid();
await db.insert(errands).values([
{ id: idMatch, description: 'Match', branch: 'b1', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'pending_review', createdAt: now, updatedAt: now },
{ id: idOtherStatus, description: 'Wrong status', branch: 'b2', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active', createdAt: now, updatedAt: now },
{ id: idOtherProject, description: 'Wrong project', branch: 'b3', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'pending_review', createdAt: now, updatedAt: now },
{ id: idNeither, description: 'Neither', branch: 'b4', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active', createdAt: now, updatedAt: now },
]);
const result = await repo.findAll({ projectId: projectA.id, status: 'pending_review' });
expect(result).toHaveLength(1);
expect(result[0].id).toBe(idMatch);
});
});
describe('findById', () => {
it('should return agentAlias when agentId is set', async () => {
const agent = await createAgent('known-agent');
const project = await createProject();
const id = nanoid();
const now = new Date();
await db.insert(errands).values({
id,
description: 'With agent',
branch: 'feature/x',
baseBranch: 'main',
agentId: agent.id,
projectId: project.id,
status: 'active',
createdAt: now,
updatedAt: now,
});
const found = await repo.findById(id);
expect(found).toBeDefined();
expect(found!.agentAlias).toBe(agent.name);
});
it('should return agentAlias as null when agentId is null', async () => {
const project = await createProject();
const id = nanoid();
const now = new Date();
await db.insert(errands).values({
id,
description: 'No agent',
branch: 'feature/y',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
createdAt: now,
updatedAt: now,
});
const found = await repo.findById(id);
expect(found).toBeDefined();
expect(found!.agentAlias).toBeNull();
});
it('should return undefined for unknown id', async () => {
const found = await repo.findById('nonexistent');
expect(found).toBeUndefined();
});
});
describe('update', () => {
it('should update status and advance updatedAt', async () => {
const project = await createProject();
const id = nanoid();
const past = new Date('2024-01-01T00:00:00Z');
await db.insert(errands).values({
id,
description: 'Errand',
branch: 'feature/update',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
createdAt: past,
updatedAt: past,
});
const updated = await repo.update(id, { status: 'pending_review' });
expect(updated.status).toBe('pending_review');
expect(updated.updatedAt.getTime()).toBeGreaterThan(past.getTime());
});
it('should throw on unknown id', async () => {
await expect(
repo.update('nonexistent', { status: 'merged' })
).rejects.toThrow('Errand not found');
});
});
describe('delete', () => {
it('should delete errand and findById returns undefined', async () => {
const errand = await createErrand();
await repo.delete(errand.id);
const found = await repo.findById(errand.id);
expect(found).toBeUndefined();
});
});
describe('cascade and set null', () => {
it('should cascade delete errands when project is deleted', async () => {
const project = await createProject();
const id = nanoid();
const now = new Date();
await db.insert(errands).values({
id,
description: 'Cascade test',
branch: 'feature/cascade',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
createdAt: now,
updatedAt: now,
});
// Delete project — should cascade delete errands
await db.delete(projects).where(eq(projects.id, project.id));
const found = await repo.findById(id);
expect(found).toBeUndefined();
});
it('should set agentId to null when agent is deleted', async () => {
const agent = await createAgent();
const project = await createProject();
const id = nanoid();
const now = new Date();
await db.insert(errands).values({
id,
description: 'Agent null test',
branch: 'feature/agent-null',
baseBranch: 'main',
agentId: agent.id,
projectId: project.id,
status: 'active',
createdAt: now,
updatedAt: now,
});
// Delete agent — should set null
await db.delete(agents).where(eq(agents.id, agent.id));
const [errand] = await db.select().from(errands).where(eq(errands.id, id));
expect(errand).toBeDefined();
expect(errand.agentId).toBeNull();
});
});
});

View File

@@ -0,0 +1,89 @@
/**
* Drizzle Errand Repository Adapter
*
* Implements ErrandRepository interface using Drizzle ORM.
*/
import { eq, desc, and } from 'drizzle-orm';
import type { DrizzleDatabase } from '../../index.js';
import { errands, agents } from '../../schema.js';
import type {
ErrandRepository,
ErrandWithAlias,
ErrandStatus,
CreateErrandData,
UpdateErrandData,
} from '../errand-repository.js';
import type { Errand } from '../../schema.js';
export class DrizzleErrandRepository implements ErrandRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateErrandData): Promise<Errand> {
const now = new Date();
const [created] = await this.db
.insert(errands)
.values({ ...data, createdAt: now, updatedAt: now })
.returning();
return created;
}
async findById(id: string): Promise<ErrandWithAlias | undefined> {
const result = await this.db
.select({
id: errands.id,
description: errands.description,
branch: errands.branch,
baseBranch: errands.baseBranch,
agentId: errands.agentId,
projectId: errands.projectId,
status: errands.status,
createdAt: errands.createdAt,
updatedAt: errands.updatedAt,
agentAlias: agents.name,
})
.from(errands)
.leftJoin(agents, eq(errands.agentId, agents.id))
.where(eq(errands.id, id))
.limit(1);
return result[0] ?? undefined;
}
async findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise<ErrandWithAlias[]> {
const conditions = [];
if (opts?.projectId) conditions.push(eq(errands.projectId, opts.projectId));
if (opts?.status) conditions.push(eq(errands.status, opts.status));
return this.db
.select({
id: errands.id,
description: errands.description,
branch: errands.branch,
baseBranch: errands.baseBranch,
agentId: errands.agentId,
projectId: errands.projectId,
status: errands.status,
createdAt: errands.createdAt,
updatedAt: errands.updatedAt,
agentAlias: agents.name,
})
.from(errands)
.leftJoin(agents, eq(errands.agentId, agents.id))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(errands.createdAt));
}
async update(id: string, data: UpdateErrandData): Promise<Errand> {
const [updated] = await this.db
.update(errands)
.set({ ...data, updatedAt: new Date() })
.where(eq(errands.id, id))
.returning();
if (!updated) throw new Error(`Errand not found: ${id}`);
return updated;
}
async delete(id: string): Promise<void> {
await this.db.delete(errands).where(eq(errands.id, id));
}
}

View File

@@ -18,3 +18,4 @@ export { DrizzleLogChunkRepository } from './log-chunk.js';
export { DrizzleConversationRepository } from './conversation.js'; export { DrizzleConversationRepository } from './conversation.js';
export { DrizzleChatSessionRepository } from './chat-session.js'; export { DrizzleChatSessionRepository } from './chat-session.js';
export { DrizzleReviewCommentRepository } from './review-comment.js'; export { DrizzleReviewCommentRepository } from './review-comment.js';
export { DrizzleErrandRepository } from './errand.js';

View File

@@ -23,7 +23,43 @@ export class DrizzleReviewCommentRepository implements ReviewCommentRepository {
lineNumber: data.lineNumber, lineNumber: data.lineNumber,
lineType: data.lineType, lineType: data.lineType,
body: data.body, body: data.body,
author: data.author ?? 'you', author: data.author ?? 'user',
parentCommentId: data.parentCommentId ?? null,
resolved: false,
createdAt: now,
updatedAt: now,
});
const rows = await this.db
.select()
.from(reviewComments)
.where(eq(reviewComments.id, id))
.limit(1);
return rows[0]!;
}
async createReply(parentCommentId: string, body: string, author?: string): Promise<ReviewComment> {
// Fetch parent comment to copy context fields
const parentRows = await this.db
.select()
.from(reviewComments)
.where(eq(reviewComments.id, parentCommentId))
.limit(1);
const parent = parentRows[0];
if (!parent) {
throw new Error(`Parent comment not found: ${parentCommentId}`);
}
const now = new Date();
const id = nanoid();
await this.db.insert(reviewComments).values({
id,
phaseId: parent.phaseId,
filePath: parent.filePath,
lineNumber: parent.lineNumber,
lineType: parent.lineType,
body,
author: author ?? 'user',
parentCommentId,
resolved: false, resolved: false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -44,6 +80,19 @@ export class DrizzleReviewCommentRepository implements ReviewCommentRepository {
.orderBy(asc(reviewComments.createdAt)); .orderBy(asc(reviewComments.createdAt));
} }
async update(id: string, body: string): Promise<ReviewComment | null> {
await this.db
.update(reviewComments)
.set({ body, updatedAt: new Date() })
.where(eq(reviewComments.id, id));
const rows = await this.db
.select()
.from(reviewComments)
.where(eq(reviewComments.id, id))
.limit(1);
return rows[0] ?? null;
}
async resolve(id: string): Promise<ReviewComment | null> { async resolve(id: string): Promise<ReviewComment | null> {
await this.db await this.db
.update(reviewComments) .update(reviewComments)

View File

@@ -71,13 +71,13 @@ describe('DrizzleTaskRepository', () => {
it('should accept custom type and priority', async () => { it('should accept custom type and priority', async () => {
const task = await taskRepo.create({ const task = await taskRepo.create({
phaseId: testPhaseId, phaseId: testPhaseId,
name: 'Checkpoint Task', name: 'High Priority Task',
type: 'checkpoint:human-verify', type: 'auto',
priority: 'high', priority: 'high',
order: 1, order: 1,
}); });
expect(task.type).toBe('checkpoint:human-verify'); expect(task.type).toBe('auto');
expect(task.priority).toBe('high'); expect(task.priority).toBe('high');
}); });
}); });

View File

@@ -0,0 +1,15 @@
import type { Errand, NewErrand } from '../schema.js';
export type ErrandStatus = 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned';
export type ErrandWithAlias = Errand & { agentAlias: string | null };
export type CreateErrandData = Omit<NewErrand, 'createdAt' | 'updatedAt'>;
export type UpdateErrandData = Partial<Omit<NewErrand, 'id' | 'createdAt'>>;
export interface ErrandRepository {
create(data: CreateErrandData): Promise<Errand>;
findById(id: string): Promise<ErrandWithAlias | undefined>;
findAll(opts?: { projectId?: string; status?: ErrandStatus }): Promise<ErrandWithAlias[]>;
update(id: string, data: UpdateErrandData): Promise<Errand>;
delete(id: string): Promise<void>;
}

View File

@@ -82,3 +82,11 @@ export type {
ReviewCommentRepository, ReviewCommentRepository,
CreateReviewCommentData, CreateReviewCommentData,
} from './review-comment-repository.js'; } from './review-comment-repository.js';
export type {
ErrandRepository,
ErrandWithAlias,
ErrandStatus,
CreateErrandData,
UpdateErrandData,
} from './errand-repository.js';

View File

@@ -13,11 +13,14 @@ export interface CreateReviewCommentData {
lineType: 'added' | 'removed' | 'context'; lineType: 'added' | 'removed' | 'context';
body: string; body: string;
author?: string; author?: string;
parentCommentId?: string; // for replies
} }
export interface ReviewCommentRepository { export interface ReviewCommentRepository {
create(data: CreateReviewCommentData): Promise<ReviewComment>; create(data: CreateReviewCommentData): Promise<ReviewComment>;
createReply(parentCommentId: string, body: string, author?: string): Promise<ReviewComment>;
findByPhaseId(phaseId: string): Promise<ReviewComment[]>; findByPhaseId(phaseId: string): Promise<ReviewComment[]>;
update(id: string, body: string): Promise<ReviewComment | null>;
resolve(id: string): Promise<ReviewComment | null>; resolve(id: string): Promise<ReviewComment | null>;
unresolve(id: string): Promise<ReviewComment | null>; unresolve(id: string): Promise<ReviewComment | null>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;

View File

@@ -55,6 +55,7 @@ export const phases = sqliteTable('phases', {
status: text('status', { enum: ['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review'] }) status: text('status', { enum: ['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review'] })
.notNull() .notNull()
.default('pending'), .default('pending'),
mergeBase: text('merge_base'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}); });
@@ -137,7 +138,7 @@ export const tasks = sqliteTable('tasks', {
name: text('name').notNull(), name: text('name').notNull(),
description: text('description'), description: text('description'),
type: text('type', { type: text('type', {
enum: ['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action'], enum: ['auto'],
}) })
.notNull() .notNull()
.default('auto'), .default('auto'),
@@ -156,6 +157,7 @@ export const tasks = sqliteTable('tasks', {
.default('pending'), .default('pending'),
order: integer('order').notNull().default(0), order: integer('order').notNull().default(0),
summary: text('summary'), // Agent result summary — propagated to dependent tasks as context summary: text('summary'), // Agent result summary — propagated to dependent tasks as context
retryCount: integer('retry_count').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}); });
@@ -260,7 +262,7 @@ export const agents = sqliteTable('agents', {
}) })
.notNull() .notNull()
.default('idle'), .default('idle'),
mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat'] }) mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat', 'errand'] })
.notNull() .notNull()
.default('execute'), .default('execute'),
pid: integer('pid'), pid: integer('pid'),
@@ -617,12 +619,46 @@ export const reviewComments = sqliteTable('review_comments', {
lineType: text('line_type', { enum: ['added', 'removed', 'context'] }).notNull(), lineType: text('line_type', { enum: ['added', 'removed', 'context'] }).notNull(),
body: text('body').notNull(), body: text('body').notNull(),
author: text('author').notNull().default('you'), author: text('author').notNull().default('you'),
parentCommentId: text('parent_comment_id').references((): ReturnType<typeof text> => reviewComments.id, { onDelete: 'cascade' }),
resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false), resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => [ }, (table) => [
index('review_comments_phase_id_idx').on(table.phaseId), index('review_comments_phase_id_idx').on(table.phaseId),
index('review_comments_parent_id_idx').on(table.parentCommentId),
]); ]);
export type ReviewComment = InferSelectModel<typeof reviewComments>; export type ReviewComment = InferSelectModel<typeof reviewComments>;
export type NewReviewComment = InferInsertModel<typeof reviewComments>; export type NewReviewComment = InferInsertModel<typeof reviewComments>;
// ============================================================================
// ERRANDS
// ============================================================================
export const errands = sqliteTable('errands', {
id: text('id').primaryKey(),
description: text('description').notNull(),
branch: text('branch').notNull(),
baseBranch: text('base_branch').notNull().default('main'),
agentId: text('agent_id').references(() => agents.id, { onDelete: 'set null' }),
projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }),
status: text('status', {
enum: ['active', 'pending_review', 'conflict', 'merged', 'abandoned'],
}).notNull().default('active'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const errandsRelations = relations(errands, ({ one }) => ({
agent: one(agents, {
fields: [errands.agentId],
references: [agents.id],
}),
project: one(projects, {
fields: [errands.projectId],
references: [projects.id],
}),
}));
export type Errand = InferSelectModel<typeof errands>;
export type NewErrand = InferInsertModel<typeof errands>;

View File

@@ -79,7 +79,6 @@ export class DefaultDispatchManager implements DispatchManager {
/** /**
* Queue a task for dispatch. * Queue a task for dispatch.
* Fetches task dependencies and adds to internal queue. * Fetches task dependencies and adds to internal queue.
* Checkpoint tasks are queued but won't auto-dispatch.
*/ */
async queue(taskId: string): Promise<void> { async queue(taskId: string): Promise<void> {
// Fetch task to verify it exists and get priority // Fetch task to verify it exists and get priority
@@ -100,7 +99,7 @@ export class DefaultDispatchManager implements DispatchManager {
this.taskQueue.set(taskId, queuedTask); this.taskQueue.set(taskId, queuedTask);
log.info({ taskId, priority: task.priority, isCheckpoint: this.isCheckpointTask(task) }, 'task queued'); log.info({ taskId, priority: task.priority }, 'task queued');
// Emit TaskQueuedEvent // Emit TaskQueuedEvent
const event: TaskQueuedEvent = { const event: TaskQueuedEvent = {
@@ -118,7 +117,6 @@ export class DefaultDispatchManager implements DispatchManager {
/** /**
* Get next dispatchable task. * Get next dispatchable task.
* Returns task with all dependencies complete, highest priority first. * Returns task with all dependencies complete, highest priority first.
* Checkpoint tasks are excluded (require human action).
*/ */
async getNextDispatchable(): Promise<QueuedTask | null> { async getNextDispatchable(): Promise<QueuedTask | null> {
const queuedTasks = Array.from(this.taskQueue.values()); const queuedTasks = Array.from(this.taskQueue.values());
@@ -127,7 +125,7 @@ export class DefaultDispatchManager implements DispatchManager {
return null; return null;
} }
// Filter to only tasks with all dependencies complete and not checkpoint tasks // Filter to only tasks with all dependencies complete
const readyTasks: QueuedTask[] = []; const readyTasks: QueuedTask[] = [];
log.debug({ queueSize: queuedTasks.length }, 'evaluating dispatchable tasks'); log.debug({ queueSize: queuedTasks.length }, 'evaluating dispatchable tasks');
@@ -139,14 +137,8 @@ export class DefaultDispatchManager implements DispatchManager {
continue; continue;
} }
// Check if this is a checkpoint task (requires human action)
const task = await this.taskRepository.findById(qt.taskId);
if (task && this.isCheckpointTask(task)) {
log.debug({ taskId: qt.taskId, type: task.type }, 'skipping checkpoint task');
continue;
}
// Skip planning-category tasks (handled by architect flow) // Skip planning-category tasks (handled by architect flow)
const task = await this.taskRepository.findById(qt.taskId);
if (task && isPlanningCategory(task.category)) { if (task && isPlanningCategory(task.category)) {
log.debug({ taskId: qt.taskId, category: task.category }, 'skipping planning-category task'); log.debug({ taskId: qt.taskId, category: task.category }, 'skipping planning-category task');
continue; continue;
@@ -255,8 +247,8 @@ export class DefaultDispatchManager implements DispatchManager {
// Clear blocked state // Clear blocked state
this.blockedTasks.delete(taskId); this.blockedTasks.delete(taskId);
// Reset DB status to pending // Reset DB status to pending and clear retry count (manual retry = fresh start)
await this.taskRepository.update(taskId, { status: 'pending' }); await this.taskRepository.update(taskId, { status: 'pending', retryCount: 0 });
log.info({ taskId }, 'retrying blocked task'); log.info({ taskId }, 'retrying blocked task');
@@ -478,14 +470,6 @@ export class DefaultDispatchManager implements DispatchManager {
return true; return true;
} }
/**
* Check if a task is a checkpoint task.
* Checkpoint tasks require human action and don't auto-dispatch.
*/
private isCheckpointTask(task: Task): boolean {
return task.type.startsWith('checkpoint:');
}
/** /**
* Store the completing agent's result summary on the task record. * Store the completing agent's result summary on the task record.
*/ */

View File

@@ -0,0 +1 @@
ALTER TABLE phases ADD COLUMN merge_base TEXT;

View File

@@ -0,0 +1,2 @@
ALTER TABLE review_comments ADD COLUMN parent_comment_id TEXT REFERENCES review_comments(id) ON DELETE CASCADE;--> statement-breakpoint
CREATE INDEX review_comments_parent_id_idx ON review_comments(parent_comment_id);

View File

@@ -0,0 +1,5 @@
-- Drop orphaned approval columns left behind by 0030_remove_task_approval.
-- These columns were removed from schema.ts but left in the DB because
-- 0030 assumed SQLite couldn't DROP COLUMN. SQLite 3.35+ supports it.
ALTER TABLE initiatives DROP COLUMN merge_requires_approval;--> statement-breakpoint
ALTER TABLE tasks DROP COLUMN requires_approval;

View File

@@ -0,0 +1 @@
ALTER TABLE tasks ADD COLUMN retry_count integer NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,13 @@
CREATE TABLE `errands` (
`id` text PRIMARY KEY NOT NULL,
`description` text NOT NULL,
`branch` text NOT NULL,
`base_branch` text DEFAULT 'main' NOT NULL,
`agent_id` text,
`project_id` text,
`status` text DEFAULT 'active' NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`agent_id`) REFERENCES `agents`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -222,8 +222,43 @@
{ {
"idx": 31, "idx": 31,
"version": "6", "version": "6",
"when": 1772236800000,
"tag": "0031_add_phase_merge_base",
"breakpoints": true
},
{
"idx": 32,
"version": "6",
"when": 1772323200000,
"tag": "0032_add_comment_threading",
"breakpoints": true
},
{
"idx": 33,
"version": "6",
"when": 1772409600000,
"tag": "0033_drop_approval_columns",
"breakpoints": true
},
{
"idx": 34,
"version": "6",
"when": 1772496000000,
"tag": "0034_add_task_retry_count",
"breakpoints": true
},
{
"idx": 35,
"version": "6",
"when": 1772796561474,
"tag": "0035_faulty_human_fly",
"breakpoints": true
},
{
"idx": 36,
"version": "6",
"when": 1772798869413, "when": 1772798869413,
"tag": "0031_icy_silvermane", "tag": "0036_icy_silvermane",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@@ -52,6 +52,7 @@ export type {
AccountCredentialsValidatedEvent, AccountCredentialsValidatedEvent,
InitiativePendingReviewEvent, InitiativePendingReviewEvent,
InitiativeReviewApprovedEvent, InitiativeReviewApprovedEvent,
InitiativeChangesRequestedEvent,
DomainEventMap, DomainEventMap,
DomainEventType, DomainEventType,
} from './types.js'; } from './types.js';

View File

@@ -591,6 +591,15 @@ export interface InitiativeReviewApprovedEvent extends DomainEvent {
}; };
} }
export interface InitiativeChangesRequestedEvent extends DomainEvent {
type: 'initiative:changes_requested';
payload: {
initiativeId: string;
phaseId: string;
taskId: string;
};
}
/** /**
* Chat Session Events * Chat Session Events
*/ */
@@ -668,7 +677,8 @@ export type DomainEventMap =
| ChatMessageCreatedEvent | ChatMessageCreatedEvent
| ChatSessionClosedEvent | ChatSessionClosedEvent
| InitiativePendingReviewEvent | InitiativePendingReviewEvent
| InitiativeReviewApprovedEvent; | InitiativeReviewApprovedEvent
| InitiativeChangesRequestedEvent;
/** /**
* Event type literal union for type checking * Event type literal union for type checking
@@ -684,6 +694,14 @@ export type DomainEventType = DomainEventMap['type'];
* *
* All modules communicate through this interface. * All modules communicate through this interface.
* Can be swapped for external systems (RabbitMQ, WebSocket forwarding) later. * Can be swapped for external systems (RabbitMQ, WebSocket forwarding) later.
*
* **Delivery guarantee: at-most-once.**
*
* Events emitted while a client is disconnected are permanently lost.
* Reconnecting clients receive only events emitted after reconnection.
* React Query's `refetchOnWindowFocus` and `refetchOnReconnect` compensate
* for missed mutations since the system uses query invalidation rather
* than incremental state.
*/ */
export interface EventBus { export interface EventBus {
/** /**

View File

@@ -0,0 +1,369 @@
/**
* ExecutionOrchestrator Tests
*
* Tests phase completion transitions, especially when initiative has no branch.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExecutionOrchestrator } from './orchestrator.js';
import { ensureProjectClone } from '../git/project-clones.js';
import type { BranchManager } from '../git/branch-manager.js';
vi.mock('../git/project-clones.js', () => ({
ensureProjectClone: vi.fn().mockResolvedValue('/tmp/test-workspace/clones/test'),
}));
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js';
import type { EventBus, TaskCompletedEvent, DomainEvent } from '../events/types.js';
function createMockEventBus(): EventBus & { handlers: Map<string, Function[]>; emitted: DomainEvent[] } {
const handlers = new Map<string, Function[]>();
const emitted: DomainEvent[] = [];
return {
handlers,
emitted,
emit: vi.fn((event: DomainEvent) => {
emitted.push(event);
const fns = handlers.get(event.type) ?? [];
for (const fn of fns) fn(event);
}),
on: vi.fn((type: string, handler: Function) => {
const fns = handlers.get(type) ?? [];
fns.push(handler);
handlers.set(type, fns);
}),
off: vi.fn(),
once: vi.fn(),
};
}
function createMocks() {
const branchManager: BranchManager = {
ensureBranch: vi.fn(),
mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged', previousRef: 'abc000' }),
diffBranches: vi.fn().mockResolvedValue(''),
deleteBranch: vi.fn(),
branchExists: vi.fn().mockResolvedValue(true),
remoteBranchExists: vi.fn().mockResolvedValue(false),
listCommits: vi.fn().mockResolvedValue([]),
diffCommit: vi.fn().mockResolvedValue(''),
getMergeBase: vi.fn().mockResolvedValue('abc123'),
pushBranch: vi.fn(),
checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }),
fetchRemote: vi.fn(),
fastForwardBranch: vi.fn(),
updateRef: vi.fn(),
};
const phaseRepository = {
findById: vi.fn(),
findByInitiativeId: vi.fn().mockResolvedValue([]),
update: vi.fn().mockImplementation(async (id: string, data: any) => ({ id, ...data })),
create: vi.fn(),
} as unknown as PhaseRepository;
const taskRepository = {
findById: vi.fn(),
findByPhaseId: vi.fn().mockResolvedValue([]),
findByInitiativeId: vi.fn().mockResolvedValue([]),
} as unknown as TaskRepository;
const initiativeRepository = {
findById: vi.fn(),
findByStatus: vi.fn().mockResolvedValue([]),
update: vi.fn(),
} as unknown as InitiativeRepository;
const projectRepository = {
findProjectsByInitiativeId: vi.fn().mockResolvedValue([]),
} as unknown as ProjectRepository;
const phaseDispatchManager: PhaseDispatchManager = {
queuePhase: vi.fn(),
getNextDispatchablePhase: vi.fn().mockResolvedValue(null),
dispatchNextPhase: vi.fn().mockResolvedValue({ success: false, phaseId: '', reason: 'none' }),
completePhase: vi.fn(),
blockPhase: vi.fn(),
getPhaseQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }),
};
const dispatchManager = {
queue: vi.fn(),
getNextDispatchable: vi.fn().mockResolvedValue(null),
dispatchNext: vi.fn().mockResolvedValue({ success: false, taskId: '' }),
completeTask: vi.fn(),
blockTask: vi.fn(),
retryBlockedTask: vi.fn(),
getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }),
} as unknown as DispatchManager;
const conflictResolutionService: ConflictResolutionService = {
handleConflict: vi.fn(),
};
const eventBus = createMockEventBus();
return {
branchManager,
phaseRepository,
taskRepository,
initiativeRepository,
projectRepository,
phaseDispatchManager,
dispatchManager,
conflictResolutionService,
eventBus,
};
}
function createOrchestrator(mocks: ReturnType<typeof createMocks>) {
const orchestrator = new ExecutionOrchestrator(
mocks.branchManager,
mocks.phaseRepository,
mocks.taskRepository,
mocks.initiativeRepository,
mocks.projectRepository,
mocks.phaseDispatchManager,
mocks.dispatchManager,
mocks.conflictResolutionService,
mocks.eventBus,
'/tmp/test-workspace',
);
orchestrator.start();
return orchestrator;
}
function emitTaskCompleted(eventBus: ReturnType<typeof createMockEventBus>, taskId: string) {
const event: TaskCompletedEvent = {
type: 'task:completed',
timestamp: new Date(),
payload: { taskId, agentId: 'agent-1', success: true, message: 'done' },
};
eventBus.emit(event);
}
describe('ExecutionOrchestrator', () => {
let mocks: ReturnType<typeof createMocks>;
beforeEach(() => {
mocks = createMocks();
});
describe('phase completion when initiative has no branch', () => {
it('should transition phase to pending_review in review mode even without a branch', async () => {
const task = {
id: 'task-1',
phaseId: 'phase-1',
initiativeId: 'init-1',
category: 'execute',
status: 'completed',
};
const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' };
const initiative = { id: 'init-1', branch: null, executionMode: 'review_per_phase' };
vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any);
vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any);
vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any);
vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any);
createOrchestrator(mocks);
emitTaskCompleted(mocks.eventBus, 'task-1');
// Allow async handler to complete
await vi.waitFor(() => {
expect(mocks.phaseRepository.update).toHaveBeenCalledWith('phase-1', { status: 'pending_review' });
});
});
it('should complete phase in yolo mode even without a branch', async () => {
const task = {
id: 'task-1',
phaseId: 'phase-1',
initiativeId: 'init-1',
category: 'execute',
status: 'completed',
};
const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' };
const initiative = { id: 'init-1', branch: null, executionMode: 'yolo' };
vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any);
vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any);
vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any);
vi.mocked(mocks.initiativeRepository.findByStatus).mockResolvedValue([]);
vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any);
vi.mocked(mocks.phaseRepository.findByInitiativeId).mockResolvedValue([phase] as any);
createOrchestrator(mocks);
emitTaskCompleted(mocks.eventBus, 'task-1');
await vi.waitFor(() => {
expect(mocks.phaseDispatchManager.completePhase).toHaveBeenCalledWith('phase-1');
});
// Should NOT have attempted any branch merges
expect(mocks.branchManager.mergeBranch).not.toHaveBeenCalled();
});
});
describe('phase completion when merge fails', () => {
it('should still check phase completion even if task merge throws', async () => {
const task = {
id: 'task-1',
phaseId: 'phase-1',
initiativeId: 'init-1',
category: 'execute',
status: 'completed',
};
const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' };
const initiative = { id: 'init-1', branch: 'cw/test', executionMode: 'review_per_phase' };
const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' };
vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any);
vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any);
vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any);
vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any);
vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([project] as any);
// Merge fails
vi.mocked(mocks.branchManager.mergeBranch).mockResolvedValue({
success: false,
message: 'conflict',
conflicts: ['file.ts'],
});
createOrchestrator(mocks);
emitTaskCompleted(mocks.eventBus, 'task-1');
// Phase should still transition despite merge failure
await vi.waitFor(() => {
expect(mocks.phaseRepository.update).toHaveBeenCalledWith('phase-1', { status: 'pending_review' });
});
});
});
describe('phase completion with branch (normal flow)', () => {
it('should merge task branch and transition phase when all tasks done', async () => {
const task = {
id: 'task-1',
phaseId: 'phase-1',
initiativeId: 'init-1',
category: 'execute',
status: 'completed',
};
const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' };
const initiative = { id: 'init-1', branch: 'cw/test', executionMode: 'review_per_phase' };
const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' };
vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any);
vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any);
vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any);
vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any);
vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([project] as any);
vi.mocked(mocks.branchManager.branchExists).mockResolvedValue(true);
vi.mocked(mocks.branchManager.mergeBranch).mockResolvedValue({ success: true, message: 'ok' });
createOrchestrator(mocks);
emitTaskCompleted(mocks.eventBus, 'task-1');
await vi.waitFor(() => {
expect(mocks.phaseRepository.update).toHaveBeenCalledWith('phase-1', { status: 'pending_review' });
});
});
it('should not transition phase when some tasks are still pending', async () => {
const task1 = {
id: 'task-1',
phaseId: 'phase-1',
initiativeId: 'init-1',
category: 'execute',
status: 'completed',
};
const task2 = {
id: 'task-2',
phaseId: 'phase-1',
initiativeId: 'init-1',
category: 'execute',
status: 'pending',
};
const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' };
const initiative = { id: 'init-1', branch: 'cw/test', executionMode: 'review_per_phase' };
vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task1 as any);
vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any);
vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any);
vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task1, task2] as any);
vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([]);
createOrchestrator(mocks);
emitTaskCompleted(mocks.eventBus, 'task-1');
// Give the async handler time to run
await new Promise((r) => setTimeout(r, 50));
expect(mocks.phaseRepository.update).not.toHaveBeenCalled();
expect(mocks.phaseDispatchManager.completePhase).not.toHaveBeenCalled();
});
});
describe('approveInitiative', () => {
function setupApproveTest(mocks: ReturnType<typeof createMocks>) {
const initiative = { id: 'init-1', branch: 'cw/test', status: 'pending_review' };
const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' };
vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any);
vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([project] as any);
vi.mocked(mocks.branchManager.branchExists).mockResolvedValue(true);
vi.mocked(mocks.branchManager.mergeBranch).mockResolvedValue({ success: true, message: 'ok', previousRef: 'abc000' });
return { initiative, project };
}
it('should roll back merge when push fails', async () => {
setupApproveTest(mocks);
vi.mocked(mocks.branchManager.pushBranch).mockRejectedValue(new Error('non-fast-forward'));
const orchestrator = createOrchestrator(mocks);
await expect(orchestrator.approveInitiative('init-1', 'merge_and_push')).rejects.toThrow('non-fast-forward');
// Should have rolled back the merge by restoring the previous ref
expect(mocks.branchManager.updateRef).toHaveBeenCalledWith(
expect.any(String),
'main',
'abc000',
);
// Should NOT have marked initiative as completed
expect(mocks.initiativeRepository.update).not.toHaveBeenCalled();
});
it('should complete initiative when push succeeds', async () => {
setupApproveTest(mocks);
const orchestrator = createOrchestrator(mocks);
await orchestrator.approveInitiative('init-1', 'merge_and_push');
expect(mocks.branchManager.updateRef).not.toHaveBeenCalled();
expect(mocks.initiativeRepository.update).toHaveBeenCalledWith('init-1', { status: 'completed' });
});
it('should not attempt rollback for push_branch strategy', async () => {
setupApproveTest(mocks);
vi.mocked(mocks.branchManager.pushBranch).mockRejectedValue(new Error('auth failed'));
const orchestrator = createOrchestrator(mocks);
await expect(orchestrator.approveInitiative('init-1', 'push_branch')).rejects.toThrow('auth failed');
// No merge happened, so no rollback needed
expect(mocks.branchManager.updateRef).not.toHaveBeenCalled();
});
});
});

View File

@@ -11,12 +11,13 @@
* - Review per-phase: pause after each phase for diff review * - Review per-phase: pause after each phase for diff review
*/ */
import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent } from '../events/index.js'; import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent, AgentCrashedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent, InitiativeChangesRequestedEvent } from '../events/index.js';
import type { BranchManager } from '../git/branch-manager.js'; import type { BranchManager } from '../git/branch-manager.js';
import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js'; import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js';
import { phaseBranchName, taskBranchName } from '../git/branch-naming.js'; import { phaseBranchName, taskBranchName } from '../git/branch-naming.js';
@@ -25,6 +26,9 @@ import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('execution-orchestrator'); const log = createModuleLogger('execution-orchestrator');
/** Maximum number of automatic retries for crashed tasks before blocking */
const MAX_TASK_RETRIES = 3;
export class ExecutionOrchestrator { export class ExecutionOrchestrator {
/** Serialize merges per phase to avoid concurrent merge conflicts */ /** Serialize merges per phase to avoid concurrent merge conflicts */
private phaseMergeLocks: Map<string, Promise<void>> = new Map(); private phaseMergeLocks: Map<string, Promise<void>> = new Map();
@@ -44,6 +48,7 @@ export class ExecutionOrchestrator {
private conflictResolutionService: ConflictResolutionService, private conflictResolutionService: ConflictResolutionService,
private eventBus: EventBus, private eventBus: EventBus,
private workspaceRoot: string, private workspaceRoot: string,
private agentRepository?: AgentRepository,
) {} ) {}
/** /**
@@ -66,6 +71,18 @@ export class ExecutionOrchestrator {
}); });
}); });
// Auto-retry crashed agent tasks (up to MAX_TASK_RETRIES)
this.eventBus.on<AgentCrashedEvent>('agent:crashed', (event) => {
this.handleAgentCrashed(event).catch((err) => {
log.error({ err: err instanceof Error ? err.message : String(err) }, 'error handling agent:crashed');
});
});
// Recover in-memory dispatch queues from DB state (survives server restarts)
this.recoverDispatchQueues().catch((err) => {
log.error({ err: err instanceof Error ? err.message : String(err) }, 'dispatch queue recovery failed');
});
log.info('execution orchestrator started'); log.info('execution orchestrator started');
} }
@@ -106,6 +123,27 @@ export class ExecutionOrchestrator {
this.scheduleDispatch(); this.scheduleDispatch();
} }
private async handleAgentCrashed(event: AgentCrashedEvent): Promise<void> {
const { taskId, agentId, error } = event.payload;
if (!taskId) return;
const task = await this.taskRepository.findById(taskId);
if (!task || task.status !== 'in_progress') return;
const retryCount = (task.retryCount ?? 0) + 1;
if (retryCount > MAX_TASK_RETRIES) {
log.warn({ taskId, agentId, retryCount, error }, 'task exceeded max retries, leaving in_progress');
return;
}
// Reset task for re-dispatch with incremented retry count
await this.taskRepository.update(taskId, { status: 'pending', retryCount });
await this.dispatchManager.queue(taskId);
log.info({ taskId, agentId, retryCount, error }, 'crashed task re-queued for retry');
this.scheduleDispatch();
}
private async runDispatchCycle(): Promise<void> { private async runDispatchCycle(): Promise<void> {
this.dispatchRunning = true; this.dispatchRunning = true;
try { try {
@@ -140,27 +178,29 @@ export class ExecutionOrchestrator {
if (!task?.phaseId || !task.initiativeId) return; if (!task?.phaseId || !task.initiativeId) return;
const initiative = await this.initiativeRepository.findById(task.initiativeId); const initiative = await this.initiativeRepository.findById(task.initiativeId);
if (!initiative?.branch) return;
const phase = await this.phaseRepository.findById(task.phaseId); const phase = await this.phaseRepository.findById(task.phaseId);
if (!phase) return; if (!phase) return;
// Skip merge/review tasks — they already work on the phase branch directly // Merge task branch into phase branch (only when branches exist)
if (task.category === 'merge' || task.category === 'review') return; if (initiative?.branch && task.category !== 'merge' && task.category !== 'review') {
try {
const initBranch = initiative.branch;
const phBranch = phaseBranchName(initBranch, phase.name);
const tBranch = taskBranchName(initBranch, task.id);
const initBranch = initiative.branch; // Serialize merges per phase
const phBranch = phaseBranchName(initBranch, phase.name); const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve();
const tBranch = taskBranchName(initBranch, task.id); const mergeOp = lock.then(async () => {
await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch);
});
this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {}));
await mergeOp;
} catch (err) {
log.error({ taskId, err: err instanceof Error ? err.message : String(err) }, 'task merge failed, still checking phase completion');
}
}
// Serialize merges per phase // Check if all phase tasks are done — always, regardless of branch/merge status
const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve();
const mergeOp = lock.then(async () => {
await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch);
});
this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {}));
await mergeOp;
// Check if all phase tasks are done
const phaseTasks = await this.taskRepository.findByPhaseId(task.phaseId); const phaseTasks = await this.taskRepository.findByPhaseId(task.phaseId);
const allDone = phaseTasks.every((t) => t.status === 'completed'); const allDone = phaseTasks.every((t) => t.status === 'completed');
if (allDone) { if (allDone) {
@@ -228,10 +268,13 @@ export class ExecutionOrchestrator {
if (!phase) return; if (!phase) return;
const initiative = await this.initiativeRepository.findById(phase.initiativeId); const initiative = await this.initiativeRepository.findById(phase.initiativeId);
if (!initiative?.branch) return; if (!initiative) return;
if (initiative.executionMode === 'yolo') { if (initiative.executionMode === 'yolo') {
await this.mergePhaseIntoInitiative(phaseId); // Merge phase branch into initiative branch (only when branches exist)
if (initiative.branch) {
await this.mergePhaseIntoInitiative(phaseId);
}
await this.phaseDispatchManager.completePhase(phaseId); await this.phaseDispatchManager.completePhase(phaseId);
// Re-queue approved phases (self-healing: survives server restarts that wipe in-memory queue) // Re-queue approved phases (self-healing: survives server restarts that wipe in-memory queue)
@@ -273,6 +316,18 @@ export class ExecutionOrchestrator {
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId); const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
// Store merge base before merging so we can reconstruct diffs for completed phases
for (const project of projects) {
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
try {
const mergeBase = await this.branchManager.getMergeBase(clonePath, initBranch, phBranch);
await this.phaseRepository.update(phaseId, { mergeBase });
break; // Only need one merge base (first project)
} catch {
// Phase branch may not exist in this project clone
}
}
for (const project of projects) { for (const project of projects) {
const clonePath = await ensureProjectClone(project, this.workspaceRoot); const clonePath = await ensureProjectClone(project, this.workspaceRoot);
const result = await this.branchManager.mergeBranch(clonePath, phBranch, initBranch); const result = await this.branchManager.mergeBranch(clonePath, phBranch, initBranch);
@@ -327,7 +382,14 @@ export class ExecutionOrchestrator {
*/ */
async requestChangesOnPhase( async requestChangesOnPhase(
phaseId: string, phaseId: string,
unresolvedComments: Array<{ filePath: string; lineNumber: number; body: string }>, unresolvedThreads: Array<{
id: string;
filePath: string;
lineNumber: number;
body: string;
author: string;
replies: Array<{ id: string; body: string; author: string }>;
}>,
summary?: string, summary?: string,
): Promise<{ taskId: string }> { ): Promise<{ taskId: string }> {
const phase = await this.phaseRepository.findById(phaseId); const phase = await this.phaseRepository.findById(phaseId);
@@ -339,16 +401,25 @@ export class ExecutionOrchestrator {
const initiative = await this.initiativeRepository.findById(phase.initiativeId); const initiative = await this.initiativeRepository.findById(phase.initiativeId);
if (!initiative) throw new Error(`Initiative not found: ${phase.initiativeId}`); if (!initiative) throw new Error(`Initiative not found: ${phase.initiativeId}`);
// Build revision task description from comments + summary // Guard: don't create duplicate review tasks
const existingTasks = await this.taskRepository.findByPhaseId(phaseId);
const activeReview = existingTasks.find(
(t) => t.category === 'review' && (t.status === 'pending' || t.status === 'in_progress'),
);
if (activeReview) {
return { taskId: activeReview.id };
}
// Build revision task description from threaded comments + summary
const lines: string[] = []; const lines: string[] = [];
if (summary) { if (summary) {
lines.push(`## Summary\n\n${summary}\n`); lines.push(`## Summary\n\n${summary}\n`);
} }
if (unresolvedComments.length > 0) { if (unresolvedThreads.length > 0) {
lines.push('## Review Comments\n'); lines.push('## Review Comments\n');
// Group comments by file // Group comments by file
const byFile = new Map<string, typeof unresolvedComments>(); const byFile = new Map<string, typeof unresolvedThreads>();
for (const c of unresolvedComments) { for (const c of unresolvedThreads) {
const arr = byFile.get(c.filePath) ?? []; const arr = byFile.get(c.filePath) ?? [];
arr.push(c); arr.push(c);
byFile.set(c.filePath, arr); byFile.set(c.filePath, arr);
@@ -356,9 +427,13 @@ export class ExecutionOrchestrator {
for (const [filePath, fileComments] of byFile) { for (const [filePath, fileComments] of byFile) {
lines.push(`### ${filePath}\n`); lines.push(`### ${filePath}\n`);
for (const c of fileComments) { for (const c of fileComments) {
lines.push(`- **Line ${c.lineNumber}**: ${c.body}`); lines.push(`#### Line ${c.lineNumber} [comment:${c.id}]`);
lines.push(`**${c.author}**: ${c.body}`);
for (const r of c.replies) {
lines.push(`> **${r.author}**: ${r.body}`);
}
lines.push('');
} }
lines.push('');
} }
} }
@@ -388,12 +463,12 @@ export class ExecutionOrchestrator {
phaseId, phaseId,
initiativeId: phase.initiativeId, initiativeId: phase.initiativeId,
taskId: task.id, taskId: task.id,
commentCount: unresolvedComments.length, commentCount: unresolvedThreads.length,
}, },
}; };
this.eventBus.emit(event); this.eventBus.emit(event);
log.info({ phaseId, taskId: task.id, commentCount: unresolvedComments.length }, 'changes requested on phase'); log.info({ phaseId, taskId: task.id, commentCount: unresolvedThreads.length }, 'changes requested on phase');
// Kick off dispatch // Kick off dispatch
this.scheduleDispatch(); this.scheduleDispatch();
@@ -401,6 +476,81 @@ export class ExecutionOrchestrator {
return { taskId: task.id }; return { taskId: task.id };
} }
/**
* Request changes on an initiative that's pending review.
* Creates/reuses a "Finalization" phase and adds a review task to it.
*/
async requestChangesOnInitiative(
initiativeId: string,
summary: string,
): Promise<{ taskId: string }> {
const initiative = await this.initiativeRepository.findById(initiativeId);
if (!initiative) throw new Error(`Initiative not found: ${initiativeId}`);
if (initiative.status !== 'pending_review') {
throw new Error(`Initiative ${initiativeId} is not pending review (status: ${initiative.status})`);
}
// Find or create a "Finalization" phase
const phases = await this.phaseRepository.findByInitiativeId(initiativeId);
let finalizationPhase = phases.find((p) => p.name === 'Finalization');
if (!finalizationPhase) {
finalizationPhase = await this.phaseRepository.create({
initiativeId,
name: 'Finalization',
status: 'in_progress',
});
} else if (finalizationPhase.status === 'completed' || finalizationPhase.status === 'pending_review') {
await this.phaseRepository.update(finalizationPhase.id, { status: 'in_progress' as any });
}
// Guard: don't create duplicate review tasks
const existingTasks = await this.taskRepository.findByPhaseId(finalizationPhase.id);
const activeReview = existingTasks.find(
(t) => t.category === 'review' && (t.status === 'pending' || t.status === 'in_progress'),
);
if (activeReview) {
// Still reset initiative to active
await this.initiativeRepository.update(initiativeId, { status: 'active' as any });
this.scheduleDispatch();
return { taskId: activeReview.id };
}
// Create review task
const task = await this.taskRepository.create({
phaseId: finalizationPhase.id,
initiativeId,
name: `Address initiative review feedback`,
description: `## Summary\n\n${summary}`,
category: 'review',
priority: 'high',
});
// Reset initiative status to active
await this.initiativeRepository.update(initiativeId, { status: 'active' as any });
// Queue task for dispatch
await this.dispatchManager.queue(task.id);
// Emit event
const event: InitiativeChangesRequestedEvent = {
type: 'initiative:changes_requested',
timestamp: new Date(),
payload: {
initiativeId,
phaseId: finalizationPhase.id,
taskId: task.id,
},
};
this.eventBus.emit(event);
log.info({ initiativeId, phaseId: finalizationPhase.id, taskId: task.id }, 'changes requested on initiative');
this.scheduleDispatch();
return { taskId: task.id };
}
/** /**
* Re-queue approved phases for an initiative into the in-memory dispatch queue. * Re-queue approved phases for an initiative into the in-memory dispatch queue.
* Self-healing: ensures phases aren't lost if the server restarted since the * Self-healing: ensures phases aren't lost if the server restarted since the
@@ -420,6 +570,63 @@ export class ExecutionOrchestrator {
} }
} }
/**
* Recover in-memory dispatch queues from DB state on server startup.
* Re-queues approved phases and pending tasks for in_progress phases.
*/
private async recoverDispatchQueues(): Promise<void> {
const initiatives = await this.initiativeRepository.findByStatus('active');
let phasesRecovered = 0;
let tasksRecovered = 0;
for (const initiative of initiatives) {
const phases = await this.phaseRepository.findByInitiativeId(initiative.id);
for (const phase of phases) {
// Re-queue approved phases into the phase dispatch queue
if (phase.status === 'approved') {
try {
await this.phaseDispatchManager.queuePhase(phase.id);
phasesRecovered++;
} catch {
// Already queued or status changed
}
}
// Re-queue pending tasks and recover stuck in_progress tasks for in_progress phases
if (phase.status === 'in_progress') {
const tasks = await this.taskRepository.findByPhaseId(phase.id);
for (const task of tasks) {
if (task.status === 'pending') {
try {
await this.dispatchManager.queue(task.id);
tasksRecovered++;
} catch {
// Already queued or task issue
}
} else if (task.status === 'in_progress' && this.agentRepository) {
// Check if the assigned agent is still alive
const agent = await this.agentRepository.findByTaskId(task.id);
const isAlive = agent && (agent.status === 'running' || agent.status === 'waiting_for_input');
if (!isAlive) {
// Agent is dead — reset task for re-dispatch
await this.taskRepository.update(task.id, { status: 'pending' });
await this.dispatchManager.queue(task.id);
tasksRecovered++;
log.info({ taskId: task.id, agentId: agent?.id }, 'recovered stuck in_progress task (dead agent)');
}
}
}
}
}
}
if (phasesRecovered > 0 || tasksRecovered > 0) {
log.info({ phasesRecovered, tasksRecovered }, 'recovered dispatch queues from DB state');
this.scheduleDispatch();
}
}
/** /**
* Check if all phases for an initiative are completed. * Check if all phases for an initiative are completed.
* If so, set initiative to pending_review and emit event. * If so, set initiative to pending_review and emit event.
@@ -474,12 +681,32 @@ export class ExecutionOrchestrator {
continue; continue;
} }
// Fetch remote so local branches are up-to-date before merge/push
await this.branchManager.fetchRemote(clonePath);
if (strategy === 'merge_and_push') { if (strategy === 'merge_and_push') {
// Fast-forward local defaultBranch to match origin before merging
try {
await this.branchManager.fastForwardBranch(clonePath, project.defaultBranch);
} catch (ffErr) {
log.warn({ project: project.name, err: (ffErr as Error).message }, 'fast-forward of default branch failed — attempting merge anyway');
}
const result = await this.branchManager.mergeBranch(clonePath, initiative.branch, project.defaultBranch); const result = await this.branchManager.mergeBranch(clonePath, initiative.branch, project.defaultBranch);
if (!result.success) { if (!result.success) {
throw new Error(`Failed to merge ${initiative.branch} into ${project.defaultBranch} for project ${project.name}: ${result.message}`); throw new Error(`Failed to merge ${initiative.branch} into ${project.defaultBranch} for project ${project.name}: ${result.message}`);
} }
await this.branchManager.pushBranch(clonePath, project.defaultBranch); try {
await this.branchManager.pushBranch(clonePath, project.defaultBranch);
} catch (pushErr) {
// Roll back the merge so the diff doesn't disappear from the review tab.
// Without rollback, defaultBranch includes the initiative changes and the
// three-dot diff (defaultBranch...initiativeBranch) becomes empty.
if (result.previousRef) {
log.warn({ project: project.name, previousRef: result.previousRef }, 'push failed — rolling back merge');
await this.branchManager.updateRef(clonePath, project.defaultBranch, result.previousRef);
}
throw pushErr;
}
log.info({ initiativeId, project: project.name }, 'initiative branch merged into default and pushed'); log.info({ initiativeId, project: project.name }, 'initiative branch merged into default and pushed');
} else { } else {
await this.branchManager.pushBranch(clonePath, initiative.branch); await this.branchManager.pushBranch(clonePath, initiative.branch);

View File

@@ -6,7 +6,7 @@
* a worktree to be checked out. * a worktree to be checked out.
*/ */
import type { MergeResult, BranchCommit } from './types.js'; import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js';
export interface BranchManager { export interface BranchManager {
/** /**
@@ -57,9 +57,41 @@ export interface BranchManager {
*/ */
diffCommit(repoPath: string, commitHash: string): Promise<string>; diffCommit(repoPath: string, commitHash: string): Promise<string>;
/**
* Get the merge base (common ancestor) of two branches.
* Returns the commit hash of the merge base.
*/
getMergeBase(repoPath: string, branch1: string, branch2: string): Promise<string>;
/** /**
* Push a branch to a remote. * Push a branch to a remote.
* Defaults to 'origin' if no remote specified. * Defaults to 'origin' if no remote specified.
*/ */
pushBranch(repoPath: string, branch: string, remote?: string): Promise<void>; pushBranch(repoPath: string, branch: string, remote?: string): Promise<void>;
/**
* Dry-run merge check — determines if sourceBranch can be cleanly merged
* into targetBranch without actually performing the merge.
* Uses `git merge-tree --write-tree` (git 2.38+).
*/
checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeabilityResult>;
/**
* Fetch all refs from a remote.
* Defaults to 'origin' if no remote specified.
*/
fetchRemote(repoPath: string, remote?: string): Promise<void>;
/**
* Fast-forward a local branch to match its remote-tracking counterpart.
* No-op if already up to date. Throws if fast-forward is not possible
* (i.e. the branches have diverged).
*/
fastForwardBranch(repoPath: string, branch: string, remote?: string): Promise<void>;
/**
* Force-update a branch ref to point at a specific commit.
* Used to roll back a merge when a subsequent push fails.
*/
updateRef(repoPath: string, branch: string, commitHash: string): Promise<void>;
} }

View File

@@ -13,7 +13,7 @@
export type { WorktreeManager } from './types.js'; export type { WorktreeManager } from './types.js';
// Domain types // Domain types
export type { Worktree, WorktreeDiff, MergeResult } from './types.js'; export type { Worktree, WorktreeDiff, MergeResult, MergeabilityResult } from './types.js';
// Adapters // Adapters
export { SimpleGitWorktreeManager } from './manager.js'; export { SimpleGitWorktreeManager } from './manager.js';

View File

@@ -61,16 +61,35 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
const worktreePath = path.join(this.worktreesDir, id); const worktreePath = path.join(this.worktreesDir, id);
log.info({ id, branch, baseBranch }, 'creating worktree'); log.info({ id, branch, baseBranch }, 'creating worktree');
// Create worktree with new branch // Safety: never force-reset a branch to its own base — this would nuke
// git worktree add -b <branch> <path> <base-branch> // shared branches like the initiative branch if passed as both branch and baseBranch.
await this.git.raw([ if (branch === baseBranch) {
'worktree', throw new Error(`Worktree branch and baseBranch are the same (${branch}). Use a unique branch name.`);
'add', }
'-b',
branch, // Create worktree — reuse existing branch or create new one
worktreePath, const branchExists = await this.branchExists(branch);
baseBranch, if (branchExists) {
]); // Branch exists from a previous run. Check if it has commits beyond baseBranch
// before resetting — a previous agent may have done real work on this branch.
try {
const aheadCount = await this.git.raw(['rev-list', '--count', `${baseBranch}..${branch}`]);
if (parseInt(aheadCount.trim(), 10) > 0) {
log.warn({ branch, baseBranch, aheadBy: aheadCount.trim() }, 'branch has commits beyond base, preserving');
} else {
await this.git.raw(['branch', '-f', branch, baseBranch]);
}
} catch {
// If rev-list fails (e.g. baseBranch doesn't exist yet), fall back to reset
await this.git.raw(['branch', '-f', branch, baseBranch]);
}
// Prune stale worktree references before adding new one
await this.git.raw(['worktree', 'prune']);
await this.git.raw(['worktree', 'add', worktreePath, branch]);
} else {
// git worktree add -b <branch> <path> <base-branch>
await this.git.raw(['worktree', 'add', '-b', branch, worktreePath, baseBranch]);
}
const worktree: Worktree = { const worktree: Worktree = {
id, id,
@@ -327,6 +346,18 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
return worktrees; return worktrees;
} }
/**
* Check if a local branch exists in the repository.
*/
private async branchExists(branch: string): Promise<boolean> {
try {
await this.git.raw(['rev-parse', '--verify', `refs/heads/${branch}`]);
return true;
} catch {
return false;
}
}
/** /**
* Parse the output of git diff --name-status. * Parse the output of git diff --name-status.
*/ */

View File

@@ -6,12 +6,12 @@
* on project clones without requiring a worktree. * on project clones without requiring a worktree.
*/ */
import { join } from 'node:path'; import { join, resolve } from 'node:path';
import { mkdtempSync, rmSync } from 'node:fs'; import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { simpleGit } from 'simple-git'; import { simpleGit } from 'simple-git';
import type { BranchManager } from './branch-manager.js'; import type { BranchManager } from './branch-manager.js';
import type { MergeResult, BranchCommit } from './types.js'; import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js';
import { createModuleLogger } from '../logger/index.js'; import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('branch-manager'); const log = createModuleLogger('branch-manager');
@@ -31,21 +31,32 @@ export class SimpleGitBranchManager implements BranchManager {
} }
async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult> { async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult> {
// Use an ephemeral worktree for merge safety // Use an ephemeral worktree with a temp branch for merge safety.
// We can't check out targetBranch directly — it may already be checked out
// in the clone's main working tree or an agent worktree.
const tmpPath = mkdtempSync(join(tmpdir(), 'cw-merge-')); const tmpPath = mkdtempSync(join(tmpdir(), 'cw-merge-'));
const repoGit = simpleGit(repoPath); const repoGit = simpleGit(repoPath);
const tempBranch = `cw-merge-${Date.now()}`;
try { try {
// Create ephemeral worktree on target branch // Capture the target branch ref before merge so callers can roll back on push failure
await repoGit.raw(['worktree', 'add', tmpPath, targetBranch]); const previousRef = (await repoGit.raw(['rev-parse', targetBranch])).trim();
// Create worktree with a temp branch starting at targetBranch's commit
await repoGit.raw(['worktree', 'add', '-b', tempBranch, tmpPath, targetBranch]);
const wtGit = simpleGit(tmpPath); const wtGit = simpleGit(tmpPath);
try { try {
await wtGit.merge([sourceBranch, '--no-edit']); await wtGit.merge([sourceBranch, '--no-edit']);
// Update the real target branch ref to the merge result.
// update-ref bypasses the "branch is checked out" guard.
const mergeCommit = (await wtGit.revparse(['HEAD'])).trim();
await repoGit.raw(['update-ref', `refs/heads/${targetBranch}`, mergeCommit]);
log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly'); log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly');
return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}` }; return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}`, previousRef };
} catch (mergeErr) { } catch (mergeErr) {
// Check for merge conflicts // Check for merge conflicts
const status = await wtGit.status(); const status = await wtGit.status();
@@ -73,6 +84,10 @@ export class SimpleGitBranchManager implements BranchManager {
try { rmSync(tmpPath, { recursive: true, force: true }); } catch { /* ignore */ } try { rmSync(tmpPath, { recursive: true, force: true }); } catch { /* ignore */ }
try { await repoGit.raw(['worktree', 'prune']); } catch { /* ignore */ } try { await repoGit.raw(['worktree', 'prune']); } catch { /* ignore */ }
} }
// Delete the temp branch
try {
await repoGit.raw(['branch', '-D', tempBranch]);
} catch { /* ignore — may already be cleaned up */ }
} }
} }
@@ -141,9 +156,95 @@ export class SimpleGitBranchManager implements BranchManager {
return git.diff([`${commitHash}~1`, commitHash]); return git.diff([`${commitHash}~1`, commitHash]);
} }
async getMergeBase(repoPath: string, branch1: string, branch2: string): Promise<string> {
const git = simpleGit(repoPath);
const result = await git.raw(['merge-base', branch1, branch2]);
return result.trim();
}
async pushBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> { async pushBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
const git = simpleGit(repoPath); const git = simpleGit(repoPath);
await git.push(remote, branch); try {
await git.push(remote, branch);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('branch is currently checked out')) throw err;
// Local non-bare repo with the branch checked out — temporarily allow it.
// receive.denyCurrentBranch=updateInstead updates the remote's working tree
// and index to match, or rejects if the working tree is dirty.
const remoteUrl = (await git.remote(['get-url', remote]))?.trim();
if (!remoteUrl) throw err;
const remotePath = resolve(repoPath, remoteUrl);
const remoteGit = simpleGit(remotePath);
await remoteGit.addConfig('receive.denyCurrentBranch', 'updateInstead');
try {
await git.push(remote, branch);
} finally {
await remoteGit.raw(['config', '--unset', 'receive.denyCurrentBranch']);
}
}
log.info({ repoPath, branch, remote }, 'branch pushed to remote'); log.info({ repoPath, branch, remote }, 'branch pushed to remote');
} }
async checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeabilityResult> {
const git = simpleGit(repoPath);
// git merge-tree --write-tree outputs everything to stdout.
// simple-git's .raw() resolves with stdout even on exit code 1 (conflicts),
// so we parse the output text instead of relying on catch.
const output = await git.raw(['merge-tree', '--write-tree', targetBranch, sourceBranch]);
// Parse conflict file names from "CONFLICT (content): Merge conflict in <path>"
const conflictPattern = /CONFLICT \([^)]+\): (?:Merge conflict in|.* -> )(.+)/g;
const conflicts: string[] = [];
let match: RegExpExecArray | null;
while ((match = conflictPattern.exec(output)) !== null) {
conflicts.push(match[1].trim());
}
if (conflicts.length > 0) {
log.debug({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge-tree check: conflicts');
return { mergeable: false, conflicts };
}
// Fallback: check for any CONFLICT text we couldn't parse specifically
if (output.includes('CONFLICT')) {
log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: unparsed conflicts');
return { mergeable: false, conflicts: ['(unable to parse conflict details)'] };
}
log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean');
return { mergeable: true };
}
async fetchRemote(repoPath: string, remote = 'origin'): Promise<void> {
const git = simpleGit(repoPath);
await git.fetch(remote);
log.info({ repoPath, remote }, 'fetched remote');
}
async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
const git = simpleGit(repoPath);
const remoteBranch = `${remote}/${branch}`;
// Verify it's a genuine fast-forward (branch is ancestor of remote)
try {
await git.raw(['merge-base', '--is-ancestor', branch, remoteBranch]);
} catch {
throw new Error(`Cannot fast-forward ${branch}: it has diverged from ${remoteBranch}`);
}
// Use update-ref instead of git merge so dirty working trees don't block it.
// The clone may have uncommitted agent work; we only need to advance the ref.
const targetCommit = (await git.raw(['rev-parse', remoteBranch])).trim();
await git.raw(['update-ref', `refs/heads/${branch}`, targetCommit]);
log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch');
}
async updateRef(repoPath: string, branch: string, commitHash: string): Promise<void> {
const git = simpleGit(repoPath);
await git.raw(['update-ref', `refs/heads/${branch}`, commitHash]);
log.info({ repoPath, branch, commitHash: commitHash.slice(0, 7) }, 'branch ref updated');
}
} }

View File

@@ -56,6 +56,21 @@ export interface MergeResult {
conflicts?: string[]; conflicts?: string[];
/** Human-readable message describing the result */ /** Human-readable message describing the result */
message: string; message: string;
/** The target branch's commit hash before the merge (for rollback on push failure) */
previousRef?: string;
}
// =============================================================================
// Mergeability Check
// =============================================================================
/**
* Result of a dry-run merge check.
* No side effects — only tells you whether the merge would succeed.
*/
export interface MergeabilityResult {
mergeable: boolean;
conflicts?: string[];
} }
// ============================================================================= // =============================================================================

View File

@@ -164,7 +164,8 @@ describe('generateGatewayCaddyfile', () => {
const caddyfile = generateGatewayCaddyfile(previews, 9100); const caddyfile = generateGatewayCaddyfile(previews, 9100);
expect(caddyfile).toContain('auto_https off'); expect(caddyfile).toContain('auto_https off');
expect(caddyfile).toContain('abc123.localhost:9100 {'); expect(caddyfile).toContain('abc123.localhost:80 {');
expect(caddyfile).toContain('handle /* {');
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000');
}); });
@@ -176,13 +177,14 @@ describe('generateGatewayCaddyfile', () => {
]); ]);
const caddyfile = generateGatewayCaddyfile(previews, 9100); const caddyfile = generateGatewayCaddyfile(previews, 9100);
expect(caddyfile).toContain('abc123.localhost:80 {');
expect(caddyfile).toContain('handle_path /api/*'); expect(caddyfile).toContain('handle_path /api/*');
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080');
expect(caddyfile).toContain('handle {'); expect(caddyfile).toContain('handle /* {');
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000');
}); });
it('generates multi-preview Caddyfile with separate subdomain blocks', () => { it('generates separate subdomain blocks for each preview', () => {
const previews = new Map<string, GatewayRoute[]>(); const previews = new Map<string, GatewayRoute[]>();
previews.set('abc', [ previews.set('abc', [
{ containerName: 'cw-preview-abc-app', port: 3000, route: '/' }, { containerName: 'cw-preview-abc-app', port: 3000, route: '/' },
@@ -192,8 +194,8 @@ describe('generateGatewayCaddyfile', () => {
]); ]);
const caddyfile = generateGatewayCaddyfile(previews, 9100); const caddyfile = generateGatewayCaddyfile(previews, 9100);
expect(caddyfile).toContain('abc.localhost:9100 {'); expect(caddyfile).toContain('abc.localhost:80 {');
expect(caddyfile).toContain('xyz.localhost:9100 {'); expect(caddyfile).toContain('xyz.localhost:80 {');
expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000'); expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000');
expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000'); expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000');
}); });
@@ -209,10 +211,10 @@ describe('generateGatewayCaddyfile', () => {
const caddyfile = generateGatewayCaddyfile(previews, 9100); const caddyfile = generateGatewayCaddyfile(previews, 9100);
const apiAuthIdx = caddyfile.indexOf('/api/auth'); const apiAuthIdx = caddyfile.indexOf('/api/auth');
const apiIdx = caddyfile.indexOf('handle_path /api/*'); const apiIdx = caddyfile.indexOf('handle_path /api/*');
const handleIdx = caddyfile.indexOf('handle {'); const rootIdx = caddyfile.indexOf('handle /* {');
expect(apiAuthIdx).toBeLessThan(apiIdx); expect(apiAuthIdx).toBeLessThan(apiIdx);
expect(apiIdx).toBeLessThan(handleIdx); expect(apiIdx).toBeLessThan(rootIdx);
}); });
}); });

View File

@@ -2,7 +2,7 @@
* Gateway Manager * Gateway Manager
* *
* Manages a single shared Caddy reverse proxy (the "gateway") that routes * Manages a single shared Caddy reverse proxy (the "gateway") that routes
* subdomain requests to per-preview compose stacks on a shared Docker network. * subdomain-based requests to per-preview compose stacks on a shared Docker network.
* *
* Architecture: * Architecture:
* .cw-previews/gateway/ * .cw-previews/gateway/
@@ -195,18 +195,20 @@ export class GatewayManager {
/** /**
* Generate a Caddyfile for the gateway from all active preview routes. * Generate a Caddyfile for the gateway from all active preview routes.
* *
* Each preview gets a subdomain block: `<previewId>.localhost:<port>` * Uses subdomain-based routing: each preview gets its own `<previewId>.localhost:80` block.
* Chrome/Firefox resolve `*.localhost` to 127.0.0.1 natively — no DNS setup needed.
* Routes within a preview are sorted by specificity (longest path first). * Routes within a preview are sorted by specificity (longest path first).
*/ */
export function generateGatewayCaddyfile( export function generateGatewayCaddyfile(
previews: Map<string, GatewayRoute[]>, previews: Map<string, GatewayRoute[]>,
port: number, _port: number,
): string { ): string {
// Caddy runs inside a container where Docker maps host:${port} → container:80.
// The Caddyfile must listen on the container-internal port (80), not the host port.
const lines: string[] = [ const lines: string[] = [
'{', '{',
' auto_https off', ' auto_https off',
'}', '}',
'',
]; ];
for (const [previewId, routes] of previews) { for (const [previewId, routes] of previews) {
@@ -217,11 +219,12 @@ export function generateGatewayCaddyfile(
return b.route.length - a.route.length; return b.route.length - a.route.length;
}); });
lines.push(`${previewId}.localhost:${port} {`); lines.push('');
lines.push(`${previewId}.localhost:80 {`);
for (const route of sorted) { for (const route of sorted) {
if (route.route === '/') { if (route.route === '/') {
lines.push(` handle {`); lines.push(` handle /* {`);
lines.push(` reverse_proxy ${route.containerName}:${route.port}`); lines.push(` reverse_proxy ${route.containerName}:${route.port}`);
lines.push(` }`); lines.push(` }`);
} else { } else {
@@ -233,8 +236,9 @@ export function generateGatewayCaddyfile(
} }
lines.push('}'); lines.push('}');
lines.push('');
} }
lines.push('');
return lines.join('\n'); return lines.join('\n');
} }

View File

@@ -1,7 +1,7 @@
/** /**
* Health Checker * Health Checker
* *
* Polls service healthcheck endpoints through the gateway's subdomain routing * Polls service healthcheck endpoints through the gateway's subdomain-based routing
* to verify that preview services are ready. * to verify that preview services are ready.
*/ */

View File

@@ -67,7 +67,7 @@ vi.mock('node:fs/promises', () => ({
})); }));
vi.mock('nanoid', () => ({ vi.mock('nanoid', () => ({
nanoid: vi.fn(() => 'abc123test'), customAlphabet: vi.fn(() => vi.fn(() => 'abc123test')),
})); }));
import { PreviewManager } from './manager.js'; import { PreviewManager } from './manager.js';
@@ -220,7 +220,7 @@ describe('PreviewManager', () => {
expect(result.projectId).toBe('proj-1'); expect(result.projectId).toBe('proj-1');
expect(result.branch).toBe('feature-x'); expect(result.branch).toBe('feature-x');
expect(result.gatewayPort).toBe(9100); expect(result.gatewayPort).toBe(9100);
expect(result.url).toBe('http://abc123test.localhost:9100'); expect(result.url).toBe('http://abc123test.localhost:9100/');
expect(result.mode).toBe('preview'); expect(result.mode).toBe('preview');
expect(result.status).toBe('running'); expect(result.status).toBe('running');
@@ -233,7 +233,7 @@ describe('PreviewManager', () => {
expect(buildingEvent).toBeDefined(); expect(buildingEvent).toBeDefined();
expect(readyEvent).toBeDefined(); expect(readyEvent).toBeDefined();
expect((readyEvent!.payload as Record<string, unknown>).url).toBe( expect((readyEvent!.payload as Record<string, unknown>).url).toBe(
'http://abc123test.localhost:9100', 'http://abc123test.localhost:9100/',
); );
}); });
@@ -472,7 +472,7 @@ describe('PreviewManager', () => {
expect(previews).toHaveLength(2); expect(previews).toHaveLength(2);
expect(previews[0].id).toBe('aaa'); expect(previews[0].id).toBe('aaa');
expect(previews[0].gatewayPort).toBe(9100); expect(previews[0].gatewayPort).toBe(9100);
expect(previews[0].url).toBe('http://aaa.localhost:9100'); expect(previews[0].url).toBe('http://aaa.localhost:9100/');
expect(previews[0].mode).toBe('preview'); expect(previews[0].mode).toBe('preview');
expect(previews[0].services).toHaveLength(1); expect(previews[0].services).toHaveLength(1);
expect(previews[1].id).toBe('bbb'); expect(previews[1].id).toBe('bbb');
@@ -573,7 +573,7 @@ describe('PreviewManager', () => {
expect(status!.status).toBe('running'); expect(status!.status).toBe('running');
expect(status!.id).toBe('abc'); expect(status!.id).toBe('abc');
expect(status!.gatewayPort).toBe(9100); expect(status!.gatewayPort).toBe(9100);
expect(status!.url).toBe('http://abc.localhost:9100'); expect(status!.url).toBe('http://abc.localhost:9100/');
expect(status!.mode).toBe('preview'); expect(status!.mode).toBe('preview');
}); });

View File

@@ -8,7 +8,7 @@
import { join } from 'node:path'; import { join } from 'node:path';
import { mkdir, writeFile, rm } from 'node:fs/promises'; import { mkdir, writeFile, rm } from 'node:fs/promises';
import { nanoid } from 'nanoid'; import { customAlphabet } from 'nanoid';
import type { ProjectRepository } from '../db/repositories/project-repository.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
@@ -116,7 +116,8 @@ export class PreviewManager {
); );
// 4. Generate ID and prepare deploy dir // 4. Generate ID and prepare deploy dir
const id = nanoid(10); const previewNanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10);
const id = previewNanoid();
const projectName = `${COMPOSE_PROJECT_PREFIX}${id}`; const projectName = `${COMPOSE_PROJECT_PREFIX}${id}`;
const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, id); const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, id);
await mkdir(deployDir, { recursive: true }); await mkdir(deployDir, { recursive: true });
@@ -238,7 +239,7 @@ export class PreviewManager {
await this.runSeeds(projectName, config); await this.runSeeds(projectName, config);
// 11. Success // 11. Success
const url = `http://${id}.localhost:${gatewayPort}`; const url = `http://${id}.localhost:${gatewayPort}/`;
log.info({ id, url }, 'preview deployment ready'); log.info({ id, url }, 'preview deployment ready');
this.eventBus.emit<PreviewReadyEvent>({ this.eventBus.emit<PreviewReadyEvent>({
@@ -604,7 +605,7 @@ export class PreviewManager {
projectId, projectId,
branch, branch,
gatewayPort, gatewayPort,
url: `http://${previewId}.localhost:${gatewayPort}`, url: `http://${previewId}.localhost:${gatewayPort}/`,
mode, mode,
status: 'running', status: 'running',
services: [], services: [],

View File

@@ -143,7 +143,7 @@ describe('Detail Workflow E2E', () => {
harness.setArchitectDetailComplete('detailer', [ harness.setArchitectDetailComplete('detailer', [
{ number: 1, name: 'Task 1', content: 'First task', type: 'auto', dependencies: [] }, { number: 1, name: 'Task 1', content: 'First task', type: 'auto', dependencies: [] },
{ number: 2, name: 'Task 2', content: 'Second task', type: 'auto', dependencies: [1] }, { number: 2, name: 'Task 2', content: 'Second task', type: 'auto', dependencies: [1] },
{ number: 3, name: 'Verify', content: 'Verify all', type: 'checkpoint:human-verify', dependencies: [2] }, { number: 3, name: 'Verify', content: 'Verify all', type: 'auto', dependencies: [2] },
]); ]);
// Resume with all answers // Resume with all answers
@@ -261,7 +261,7 @@ describe('Detail Workflow E2E', () => {
tasks: [ tasks: [
{ number: 1, name: 'Schema', description: 'Create tables', type: 'auto', dependencies: [] }, { number: 1, name: 'Schema', description: 'Create tables', type: 'auto', dependencies: [] },
{ number: 2, name: 'API', description: 'Create endpoints', type: 'auto', dependencies: [1] }, { number: 2, name: 'API', description: 'Create endpoints', type: 'auto', dependencies: [1] },
{ number: 3, name: 'Verify', description: 'Test flow', type: 'checkpoint:human-verify', dependencies: [2] }, { number: 3, name: 'Verify', description: 'Test flow', type: 'auto', dependencies: [2] },
], ],
}); });
@@ -271,33 +271,31 @@ describe('Detail Workflow E2E', () => {
expect(tasks[0].name).toBe('Schema'); expect(tasks[0].name).toBe('Schema');
expect(tasks[1].name).toBe('API'); expect(tasks[1].name).toBe('API');
expect(tasks[2].name).toBe('Verify'); expect(tasks[2].name).toBe('Verify');
expect(tasks[2].type).toBe('checkpoint:human-verify'); expect(tasks[2].type).toBe('auto');
}); });
it('should handle all task types', async () => { it('should create tasks with auto type', async () => {
const initiative = await harness.createInitiative('Task Types Test'); const initiative = await harness.createInitiative('Task Types Test');
const phases = await harness.createPhasesFromPlan(initiative.id, [ const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Phase 1' }, { name: 'Phase 1' },
]); ]);
const detailTask = await harness.createDetailTask(phases[0].id, 'Mixed Tasks'); const detailTask = await harness.createDetailTask(phases[0].id, 'Mixed Tasks');
// Create tasks with all types
await harness.caller.createChildTasks({ await harness.caller.createChildTasks({
parentTaskId: detailTask.id, parentTaskId: detailTask.id,
tasks: [ tasks: [
{ number: 1, name: 'Auto Task', description: 'Automated work', type: 'auto' }, { number: 1, name: 'Auto Task', description: 'Automated work', type: 'auto' },
{ number: 2, name: 'Human Verify', description: 'Visual check', type: 'checkpoint:human-verify', dependencies: [1] }, { number: 2, name: 'Second Task', description: 'More work', type: 'auto', dependencies: [1] },
{ number: 3, name: 'Decision', description: 'Choose approach', type: 'checkpoint:decision', dependencies: [2] }, { number: 3, name: 'Third Task', description: 'Even more', type: 'auto', dependencies: [2] },
{ number: 4, name: 'Human Action', description: 'Manual step', type: 'checkpoint:human-action', dependencies: [3] }, { number: 4, name: 'Final Task', description: 'Last step', type: 'auto', dependencies: [3] },
], ],
}); });
const tasks = await harness.getChildTasks(detailTask.id); const tasks = await harness.getChildTasks(detailTask.id);
expect(tasks).toHaveLength(4); expect(tasks).toHaveLength(4);
expect(tasks[0].type).toBe('auto'); for (const task of tasks) {
expect(tasks[1].type).toBe('checkpoint:human-verify'); expect(task.type).toBe('auto');
expect(tasks[2].type).toBe('checkpoint:decision'); }
expect(tasks[3].type).toBe('checkpoint:human-action');
}); });
it('should create task dependencies', async () => { it('should create task dependencies', async () => {
@@ -346,7 +344,7 @@ describe('Detail Workflow E2E', () => {
{ number: 1, name: 'Create user schema', content: 'Define User model', type: 'auto', dependencies: [] }, { number: 1, name: 'Create user schema', content: 'Define User model', type: 'auto', dependencies: [] },
{ number: 2, name: 'Implement JWT', content: 'Token generation', type: 'auto', dependencies: [1] }, { number: 2, name: 'Implement JWT', content: 'Token generation', type: 'auto', dependencies: [1] },
{ number: 3, name: 'Protected routes', content: 'Middleware', type: 'auto', dependencies: [2] }, { number: 3, name: 'Protected routes', content: 'Middleware', type: 'auto', dependencies: [2] },
{ number: 4, name: 'Verify auth', content: 'Test login flow', type: 'checkpoint:human-verify', dependencies: [3] }, { number: 4, name: 'Verify auth', content: 'Test login flow', type: 'auto', dependencies: [3] },
]); ]);
await harness.caller.spawnArchitectDetail({ await harness.caller.spawnArchitectDetail({
@@ -367,7 +365,7 @@ describe('Detail Workflow E2E', () => {
{ number: 1, name: 'Create user schema', description: 'Define User model', type: 'auto', dependencies: [] }, { number: 1, name: 'Create user schema', description: 'Define User model', type: 'auto', dependencies: [] },
{ number: 2, name: 'Implement JWT', description: 'Token generation', type: 'auto', dependencies: [1] }, { number: 2, name: 'Implement JWT', description: 'Token generation', type: 'auto', dependencies: [1] },
{ number: 3, name: 'Protected routes', description: 'Middleware', type: 'auto', dependencies: [2] }, { number: 3, name: 'Protected routes', description: 'Middleware', type: 'auto', dependencies: [2] },
{ number: 4, name: 'Verify auth', description: 'Test login flow', type: 'checkpoint:human-verify', dependencies: [3] }, { number: 4, name: 'Verify auth', description: 'Test login flow', type: 'auto', dependencies: [3] },
], ],
}); });
@@ -375,7 +373,7 @@ describe('Detail Workflow E2E', () => {
const tasks = await harness.getChildTasks(detailTask.id); const tasks = await harness.getChildTasks(detailTask.id);
expect(tasks).toHaveLength(4); expect(tasks).toHaveLength(4);
expect(tasks[0].name).toBe('Create user schema'); expect(tasks[0].name).toBe('Create user schema');
expect(tasks[3].type).toBe('checkpoint:human-verify'); expect(tasks[3].type).toBe('auto');
// Agent should be idle // Agent should be idle
const finalAgent = await harness.caller.getAgent({ name: 'detailer' }); const finalAgent = await harness.caller.getAgent({ name: 'detailer' });

View File

@@ -202,6 +202,27 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
return candidates[0] ?? null; return candidates[0] ?? null;
}), }),
getActiveConflictAgent: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
const agentManager = requireAgentManager(ctx);
const allAgents = await agentManager.list();
const candidates = allAgents
.filter(
(a) =>
a.mode === 'execute' &&
a.initiativeId === input.initiativeId &&
a.name?.startsWith('conflict-') &&
['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) &&
!a.userDismissedAt,
)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return candidates[0] ?? null;
}),
getAgentOutput: publicProcedure getAgentOutput: publicProcedure
.input(agentIdentifierSchema) .input(agentIdentifierSchema)
.query(async ({ ctx, input }): Promise<string> => { .query(async ({ ctx, input }): Promise<string> => {

View File

@@ -91,6 +91,9 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
} }
} }
// Mark reverted FIRST to avoid ghost state if entity deletion fails partway
await repo.markReverted(input.id);
// Apply reverts in reverse entry order // Apply reverts in reverse entry order
const reversedEntries = [...cs.entries].reverse(); const reversedEntries = [...cs.entries].reverse();
for (const entry of reversedEntries) { for (const entry of reversedEntries) {
@@ -159,8 +162,6 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
} }
} }
await repo.markReverted(input.id);
ctx.eventBus.emit({ ctx.eventBus.emit({
type: 'changeset:reverted' as const, type: 'changeset:reverted' as const,
timestamp: new Date(), timestamp: new Date(),

View File

@@ -7,7 +7,7 @@ import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js'; import type { ProcedureBuilder } from '../trpc.js';
import { requireAgentManager, requireInitiativeRepository, requireProjectRepository, requireTaskRepository, requireBranchManager, requireExecutionOrchestrator } from './_helpers.js'; import { requireAgentManager, requireInitiativeRepository, requireProjectRepository, requireTaskRepository, requireBranchManager, requireExecutionOrchestrator } from './_helpers.js';
import { deriveInitiativeActivity } from './initiative-activity.js'; import { deriveInitiativeActivity } from './initiative-activity.js';
import { buildRefinePrompt } from '../../agent/prompts/index.js'; import { buildRefinePrompt, buildConflictResolutionPrompt, buildConflictResolutionDescription } from '../../agent/prompts/index.js';
import type { PageForSerialization } from '../../agent/content-serializer.js'; import type { PageForSerialization } from '../../agent/content-serializer.js';
import { ensureProjectClone } from '../../git/project-clones.js'; import { ensureProjectClone } from '../../git/project-clones.js';
@@ -335,5 +335,145 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
await orchestrator.approveInitiative(input.initiativeId, input.strategy); await orchestrator.approveInitiative(input.initiativeId, input.strategy);
return { success: true }; return { success: true };
}), }),
requestInitiativeChanges: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
summary: z.string().trim().min(1),
}))
.mutation(async ({ ctx, input }) => {
const orchestrator = requireExecutionOrchestrator(ctx);
const result = await orchestrator.requestChangesOnInitiative(
input.initiativeId,
input.summary,
);
return { success: true, taskId: result.taskId };
}),
checkInitiativeMergeability: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const initiativeRepo = requireInitiativeRepository(ctx);
const projectRepo = requireProjectRepository(ctx);
const branchManager = requireBranchManager(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` });
}
if (!initiative.branch) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
}
const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId);
const allConflicts: string[] = [];
let mergeable = true;
for (const project of projects) {
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
const result = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch);
if (!result.mergeable) {
mergeable = false;
if (result.conflicts) allConflicts.push(...result.conflicts);
}
}
return {
mergeable,
conflictFiles: allConflicts,
targetBranch: projects[0]?.defaultBranch ?? 'main',
};
}),
spawnConflictResolutionAgent: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
provider: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const agentManager = requireAgentManager(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const projectRepo = requireProjectRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const branchManager = requireBranchManager(ctx);
const initiative = await initiativeRepo.findById(input.initiativeId);
if (!initiative) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` });
}
if (!initiative.branch) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
}
const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId);
if (projects.length === 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no linked projects' });
}
// Auto-dismiss stale conflict agents
const allAgents = await agentManager.list();
const staleAgents = allAgents.filter(
(a) =>
a.mode === 'execute' &&
a.initiativeId === input.initiativeId &&
a.name?.startsWith('conflict-') &&
['crashed', 'idle'].includes(a.status) &&
!a.userDismissedAt,
);
for (const stale of staleAgents) {
await agentManager.dismiss(stale.id);
}
// Reject if active conflict agent already running
const activeConflictAgents = allAgents.filter(
(a) =>
a.mode === 'execute' &&
a.initiativeId === input.initiativeId &&
a.name?.startsWith('conflict-') &&
['running', 'waiting_for_input'].includes(a.status),
);
if (activeConflictAgents.length > 0) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A conflict resolution agent is already running for this initiative',
});
}
// Re-check mergeability to get current conflict list
const project = projects[0];
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
const mergeCheck = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch);
if (mergeCheck.mergeable) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No merge conflicts detected — merge is clean' });
}
const conflicts = mergeCheck.conflicts ?? [];
const targetBranch = project.defaultBranch;
// Create task
const task = await taskRepo.create({
initiativeId: input.initiativeId,
name: `Resolve conflicts: ${initiative.name}`,
description: buildConflictResolutionDescription(initiative.branch, targetBranch, conflicts),
category: 'merge',
status: 'in_progress',
});
// Spawn agent on a unique temp branch based off the initiative branch.
// Using initiative.branch directly as branchName would cause SimpleGitWorktreeManager.create()
// to run `git branch -f <branch> <base>`, force-resetting the initiative branch.
const tempBranch = `${initiative.branch}-conflict-${Date.now()}`;
const prompt = buildConflictResolutionPrompt(initiative.branch, targetBranch, conflicts);
return agentManager.spawn({
name: `conflict-${Date.now()}`,
taskId: task.id,
prompt,
mode: 'execute',
provider: input.provider,
initiativeId: input.initiativeId,
baseBranch: initiative.branch,
branchName: tempBranch,
});
}),
}; };
} }

View File

@@ -53,7 +53,7 @@ export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) {
number: z.number().int().positive(), number: z.number().int().positive(),
name: z.string().min(1), name: z.string().min(1),
description: z.string(), description: z.string(),
type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).default('auto'), type: z.enum(['auto']).default('auto'),
dependencies: z.array(z.number().int().positive()).optional(), dependencies: z.array(z.number().int().positive()).optional(),
})), })),
})) }))

View File

@@ -6,7 +6,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import type { Phase } from '../../db/schema.js'; import type { Phase } from '../../db/schema.js';
import type { ProcedureBuilder } from '../trpc.js'; import type { ProcedureBuilder } from '../trpc.js';
import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository } from './_helpers.js'; import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository, requireChangeSetRepository } from './_helpers.js';
import { phaseBranchName } from '../../git/branch-naming.js'; import { phaseBranchName } from '../../git/branch-naming.js';
import { ensureProjectClone } from '../../git/project-clones.js'; import { ensureProjectClone } from '../../git/project-clones.js';
@@ -98,6 +98,29 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx); const repo = requirePhaseRepository(ctx);
await repo.delete(input.id); await repo.delete(input.id);
// Reconcile any applied changesets that created this phase.
// If all created phases in a changeset are now deleted, mark it reverted.
if (ctx.changeSetRepository) {
try {
const csRepo = requireChangeSetRepository(ctx);
const affectedChangeSets = await csRepo.findAppliedByCreatedEntity('phase', input.id);
for (const cs of affectedChangeSets) {
const createdPhaseIds = cs.entries
.filter(e => e.entityType === 'phase' && e.action === 'create')
.map(e => e.entityId);
const survivingPhases = await Promise.all(
createdPhaseIds.map(id => repo.findById(id)),
);
if (survivingPhases.every(p => p === null)) {
await csRepo.markReverted(cs.id);
}
}
} catch {
// Best-effort reconciliation — don't fail the delete
}
}
return { success: true }; return { success: true };
}), }),
@@ -196,8 +219,8 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
if (!phase) { if (!phase) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` }); throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` });
} }
if (phase.status !== 'pending_review') { if (phase.status !== 'pending_review' && phase.status !== 'completed') {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` }); throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not reviewable (status: ${phase.status})` });
} }
const initiative = await initiativeRepo.findById(phase.initiativeId); const initiative = await initiativeRepo.findById(phase.initiativeId);
@@ -207,13 +230,15 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
const initBranch = initiative.branch; const initBranch = initiative.branch;
const phBranch = phaseBranchName(initBranch, phase.name); const phBranch = phaseBranchName(initBranch, phase.name);
// For completed phases, use stored merge base; for pending_review, use initiative branch
const diffBase = (phase.status === 'completed' && phase.mergeBase) ? phase.mergeBase : initBranch;
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId); const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
let rawDiff = ''; let rawDiff = '';
for (const project of projects) { for (const project of projects) {
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
const diff = await branchManager.diffBranches(clonePath, initBranch, phBranch); const diff = await branchManager.diffBranches(clonePath, diffBase, phBranch);
if (diff) { if (diff) {
rawDiff += diff + '\n'; rawDiff += diff + '\n';
} }
@@ -247,8 +272,8 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
if (!phase) { if (!phase) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` }); throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` });
} }
if (phase.status !== 'pending_review') { if (phase.status !== 'pending_review' && phase.status !== 'completed') {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` }); throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not reviewable (status: ${phase.status})` });
} }
const initiative = await initiativeRepo.findById(phase.initiativeId); const initiative = await initiativeRepo.findById(phase.initiativeId);
@@ -258,13 +283,14 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
const initBranch = initiative.branch; const initBranch = initiative.branch;
const phBranch = phaseBranchName(initBranch, phase.name); const phBranch = phaseBranchName(initBranch, phase.name);
const diffBase = (phase.status === 'completed' && phase.mergeBase) ? phase.mergeBase : initBranch;
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId); const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
const allCommits: Array<{ hash: string; shortHash: string; message: string; author: string; date: string; filesChanged: number; insertions: number; deletions: number }> = []; const allCommits: Array<{ hash: string; shortHash: string; message: string; author: string; date: string; filesChanged: number; insertions: number; deletions: number }> = [];
for (const project of projects) { for (const project of projects) {
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
const commits = await branchManager.listCommits(clonePath, initBranch, phBranch); const commits = await branchManager.listCommits(clonePath, diffBase, phBranch);
allCommits.push(...commits); allCommits.push(...commits);
} }
@@ -320,6 +346,20 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
return repo.create(input); return repo.create(input);
}), }),
updateReviewComment: publicProcedure
.input(z.object({
id: z.string().min(1),
body: z.string().trim().min(1),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireReviewCommentRepository(ctx);
const comment = await repo.update(input.id, input.body);
if (!comment) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Review comment '${input.id}' not found` });
}
return comment;
}),
resolveReviewComment: publicProcedure resolveReviewComment: publicProcedure
.input(z.object({ id: z.string().min(1) })) .input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -342,25 +382,54 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
return comment; return comment;
}), }),
replyToReviewComment: publicProcedure
.input(z.object({
parentCommentId: z.string().min(1),
body: z.string().trim().min(1),
author: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireReviewCommentRepository(ctx);
return repo.createReply(input.parentCommentId, input.body, input.author);
}),
requestPhaseChanges: publicProcedure requestPhaseChanges: publicProcedure
.input(z.object({ .input(z.object({
phaseId: z.string().min(1), phaseId: z.string().min(1),
summary: z.string().optional(), summary: z.string().trim().min(1).optional(),
})) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const orchestrator = requireExecutionOrchestrator(ctx); const orchestrator = requireExecutionOrchestrator(ctx);
const reviewCommentRepo = requireReviewCommentRepository(ctx); const reviewCommentRepo = requireReviewCommentRepository(ctx);
const allComments = await reviewCommentRepo.findByPhaseId(input.phaseId); const allComments = await reviewCommentRepo.findByPhaseId(input.phaseId);
const unresolved = allComments // Build threaded structure: unresolved root comments with their replies
.filter((c: { resolved: boolean }) => !c.resolved) const rootComments = allComments.filter((c) => !c.parentCommentId);
.map((c: { filePath: string; lineNumber: number; body: string }) => ({ const repliesByParent = new Map<string, typeof allComments>();
for (const c of allComments) {
if (c.parentCommentId) {
const arr = repliesByParent.get(c.parentCommentId) ?? [];
arr.push(c);
repliesByParent.set(c.parentCommentId, arr);
}
}
const unresolvedThreads = rootComments
.filter((c) => !c.resolved)
.map((c) => ({
id: c.id,
filePath: c.filePath, filePath: c.filePath,
lineNumber: c.lineNumber, lineNumber: c.lineNumber,
body: c.body, body: c.body,
author: c.author,
replies: (repliesByParent.get(c.id) ?? []).map((r) => ({
id: r.id,
body: r.body,
author: r.author,
})),
})); }));
if (unresolved.length === 0 && !input.summary) { if (unresolvedThreads.length === 0 && !input.summary) {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Add comments or a summary before requesting changes', message: 'Add comments or a summary before requesting changes',
@@ -369,7 +438,7 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
const result = await orchestrator.requestChangesOnPhase( const result = await orchestrator.requestChangesOnPhase(
input.phaseId, input.phaseId,
unresolved, unresolvedThreads,
input.summary, input.summary,
); );
return { success: true, taskId: result.taskId }; return { success: true, taskId: result.taskId };

View File

@@ -2,7 +2,6 @@
* Subscription Router — SSE event streams * Subscription Router — SSE event streams
*/ */
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js'; import type { ProcedureBuilder } from '../trpc.js';
import { import {
eventBusIterable, eventBusIterable,
@@ -17,42 +16,40 @@ import {
export function subscriptionProcedures(publicProcedure: ProcedureBuilder) { export function subscriptionProcedures(publicProcedure: ProcedureBuilder) {
return { return {
onEvent: publicProcedure onEvent: publicProcedure
.input(z.object({ lastEventId: z.string().nullish() }).optional())
.subscription(async function* (opts) { .subscription(async function* (opts) {
const signal = opts.signal ?? new AbortController().signal; const signal = opts.signal ?? new AbortController().signal;
yield* eventBusIterable(opts.ctx.eventBus, ALL_EVENT_TYPES, signal); yield* eventBusIterable(opts.ctx.eventBus, ALL_EVENT_TYPES, signal);
}), }),
onAgentUpdate: publicProcedure onAgentUpdate: publicProcedure
.input(z.object({ lastEventId: z.string().nullish() }).optional())
.subscription(async function* (opts) { .subscription(async function* (opts) {
const signal = opts.signal ?? new AbortController().signal; const signal = opts.signal ?? new AbortController().signal;
yield* eventBusIterable(opts.ctx.eventBus, AGENT_EVENT_TYPES, signal); yield* eventBusIterable(opts.ctx.eventBus, AGENT_EVENT_TYPES, signal);
}), }),
onTaskUpdate: publicProcedure onTaskUpdate: publicProcedure
.input(z.object({ lastEventId: z.string().nullish() }).optional())
.subscription(async function* (opts) { .subscription(async function* (opts) {
const signal = opts.signal ?? new AbortController().signal; const signal = opts.signal ?? new AbortController().signal;
yield* eventBusIterable(opts.ctx.eventBus, TASK_EVENT_TYPES, signal); yield* eventBusIterable(opts.ctx.eventBus, TASK_EVENT_TYPES, signal);
}), }),
onPageUpdate: publicProcedure onPageUpdate: publicProcedure
.input(z.object({ lastEventId: z.string().nullish() }).optional())
.subscription(async function* (opts) { .subscription(async function* (opts) {
const signal = opts.signal ?? new AbortController().signal; const signal = opts.signal ?? new AbortController().signal;
yield* eventBusIterable(opts.ctx.eventBus, PAGE_EVENT_TYPES, signal); yield* eventBusIterable(opts.ctx.eventBus, PAGE_EVENT_TYPES, signal);
}), }),
onPreviewUpdate: publicProcedure onPreviewUpdate: publicProcedure
.input(z.object({ lastEventId: z.string().nullish() }).optional())
.subscription(async function* (opts) { .subscription(async function* (opts) {
const signal = opts.signal ?? new AbortController().signal; const signal = opts.signal ?? new AbortController().signal;
yield* eventBusIterable(opts.ctx.eventBus, PREVIEW_EVENT_TYPES, signal); yield* eventBusIterable(opts.ctx.eventBus, PREVIEW_EVENT_TYPES, signal);
}), }),
// NOTE: No frontend view currently displays inter-agent conversation data.
// When a conversation view is added, add to its useLiveUpdates call:
// { prefix: 'conversation:', invalidate: ['<query-key>'] }
// and add the relevant mutation(s) to INVALIDATION_MAP in apps/web/src/lib/invalidation.ts.
onConversationUpdate: publicProcedure onConversationUpdate: publicProcedure
.input(z.object({ lastEventId: z.string().nullish() }).optional())
.subscription(async function* (opts) { .subscription(async function* (opts) {
const signal = opts.signal ?? new AbortController().signal; const signal = opts.signal ?? new AbortController().signal;
yield* eventBusIterable(opts.ctx.eventBus, CONVERSATION_EVENT_TYPES, signal); yield* eventBusIterable(opts.ctx.eventBus, CONVERSATION_EVENT_TYPES, signal);

View File

@@ -10,6 +10,7 @@ import {
requireInitiativeRepository, requireInitiativeRepository,
requirePhaseRepository, requirePhaseRepository,
requireDispatchManager, requireDispatchManager,
requireChangeSetRepository,
} from './_helpers.js'; } from './_helpers.js';
export function taskProcedures(publicProcedure: ProcedureBuilder) { export function taskProcedures(publicProcedure: ProcedureBuilder) {
@@ -49,6 +50,14 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
message: `Task '${input.id}' not found`, message: `Task '${input.id}' not found`,
}); });
} }
// Route through dispatchManager when completing — emits task:completed
// event so the orchestrator can check phase completion and merge branches
if (input.status === 'completed' && ctx.dispatchManager) {
await ctx.dispatchManager.completeTask(input.id);
return (await taskRepository.findById(input.id))!;
}
return taskRepository.update(input.id, { status: input.status }); return taskRepository.update(input.id, { status: input.status });
}), }),
@@ -58,7 +67,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(), category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(),
type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).optional(), type: z.enum(['auto']).optional(),
})) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx); const taskRepository = requireTaskRepository(ctx);
@@ -88,7 +97,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(), category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(),
type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).optional(), type: z.enum(['auto']).optional(),
})) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx); const taskRepository = requireTaskRepository(ctx);
@@ -152,6 +161,29 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx); const taskRepository = requireTaskRepository(ctx);
await taskRepository.delete(input.id); await taskRepository.delete(input.id);
// Reconcile any applied changesets that created this task.
// If all created tasks in a changeset are now deleted, mark it reverted.
if (ctx.changeSetRepository) {
try {
const csRepo = requireChangeSetRepository(ctx);
const affectedChangeSets = await csRepo.findAppliedByCreatedEntity('task', input.id);
for (const cs of affectedChangeSets) {
const createdTaskIds = cs.entries
.filter(e => e.entityType === 'task' && e.action === 'create')
.map(e => e.entityId);
const survivingTasks = await Promise.all(
createdTaskIds.map(id => taskRepository.findById(id)),
);
if (survivingTasks.every(t => t === null)) {
await csRepo.markReverted(cs.id);
}
}
} catch {
// Best-effort reconciliation — don't fail the delete
}
}
return { success: true }; return { success: true };
}), }),

View File

@@ -12,7 +12,7 @@ export interface SerializedTask {
parentTaskId: string | null; parentTaskId: string | null;
name: string; name: string;
description: string | null; description: string | null;
type: "auto" | "checkpoint:human-verify" | "checkpoint:decision" | "checkpoint:human-action"; type: "auto";
category: string; category: string;
priority: "low" | "medium" | "high"; priority: "low" | "medium" | "high";
status: "pending" | "in_progress" | "completed" | "blocked"; status: "pending" | "in_progress" | "completed" | "blocked";

View File

@@ -27,6 +27,7 @@ export function PlanSection({
(a) => (a) =>
a.mode === "plan" && a.mode === "plan" &&
a.initiativeId === initiativeId && a.initiativeId === initiativeId &&
!a.userDismissedAt &&
["running", "waiting_for_input", "idle"].includes(a.status), ["running", "waiting_for_input", "idle"].includes(a.status),
) )
.sort( .sort(

View File

@@ -7,14 +7,15 @@ interface CommentFormProps {
onCancel: () => void; onCancel: () => void;
placeholder?: string; placeholder?: string;
submitLabel?: string; submitLabel?: string;
initialValue?: string;
} }
export const CommentForm = forwardRef<HTMLTextAreaElement, CommentFormProps>( export const CommentForm = forwardRef<HTMLTextAreaElement, CommentFormProps>(
function CommentForm( function CommentForm(
{ onSubmit, onCancel, placeholder = "Write a comment...", submitLabel = "Comment" }, { onSubmit, onCancel, placeholder = "Write a comment...", submitLabel = "Comment", initialValue = "" },
ref ref
) { ) {
const [body, setBody] = useState(""); const [body, setBody] = useState(initialValue);
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
const trimmed = body.trim(); const trimmed = body.trim();

View File

@@ -1,71 +1,214 @@
import { Check, RotateCcw } from "lucide-react"; import { useState, useRef, useEffect } from "react";
import { Check, RotateCcw, Reply, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CommentForm } from "./CommentForm";
import type { ReviewComment } from "./types"; import type { ReviewComment } from "./types";
interface CommentThreadProps { interface CommentThreadProps {
comments: ReviewComment[]; comments: ReviewComment[];
onResolve: (commentId: string) => void; onResolve: (commentId: string) => void;
onUnresolve: (commentId: string) => void; onUnresolve: (commentId: string) => void;
onReply?: (parentCommentId: string, body: string) => void;
onEdit?: (commentId: string, body: string) => void;
} }
export function CommentThread({ comments, onResolve, onUnresolve }: CommentThreadProps) { export function CommentThread({ comments, onResolve, onUnresolve, onReply, onEdit }: CommentThreadProps) {
// Group: root comments (no parentCommentId) and their replies
const rootComments = comments.filter((c) => !c.parentCommentId);
const repliesByParent = new Map<string, ReviewComment[]>();
for (const c of comments) {
if (c.parentCommentId) {
const arr = repliesByParent.get(c.parentCommentId) ?? [];
arr.push(c);
repliesByParent.set(c.parentCommentId, arr);
}
}
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{comments.map((comment) => ( {rootComments.map((comment) => (
<div <RootComment
key={comment.id} key={comment.id}
className={`rounded border p-2.5 text-xs space-y-1.5 ${ comment={comment}
comment.resolved replies={repliesByParent.get(comment.id) ?? []}
? "border-status-success-border bg-status-success-bg/50" onResolve={onResolve}
: "border-border bg-card" onUnresolve={onUnresolve}
}`} onReply={onReply}
> onEdit={onEdit}
<div className="flex items-center justify-between gap-2"> />
<div className="flex items-center gap-1.5">
<span className="font-semibold text-foreground">{comment.author}</span>
<span className="text-muted-foreground">
{formatTime(comment.createdAt)}
</span>
{comment.resolved && (
<span className="flex items-center gap-0.5 text-status-success-fg text-[10px] font-medium">
<Check className="h-3 w-3" />
Resolved
</span>
)}
</div>
<div>
{comment.resolved ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => onUnresolve(comment.id)}
>
<RotateCcw className="h-3 w-3 mr-0.5" />
Reopen
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => onResolve(comment.id)}
>
<Check className="h-3 w-3 mr-0.5" />
Resolve
</Button>
)}
</div>
</div>
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
{comment.body}
</p>
</div>
))} ))}
</div> </div>
); );
} }
function RootComment({
comment,
replies,
onResolve,
onUnresolve,
onReply,
onEdit,
}: {
comment: ReviewComment;
replies: ReviewComment[];
onResolve: (id: string) => void;
onUnresolve: (id: string) => void;
onReply?: (parentCommentId: string, body: string) => void;
onEdit?: (commentId: string, body: string) => void;
}) {
const [isReplying, setIsReplying] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const replyRef = useRef<HTMLTextAreaElement>(null);
const editRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (isReplying) replyRef.current?.focus();
}, [isReplying]);
useEffect(() => {
if (editingId) editRef.current?.focus();
}, [editingId]);
const isEditingRoot = editingId === comment.id;
return (
<div className={`rounded border ${comment.resolved ? "border-status-success-border bg-status-success-bg/50" : "border-border bg-card"}`}>
{/* Root comment */}
<div className="p-2.5 text-xs space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<span className="font-semibold text-foreground">{comment.author}</span>
<span className="text-muted-foreground">{formatTime(comment.createdAt)}</span>
{comment.resolved && (
<span className="flex items-center gap-0.5 text-status-success-fg text-[10px] font-medium">
<Check className="h-3 w-3" />
Resolved
</span>
)}
</div>
<div className="flex items-center gap-0.5">
{onEdit && comment.author !== "agent" && !comment.resolved && (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => setEditingId(isEditingRoot ? null : comment.id)}
>
<Pencil className="h-3 w-3 mr-0.5" />
Edit
</Button>
)}
{onReply && !comment.resolved && (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => setIsReplying(!isReplying)}
>
<Reply className="h-3 w-3 mr-0.5" />
Reply
</Button>
)}
{comment.resolved ? (
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onUnresolve(comment.id)}>
<RotateCcw className="h-3 w-3 mr-0.5" />
Reopen
</Button>
) : (
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onResolve(comment.id)}>
<Check className="h-3 w-3 mr-0.5" />
Resolve
</Button>
)}
</div>
</div>
{isEditingRoot ? (
<CommentForm
ref={editRef}
initialValue={comment.body}
onSubmit={(body) => {
onEdit!(comment.id, body);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
placeholder="Edit comment..."
submitLabel="Save"
/>
) : (
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{comment.body}</p>
)}
</div>
{/* Replies */}
{replies.length > 0 && (
<div className="border-t border-border/50">
{replies.map((reply) => (
<div
key={reply.id}
className={`px-2.5 py-2 text-xs border-l-2 ml-3 space-y-1 ${
reply.author === "agent"
? "border-l-primary bg-primary/5"
: "border-l-muted-foreground/30"
}`}
>
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-center gap-1.5">
<span className={`font-semibold ${reply.author === "agent" ? "text-primary" : "text-foreground"}`}>
{reply.author}
</span>
<span className="text-muted-foreground">{formatTime(reply.createdAt)}</span>
</div>
{onEdit && reply.author !== "agent" && !comment.resolved && editingId !== reply.id && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1 text-[10px]"
onClick={() => setEditingId(reply.id)}
>
<Pencil className="h-2.5 w-2.5 mr-0.5" />
Edit
</Button>
)}
</div>
{editingId === reply.id ? (
<CommentForm
ref={editRef}
initialValue={reply.body}
onSubmit={(body) => {
onEdit!(reply.id, body);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
placeholder="Edit reply..."
submitLabel="Save"
/>
) : (
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{reply.body}</p>
)}
</div>
))}
</div>
)}
{/* Reply form */}
{isReplying && onReply && (
<div className="border-t border-border/50 p-2.5">
<CommentForm
ref={replyRef}
onSubmit={(body) => {
onReply(comment.id, body);
setIsReplying(false);
}}
onCancel={() => setIsReplying(false)}
placeholder="Write a reply..."
submitLabel="Reply"
/>
</div>
)}
</div>
);
}
function formatTime(iso: string): string { function formatTime(iso: string): string {
const d = new Date(iso); const d = new Date(iso);
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });

View File

@@ -0,0 +1,180 @@
import { Loader2, AlertCircle, GitMerge, CheckCircle2, ChevronDown, ChevronRight, Terminal } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { QuestionForm } from '@/components/QuestionForm';
import { useConflictAgent } from '@/hooks/useConflictAgent';
interface ConflictResolutionPanelProps {
initiativeId: string;
conflicts: string[];
onResolved: () => void;
}
export function ConflictResolutionPanel({ initiativeId, conflicts, onResolved }: ConflictResolutionPanelProps) {
const { state, agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId);
const [showManual, setShowManual] = useState(false);
const prevStateRef = useRef(state);
// Auto-dismiss and re-check mergeability when conflict agent completes
useEffect(() => {
const prev = prevStateRef.current;
prevStateRef.current = state;
if (prev !== 'completed' && state === 'completed') {
dismiss();
onResolved();
}
}, [state, dismiss, onResolved]);
if (state === 'none') {
return (
<div className="mx-4 mt-3 rounded-lg border border-status-error-border bg-status-error-bg/50 p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-4 w-4 text-status-error-fg mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-foreground mb-1">
{conflicts.length} merge conflict{conflicts.length !== 1 ? 's' : ''} detected
</h3>
<ul className="text-xs text-muted-foreground font-mono space-y-0.5 mb-3">
{conflicts.map((file) => (
<li key={file}>{file}</li>
))}
</ul>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => spawn.mutate({ initiativeId })}
disabled={spawn.isPending}
className="h-7 text-xs"
>
{spawn.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<GitMerge className="h-3 w-3" />
)}
Resolve with Agent
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowManual(!showManual)}
className="h-7 text-xs text-muted-foreground"
>
{showManual ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Manual Resolution
</Button>
</div>
{spawn.error && (
<p className="mt-2 text-xs text-status-error-fg">{spawn.error.message}</p>
)}
{showManual && (
<div className="mt-3 rounded border border-border bg-card p-3">
<p className="text-xs text-muted-foreground mb-2">
In your project clone, run:
</p>
<pre className="text-xs font-mono bg-terminal text-terminal-fg rounded p-2 overflow-x-auto">
{`git checkout <initiative-branch>
git merge <target-branch>
# Resolve conflicts in each file
git add <resolved-files>
git commit --no-edit`}
</pre>
</div>
)}
</div>
</div>
</div>
);
}
if (state === 'running') {
return (
<div className="mx-4 mt-3 rounded-lg border border-border bg-card px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">Resolving merge conflicts...</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => stop.mutate()}
disabled={stop.isPending}
className="h-7 text-xs"
>
Stop
</Button>
</div>
</div>
);
}
if (state === 'waiting' && questions) {
return (
<div className="mx-4 mt-3 rounded-lg border border-border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Terminal className="h-3.5 w-3.5 text-primary" />
<h3 className="text-sm font-semibold">Agent needs input</h3>
</div>
<QuestionForm
questions={questions.questions}
onSubmit={(answers) => resume.mutate(answers)}
onCancel={() => {}}
onDismiss={() => stop.mutate()}
isSubmitting={resume.isPending}
isDismissing={stop.isPending}
/>
</div>
);
}
if (state === 'completed') {
// Auto-dismiss effect above handles this — show brief success message during transition
return (
<div className="mx-4 mt-3 rounded-lg border border-status-success-border bg-status-success-bg/50 px-4 py-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-3.5 w-3.5 text-status-success-fg" />
<span className="text-sm text-status-success-fg">Conflicts resolved re-checking mergeability...</span>
<Loader2 className="h-3 w-3 animate-spin text-status-success-fg" />
</div>
</div>
);
}
if (state === 'crashed') {
return (
<div className="mx-4 mt-3 rounded-lg border border-status-error-border bg-status-error-bg/50 px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertCircle className="h-3.5 w-3.5 text-status-error-fg" />
<span className="text-sm text-status-error-fg">Conflict resolution agent crashed</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
dismiss();
}}
className="h-7 text-xs"
>
Dismiss
</Button>
<Button
size="sm"
onClick={() => {
dismiss();
spawn.mutate({ initiativeId });
}}
disabled={spawn.isPending}
className="h-7 text-xs"
>
Retry
</Button>
</div>
</div>
</div>
);
}
return null;
}

View File

@@ -12,6 +12,8 @@ interface DiffViewerProps {
) => void; ) => void;
onResolveComment: (commentId: string) => void; onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void;
onReplyComment?: (parentCommentId: string, body: string) => void;
onEditComment?: (commentId: string, body: string) => void;
viewedFiles?: Set<string>; viewedFiles?: Set<string>;
onToggleViewed?: (filePath: string) => void; onToggleViewed?: (filePath: string) => void;
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void; onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
@@ -23,6 +25,8 @@ export function DiffViewer({
onAddComment, onAddComment,
onResolveComment, onResolveComment,
onUnresolveComment, onUnresolveComment,
onReplyComment,
onEditComment,
viewedFiles, viewedFiles,
onToggleViewed, onToggleViewed,
onRegisterRef, onRegisterRef,
@@ -37,6 +41,8 @@ export function DiffViewer({
onAddComment={onAddComment} onAddComment={onAddComment}
onResolveComment={onResolveComment} onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment} onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
isViewed={viewedFiles?.has(file.newPath) ?? false} isViewed={viewedFiles?.has(file.newPath) ?? false}
onToggleViewed={() => onToggleViewed?.(file.newPath)} onToggleViewed={() => onToggleViewed?.(file.newPath)}
/> />

View File

@@ -52,6 +52,8 @@ interface FileCardProps {
) => void; ) => void;
onResolveComment: (commentId: string) => void; onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void;
onReplyComment?: (parentCommentId: string, body: string) => void;
onEditComment?: (commentId: string, body: string) => void;
isViewed?: boolean; isViewed?: boolean;
onToggleViewed?: () => void; onToggleViewed?: () => void;
} }
@@ -62,6 +64,8 @@ export function FileCard({
onAddComment, onAddComment,
onResolveComment, onResolveComment,
onUnresolveComment, onUnresolveComment,
onReplyComment,
onEditComment,
isViewed = false, isViewed = false,
onToggleViewed = () => {}, onToggleViewed = () => {},
}: FileCardProps) { }: FileCardProps) {
@@ -77,10 +81,11 @@ export function FileCard({
const tokenMap = useHighlightedFile(file.newPath, allLines); const tokenMap = useHighlightedFile(file.newPath, allLines);
return ( return (
<div className="rounded-lg border border-border overflow-hidden"> <div className="rounded-lg border border-border overflow-clip">
{/* File header — sticky so it stays visible when scrolling */} {/* File header — sticky so it stays visible when scrolling */}
<button <button
className={`sticky top-0 z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.changeType]}`} className={`sticky z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.changeType]}`}
style={{ top: 'var(--review-header-h, 0px)' }}
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
> >
{expanded ? ( {expanded ? (
@@ -157,6 +162,8 @@ export function FileCard({
onAddComment={onAddComment} onAddComment={onAddComment}
onResolveComment={onResolveComment} onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment} onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
tokenMap={tokenMap} tokenMap={tokenMap}
/> />
))} ))}

View File

@@ -15,6 +15,8 @@ interface HunkRowsProps {
) => void; ) => void;
onResolveComment: (commentId: string) => void; onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void;
onReplyComment?: (parentCommentId: string, body: string) => void;
onEditComment?: (commentId: string, body: string) => void;
tokenMap?: LineTokenMap | null; tokenMap?: LineTokenMap | null;
} }
@@ -25,6 +27,8 @@ export function HunkRows({
onAddComment, onAddComment,
onResolveComment, onResolveComment,
onUnresolveComment, onUnresolveComment,
onReplyComment,
onEditComment,
tokenMap, tokenMap,
}: HunkRowsProps) { }: HunkRowsProps) {
const [commentingLine, setCommentingLine] = useState<{ const [commentingLine, setCommentingLine] = useState<{
@@ -98,6 +102,8 @@ export function HunkRows({
onSubmitComment={handleSubmitComment} onSubmitComment={handleSubmitComment}
onResolveComment={onResolveComment} onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment} onUnresolveComment={onUnresolveComment}
onReplyComment={onReplyComment}
onEditComment={onEditComment}
tokens={ tokens={
line.newLineNumber !== null line.newLineNumber !== null
? tokenMap?.get(line.newLineNumber) ?? undefined ? tokenMap?.get(line.newLineNumber) ?? undefined

View File

@@ -1,11 +1,14 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge } from "lucide-react"; import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge, AlertTriangle, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { parseUnifiedDiff } from "./parse-diff"; import { parseUnifiedDiff } from "./parse-diff";
import { DiffViewer } from "./DiffViewer"; import { DiffViewer } from "./DiffViewer";
import { ReviewSidebar } from "./ReviewSidebar"; import { ReviewSidebar } from "./ReviewSidebar";
import { PreviewControls } from "./PreviewControls";
import { ConflictResolutionPanel } from "./ConflictResolutionPanel";
interface InitiativeReviewProps { interface InitiativeReviewProps {
initiativeId: string; initiativeId: string;
@@ -48,6 +51,61 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
{ enabled: !!selectedCommit }, { enabled: !!selectedCommit },
); );
// Mergeability check
const mergeabilityQuery = trpc.checkInitiativeMergeability.useQuery(
{ initiativeId },
{ refetchInterval: 30_000 },
);
const mergeability = mergeabilityQuery.data ?? null;
// Auto-refresh mergeability when a conflict agent completes
const conflictAgentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId });
const conflictAgentStatus = conflictAgentQuery.data?.status;
const prevConflictStatusRef = useRef(conflictAgentStatus);
useEffect(() => {
const prev = prevConflictStatusRef.current;
prevConflictStatusRef.current = conflictAgentStatus;
// When agent transitions from running/waiting to idle (completed)
if (prev && ['running', 'waiting_for_input'].includes(prev) && conflictAgentStatus === 'idle') {
void mergeabilityQuery.refetch();
void diffQuery.refetch();
void commitsQuery.refetch();
}
}, [conflictAgentStatus, mergeabilityQuery, diffQuery, commitsQuery]);
// Preview state
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
const existingPreview = previewsQuery.data?.find(
(p) => p.initiativeId === initiativeId,
);
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
{ enabled: !!(activePreviewId ?? existingPreview?.id) },
);
const preview = previewStatusQuery.data ?? existingPreview;
const startPreview = trpc.startPreview.useMutation({
onSuccess: (data) => {
setActivePreviewId(data.id);
previewsQuery.refetch();
toast.success(`Preview running at ${data.url}`);
},
onError: (err) => toast.error(`Preview failed: ${err.message}`),
});
const stopPreview = trpc.stopPreview.useMutation({
onSuccess: () => {
setActivePreviewId(null);
toast.success("Preview stopped");
previewsQuery.refetch();
},
onError: (err) => toast.error(`Failed to stop: ${err.message}`),
});
const approveMutation = trpc.approveInitiativeReview.useMutation({ const approveMutation = trpc.approveInitiativeReview.useMutation({
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
const msg = variables.strategy === "merge_and_push" const msg = variables.strategy === "merge_and_push"
@@ -87,6 +145,31 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
const sourceBranch = diffQuery.data?.sourceBranch ?? ""; const sourceBranch = diffQuery.data?.sourceBranch ?? "";
const targetBranch = diffQuery.data?.targetBranch ?? ""; const targetBranch = diffQuery.data?.targetBranch ?? "";
const previewState = firstProjectId && sourceBranch
? {
status: preview?.status === "running"
? ("running" as const)
: preview?.status === "failed"
? ("failed" as const)
: (startPreview.isPending || preview?.status === "building")
? ("building" as const)
: ("idle" as const),
url: preview?.url ?? undefined,
onStart: () =>
startPreview.mutate({
initiativeId,
projectId: firstProjectId,
branch: sourceBranch,
}),
onStop: () => {
const id = activePreviewId ?? existingPreview?.id;
if (id) stopPreview.mutate({ previewId: id });
},
isStarting: startPreview.isPending,
isStopping: stopPreview.isPending,
}
: null;
return ( return (
<div className="rounded-lg border border-border overflow-hidden bg-card"> <div className="rounded-lg border border-border overflow-hidden bg-card">
{/* Header */} {/* Header */}
@@ -125,10 +208,29 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
{totalDeletions} {totalDeletions}
</span> </span>
</div> </div>
{/* Mergeability badge */}
{mergeabilityQuery.isLoading ? (
<Badge variant="secondary" className="text-[10px] h-5">
<Loader2 className="h-2.5 w-2.5 animate-spin mr-1" />
Checking...
</Badge>
) : mergeability?.mergeable ? (
<Badge variant="success" className="text-[10px] h-5">
<CheckCircle2 className="h-2.5 w-2.5 mr-1" />
Clean merge
</Badge>
) : mergeability && !mergeability.mergeable ? (
<Badge variant="error" className="text-[10px] h-5">
<AlertTriangle className="h-2.5 w-2.5 mr-1" />
{mergeability.conflictFiles.length} conflict{mergeability.conflictFiles.length !== 1 ? 's' : ''}
</Badge>
) : null}
</div> </div>
{/* Right: action buttons */} {/* Right: preview + action buttons */}
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{previewState && <PreviewControls preview={previewState} />}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -146,7 +248,8 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
<Button <Button
size="sm" size="sm"
onClick={() => approveMutation.mutate({ initiativeId, strategy: "merge_and_push" })} onClick={() => approveMutation.mutate({ initiativeId, strategy: "merge_and_push" })}
disabled={approveMutation.isPending} disabled={approveMutation.isPending || mergeability?.mergeable === false}
title={mergeability?.mergeable === false ? 'Resolve merge conflicts before merging' : undefined}
className="h-9 px-5 text-sm font-semibold shadow-sm" className="h-9 px-5 text-sm font-semibold shadow-sm"
> >
{approveMutation.isPending ? ( {approveMutation.isPending ? (
@@ -154,12 +257,25 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
) : ( ) : (
<GitMerge className="h-3.5 w-3.5" /> <GitMerge className="h-3.5 w-3.5" />
)} )}
Merge & Push to Default Merge & Push to {targetBranch || "default"}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
{/* Conflict resolution panel */}
{mergeability && !mergeability.mergeable && (
<ConflictResolutionPanel
initiativeId={initiativeId}
conflicts={mergeability.conflictFiles}
onResolved={() => {
void mergeabilityQuery.refetch();
void diffQuery.refetch();
void commitsQuery.refetch();
}}
/>
)}
{/* Main content */} {/* Main content */}
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr]"> <div className="grid grid-cols-1 lg:grid-cols-[260px_1fr]">
<div className="border-r border-border"> <div className="border-r border-border">

View File

@@ -15,6 +15,8 @@ interface LineWithCommentsProps {
onSubmitComment: (body: string) => void; onSubmitComment: (body: string) => void;
onResolveComment: (commentId: string) => void; onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void; onUnresolveComment: (commentId: string) => void;
onReplyComment?: (parentCommentId: string, body: string) => void;
onEditComment?: (commentId: string, body: string) => void;
/** Syntax-highlighted tokens for this line (if available) */ /** Syntax-highlighted tokens for this line (if available) */
tokens?: TokenizedLine; tokens?: TokenizedLine;
} }
@@ -29,6 +31,8 @@ export function LineWithComments({
onSubmitComment, onSubmitComment,
onResolveComment, onResolveComment,
onUnresolveComment, onUnresolveComment,
onReplyComment,
onEditComment,
tokens, tokens,
}: LineWithCommentsProps) { }: LineWithCommentsProps) {
const formRef = useRef<HTMLTextAreaElement>(null); const formRef = useRef<HTMLTextAreaElement>(null);
@@ -132,7 +136,7 @@ export function LineWithComments({
{/* Existing comments on this line */} {/* Existing comments on this line */}
{lineComments.length > 0 && ( {lineComments.length > 0 && (
<tr> <tr data-comment-id={lineComments.find((c) => !c.parentCommentId)?.id}>
<td <td
colSpan={3} colSpan={3}
className="px-3 py-2 bg-muted/20 border-y border-border/50" className="px-3 py-2 bg-muted/20 border-y border-border/50"
@@ -141,6 +145,8 @@ export function LineWithComments({
comments={lineComments} comments={lineComments}
onResolve={onResolveComment} onResolve={onResolveComment}
onUnresolve={onUnresolveComment} onUnresolve={onUnresolveComment}
onReply={onReplyComment}
onEdit={onEditComment}
/> />
</td> </td>
</tr> </tr>

View File

@@ -0,0 +1,81 @@
import {
ExternalLink,
Loader2,
Square,
CircleDot,
RotateCcw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
export interface PreviewState {
status: "idle" | "building" | "running" | "failed";
url?: string;
onStart: () => void;
onStop: () => void;
isStarting: boolean;
isStopping: boolean;
}
export function PreviewControls({ preview }: { preview: PreviewState }) {
if (preview.status === "building" || preview.isStarting) {
return (
<div className="flex items-center gap-1.5 text-xs text-status-active-fg">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Building...</span>
</div>
);
}
if (preview.status === "running") {
return (
<div className="flex items-center gap-1.5">
<a
href={preview.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-status-success-fg hover:underline"
>
<CircleDot className="h-3 w-3" />
Preview
<ExternalLink className="h-2.5 w-2.5" />
</a>
<Button
variant="ghost"
size="sm"
onClick={preview.onStop}
disabled={preview.isStopping}
className="h-6 w-6 p-0"
>
<Square className="h-2.5 w-2.5" />
</Button>
</div>
);
}
if (preview.status === "failed") {
return (
<Button
variant="ghost"
size="sm"
onClick={preview.onStart}
className="h-7 text-xs text-status-error-fg"
>
<RotateCcw className="h-3 w-3" />
Retry Preview
</Button>
);
}
return (
<Button
variant="ghost"
size="sm"
onClick={preview.onStart}
disabled={preview.isStarting}
className="h-7 text-xs"
>
<ExternalLink className="h-3 w-3" />
Preview
</Button>
);
}

View File

@@ -6,11 +6,7 @@ import {
FileCode, FileCode,
Plus, Plus,
Minus, Minus,
ExternalLink,
Loader2, Loader2,
Square,
CircleDot,
RotateCcw,
ArrowRight, ArrowRight,
Eye, Eye,
AlertCircle, AlertCircle,
@@ -18,25 +14,21 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { PreviewControls } from "./PreviewControls";
import type { PreviewState } from "./PreviewControls";
import type { FileDiff, ReviewStatus } from "./types"; import type { FileDiff, ReviewStatus } from "./types";
interface PhaseOption { interface PhaseOption {
id: string; id: string;
name: string; name: string;
} status: string;
interface PreviewState {
status: "idle" | "building" | "running" | "failed";
url?: string;
onStart: () => void;
onStop: () => void;
isStarting: boolean;
isStopping: boolean;
} }
interface ReviewHeaderProps { interface ReviewHeaderProps {
ref?: React.Ref<HTMLDivElement>;
phases: PhaseOption[]; phases: PhaseOption[];
activePhaseId: string | null; activePhaseId: string | null;
isReadOnly?: boolean;
onPhaseSelect: (id: string) => void; onPhaseSelect: (id: string) => void;
phaseName: string; phaseName: string;
sourceBranch: string; sourceBranch: string;
@@ -53,8 +45,10 @@ interface ReviewHeaderProps {
} }
export function ReviewHeader({ export function ReviewHeader({
ref,
phases, phases,
activePhaseId, activePhaseId,
isReadOnly,
onPhaseSelect, onPhaseSelect,
phaseName, phaseName,
sourceBranch, sourceBranch,
@@ -72,28 +66,38 @@ export function ReviewHeader({
const totalAdditions = files.reduce((s, f) => s + f.additions, 0); const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
const totalDeletions = files.reduce((s, f) => s + f.deletions, 0); const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
const [showConfirmation, setShowConfirmation] = useState(false); const [showConfirmation, setShowConfirmation] = useState(false);
const [showRequestConfirm, setShowRequestConfirm] = useState(false);
const confirmRef = useRef<HTMLDivElement>(null); const confirmRef = useRef<HTMLDivElement>(null);
const requestConfirmRef = useRef<HTMLDivElement>(null);
// Click-outside handler to dismiss confirmation // Click-outside handler to dismiss confirmation dropdowns
useEffect(() => { useEffect(() => {
if (!showConfirmation) return; if (!showConfirmation && !showRequestConfirm) return;
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
if ( if (
showConfirmation &&
confirmRef.current && confirmRef.current &&
!confirmRef.current.contains(e.target as Node) !confirmRef.current.contains(e.target as Node)
) { ) {
setShowConfirmation(false); setShowConfirmation(false);
} }
if (
showRequestConfirm &&
requestConfirmRef.current &&
!requestConfirmRef.current.contains(e.target as Node)
) {
setShowRequestConfirm(false);
}
} }
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showConfirmation]); }, [showConfirmation, showRequestConfirm]);
const viewed = viewedCount ?? 0; const viewed = viewedCount ?? 0;
const total = totalCount ?? 0; const total = totalCount ?? 0;
return ( return (
<div className="border-b border-border bg-card/80 backdrop-blur-sm sticky top-0 z-20"> <div ref={ref} className="border-b border-border bg-card backdrop-blur-sm sticky top-0 z-20 rounded-t-lg">
{/* Phase selector row */} {/* Phase selector row */}
{phases.length > 1 && ( {phases.length > 1 && (
<div className="flex items-center gap-1 px-4 pt-3 pb-2 border-b border-border/50"> <div className="flex items-center gap-1 px-4 pt-3 pb-2 border-b border-border/50">
@@ -103,6 +107,12 @@ export function ReviewHeader({
<div className="flex gap-1 overflow-x-auto"> <div className="flex gap-1 overflow-x-auto">
{phases.map((phase) => { {phases.map((phase) => {
const isActive = phase.id === activePhaseId; const isActive = phase.id === activePhaseId;
const isCompleted = phase.status === "completed";
const dotColor = isActive
? "bg-primary"
: isCompleted
? "bg-status-success-dot"
: "bg-status-warning-dot";
return ( return (
<button <button
key={phase.id} key={phase.id}
@@ -117,9 +127,7 @@ export function ReviewHeader({
`} `}
> >
<span <span
className={`h-1.5 w-1.5 rounded-full shrink-0 ${ className={`h-1.5 w-1.5 rounded-full shrink-0 ${dotColor}`}
isActive ? "bg-primary" : "bg-status-warning-dot"
}`}
/> />
{phase.name} {phase.name}
</button> </button>
@@ -182,102 +190,151 @@ export function ReviewHeader({
{preview && <PreviewControls preview={preview} />} {preview && <PreviewControls preview={preview} />}
{/* Review status / actions */} {/* Review status / actions */}
{status === "pending" && ( {isReadOnly ? (
<>
<Button
variant="outline"
size="sm"
onClick={onRequestChanges}
disabled={isRequestingChanges}
className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border"
>
{isRequestingChanges ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<X className="h-3 w-3" />
)}
Request Changes
</Button>
<div className="relative" ref={confirmRef}>
<Button
size="sm"
onClick={() => {
if (unresolvedCount > 0) return;
setShowConfirmation(true);
}}
disabled={unresolvedCount > 0}
className="h-9 px-5 text-sm font-semibold shadow-sm"
>
{unresolvedCount > 0 ? (
<>
<AlertCircle className="h-3.5 w-3.5" />
{unresolvedCount} unresolved
</>
) : (
<>
<GitMerge className="h-3.5 w-3.5" />
Approve & Merge
</>
)}
</Button>
{/* Merge confirmation dropdown */}
{showConfirmation && (
<div className="absolute right-0 top-full mt-1 z-30 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
<p className="text-sm font-semibold mb-3">
Ready to merge?
</p>
<div className="space-y-1.5 mb-4">
<div className="flex items-center gap-2 text-xs">
<Check className="h-3.5 w-3.5 text-status-success-fg" />
<span className="text-muted-foreground">
0 unresolved comments
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
{viewed}/{total} files viewed
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowConfirmation(false)}
className="h-8 text-xs"
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
setShowConfirmation(false);
onApprove();
}}
className="h-8 px-4 text-xs font-semibold shadow-sm"
>
<GitMerge className="h-3.5 w-3.5" />
Merge Now
</Button>
</div>
</div>
)}
</div>
</>
)}
{status === "approved" && (
<Badge variant="success" size="xs"> <Badge variant="success" size="xs">
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
Approved Merged
</Badge>
)}
{status === "changes_requested" && (
<Badge variant="warning" size="xs">
<X className="h-3 w-3" />
Changes Requested
</Badge> </Badge>
) : (
<>
{status === "pending" && (
<>
<div className="relative" ref={requestConfirmRef}>
<Button
variant="outline"
size="sm"
onClick={() => setShowRequestConfirm(true)}
disabled={isRequestingChanges || unresolvedCount === 0}
className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border"
>
{isRequestingChanges ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<X className="h-3 w-3" />
)}
Request Changes
</Button>
{showRequestConfirm && (
<div className="absolute right-0 top-full mt-1 z-30 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
<p className="text-sm font-semibold mb-3">
Request changes?
</p>
<div className="space-y-1.5 mb-4">
<div className="flex items-center gap-2 text-xs">
<AlertCircle className="h-3.5 w-3.5 text-status-error-fg" />
<span className="text-muted-foreground">
{unresolvedCount} unresolved {unresolvedCount === 1 ? "comment" : "comments"} will be sent
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowRequestConfirm(false)}
className="h-8 text-xs"
>
Cancel
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setShowRequestConfirm(false);
onRequestChanges();
}}
className="h-8 px-4 text-xs font-semibold shadow-sm border-status-error-border text-status-error-fg hover:bg-status-error-bg"
>
<X className="h-3.5 w-3.5" />
Request Changes
</Button>
</div>
</div>
)}
</div>
<div className="relative" ref={confirmRef}>
<Button
size="sm"
onClick={() => {
if (unresolvedCount > 0) return;
setShowConfirmation(true);
}}
disabled={unresolvedCount > 0}
className="h-9 px-5 text-sm font-semibold shadow-sm"
>
{unresolvedCount > 0 ? (
<>
<AlertCircle className="h-3.5 w-3.5" />
{unresolvedCount} unresolved
</>
) : (
<>
<GitMerge className="h-3.5 w-3.5" />
Approve & Merge
</>
)}
</Button>
{/* Merge confirmation dropdown */}
{showConfirmation && (
<div className="absolute right-0 top-full mt-1 z-30 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
<p className="text-sm font-semibold mb-3">
Ready to merge?
</p>
<div className="space-y-1.5 mb-4">
<div className="flex items-center gap-2 text-xs">
<Check className="h-3.5 w-3.5 text-status-success-fg" />
<span className="text-muted-foreground">
0 unresolved comments
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
{viewed}/{total} files viewed
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowConfirmation(false)}
className="h-8 text-xs"
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
setShowConfirmation(false);
onApprove();
}}
className="h-8 px-4 text-xs font-semibold shadow-sm"
>
<GitMerge className="h-3.5 w-3.5" />
Merge Now
</Button>
</div>
</div>
)}
</div>
</>
)}
{status === "approved" && (
<Badge variant="success" size="xs">
<Check className="h-3 w-3" />
Approved
</Badge>
)}
{status === "changes_requested" && (
<Badge variant="warning" size="xs">
<X className="h-3 w-3" />
Changes Requested
</Badge>
)}
</>
)} )}
</div> </div>
</div> </div>
@@ -285,66 +342,3 @@ export function ReviewHeader({
); );
} }
function PreviewControls({ preview }: { preview: PreviewState }) {
if (preview.status === "building" || preview.isStarting) {
return (
<div className="flex items-center gap-1.5 text-xs text-status-active-fg">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Building...</span>
</div>
);
}
if (preview.status === "running") {
return (
<div className="flex items-center gap-1.5">
<a
href={preview.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-status-success-fg hover:underline"
>
<CircleDot className="h-3 w-3" />
Preview
<ExternalLink className="h-2.5 w-2.5" />
</a>
<Button
variant="ghost"
size="sm"
onClick={preview.onStop}
disabled={preview.isStopping}
className="h-6 w-6 p-0"
>
<Square className="h-2.5 w-2.5" />
</Button>
</div>
);
}
if (preview.status === "failed") {
return (
<Button
variant="ghost"
size="sm"
onClick={preview.onStart}
className="h-7 text-xs text-status-error-fg"
>
<RotateCcw className="h-3 w-3" />
Retry Preview
</Button>
);
}
return (
<Button
variant="ghost"
size="sm"
onClick={preview.onStart}
disabled={preview.isStarting}
className="h-7 text-xs"
>
<ExternalLink className="h-3 w-3" />
Preview
</Button>
);
}

View File

@@ -18,6 +18,7 @@ interface ReviewSidebarProps {
files: FileDiff[]; files: FileDiff[];
comments: ReviewComment[]; comments: ReviewComment[];
onFileClick: (filePath: string) => void; onFileClick: (filePath: string) => void;
onCommentClick?: (commentId: string) => void;
selectedCommit: string | null; selectedCommit: string | null;
activeFiles: FileDiff[]; activeFiles: FileDiff[];
commits: CommitInfo[]; commits: CommitInfo[];
@@ -29,6 +30,7 @@ export function ReviewSidebar({
files, files,
comments, comments,
onFileClick, onFileClick,
onCommentClick,
selectedCommit, selectedCommit,
activeFiles, activeFiles,
commits, commits,
@@ -63,6 +65,7 @@ export function ReviewSidebar({
files={files} files={files}
comments={comments} comments={comments}
onFileClick={onFileClick} onFileClick={onFileClick}
onCommentClick={onCommentClick}
selectedCommit={selectedCommit} selectedCommit={selectedCommit}
activeFiles={activeFiles} activeFiles={activeFiles}
viewedFiles={viewedFiles} viewedFiles={viewedFiles}
@@ -172,6 +175,7 @@ function FilesView({
files, files,
comments, comments,
onFileClick, onFileClick,
onCommentClick,
selectedCommit, selectedCommit,
activeFiles, activeFiles,
viewedFiles, viewedFiles,
@@ -179,12 +183,13 @@ function FilesView({
files: FileDiff[]; files: FileDiff[];
comments: ReviewComment[]; comments: ReviewComment[];
onFileClick: (filePath: string) => void; onFileClick: (filePath: string) => void;
onCommentClick?: (commentId: string) => void;
selectedCommit: string | null; selectedCommit: string | null;
activeFiles: FileDiff[]; activeFiles: FileDiff[];
viewedFiles: Set<string>; viewedFiles: Set<string>;
}) { }) {
const unresolvedCount = comments.filter((c) => !c.resolved).length; const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
const resolvedCount = comments.filter((c) => c.resolved).length; const resolvedCount = comments.filter((c) => c.resolved && !c.parentCommentId).length;
const activeFilePaths = new Set(activeFiles.map((f) => f.newPath)); const activeFilePaths = new Set(activeFiles.map((f) => f.newPath));
const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]); const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]);
@@ -213,29 +218,66 @@ function FilesView({
</div> </div>
)} )}
{/* Comment summary */} {/* Discussions — individual threads */}
{comments.length > 0 && ( {comments.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider"> <h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center justify-between">
Discussions <span>Discussions</span>
</h4> <span className="flex items-center gap-2 font-normal normal-case">
<div className="flex items-center gap-3 text-xs"> {unresolvedCount > 0 && (
<span className="flex items-center gap-1 text-muted-foreground"> <span className="flex items-center gap-0.5 text-status-warning-fg">
<MessageSquare className="h-3 w-3" /> <Circle className="h-2.5 w-2.5" />
{comments.length} {unresolvedCount}
</span>
)}
{resolvedCount > 0 && (
<span className="flex items-center gap-0.5 text-status-success-fg">
<CheckCircle2 className="h-2.5 w-2.5" />
{resolvedCount}
</span>
)}
</span> </span>
{resolvedCount > 0 && ( </h4>
<span className="flex items-center gap-1 text-status-success-fg"> <div className="space-y-0.5">
<CheckCircle2 className="h-3 w-3" /> {comments
{resolvedCount} .filter((c) => !c.parentCommentId)
</span> .map((thread) => {
)} const replyCount = comments.filter(
{unresolvedCount > 0 && ( (c) => c.parentCommentId === thread.id,
<span className="flex items-center gap-1 text-status-warning-fg"> ).length;
<Circle className="h-3 w-3" /> return (
{unresolvedCount} <button
</span> key={thread.id}
)} className={`
flex w-full flex-col gap-0.5 rounded px-2 py-1.5 text-left
transition-colors hover:bg-accent/50
${thread.resolved ? "opacity-50" : ""}
`}
onClick={() => onCommentClick ? onCommentClick(thread.id) : onFileClick(thread.filePath)}
>
<div className="flex items-center gap-1.5 w-full min-w-0">
{thread.resolved ? (
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
) : (
<MessageSquare className="h-3 w-3 text-status-warning-fg shrink-0" />
)}
<span className="text-[10px] font-mono text-muted-foreground truncate">
{getFileName(thread.filePath)}:{thread.lineNumber}
</span>
{replyCount > 0 && (
<span className="text-[9px] text-muted-foreground/70 shrink-0 ml-auto">
{replyCount}
</span>
)}
</div>
<span className="text-[11px] text-foreground/80 truncate pl-[18px]">
{thread.body.length > 60
? thread.body.slice(0, 57) + "..."
: thread.body}
</span>
</button>
);
})}
</div> </div>
</div> </div>
)} )}
@@ -263,7 +305,7 @@ function FilesView({
<div className="space-y-0.5"> <div className="space-y-0.5">
{group.files.map((file) => { {group.files.map((file) => {
const fileCommentCount = comments.filter( const fileCommentCount = comments.filter(
(c) => c.filePath === file.newPath, (c) => c.filePath === file.newPath && !c.parentCommentId,
).length; ).length;
const isInView = activeFilePaths.has(file.newPath); const isInView = activeFilePaths.has(file.newPath);
const dimmed = selectedCommit && !isInView; const dimmed = selectedCommit && !isInView;

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
@@ -18,6 +18,18 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
const [selectedCommit, setSelectedCommit] = useState<string | null>(null); const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set()); const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map()); const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const headerRef = useRef<HTMLDivElement>(null);
const [headerHeight, setHeaderHeight] = useState(0);
useEffect(() => {
const el = headerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
setHeaderHeight(entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height);
});
ro.observe(el, { box: 'border-box' });
return () => ro.disconnect();
}, []);
const toggleViewed = useCallback((filePath: string) => { const toggleViewed = useCallback((filePath: string) => {
setViewedFiles(prev => { setViewedFiles(prev => {
@@ -45,14 +57,17 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
// Fetch phases for this initiative // Fetch phases for this initiative
const phasesQuery = trpc.listPhases.useQuery({ initiativeId }); const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
const pendingReviewPhases = useMemo( const reviewablePhases = useMemo(
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review"), () => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review" || p.status === "completed"),
[phasesQuery.data], [phasesQuery.data],
); );
// Select first pending review phase // Select first pending review phase, falling back to completed phases
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null); const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
const activePhaseId = selectedPhaseId ?? pendingReviewPhases[0]?.id ?? null; const defaultPhaseId = reviewablePhases.find((p) => p.status === "pending_review")?.id ?? reviewablePhases[0]?.id ?? null;
const activePhaseId = selectedPhaseId ?? defaultPhaseId;
const activePhase = reviewablePhases.find((p) => p.id === activePhaseId);
const isActivePhaseCompleted = activePhase?.status === "completed";
// Fetch projects for this initiative (needed for preview) // Fetch projects for this initiative (needed for preview)
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId }); const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
@@ -78,20 +93,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
); );
// Preview state // Preview state
const previewsQuery = trpc.listPreviews.useQuery( const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
{ initiativeId },
{ refetchInterval: 3000 },
);
const existingPreview = previewsQuery.data?.find( const existingPreview = previewsQuery.data?.find(
(p) => p.phaseId === activePhaseId || p.initiativeId === initiativeId, (p) => p.phaseId === activePhaseId || p.initiativeId === initiativeId,
); );
const [activePreviewId, setActivePreviewId] = useState<string | null>(null); const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
const previewStatusQuery = trpc.getPreviewStatus.useQuery( const previewStatusQuery = trpc.getPreviewStatus.useQuery(
{ previewId: activePreviewId ?? existingPreview?.id ?? "" }, { previewId: activePreviewId ?? existingPreview?.id ?? "" },
{ { enabled: !!(activePreviewId ?? existingPreview?.id) },
enabled: !!(activePreviewId ?? existingPreview?.id),
refetchInterval: 3000,
},
); );
const preview = previewStatusQuery.data ?? existingPreview; const preview = previewStatusQuery.data ?? existingPreview;
const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? ""; const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? "";
@@ -99,6 +108,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
const startPreview = trpc.startPreview.useMutation({ const startPreview = trpc.startPreview.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
setActivePreviewId(data.id); setActivePreviewId(data.id);
previewsQuery.refetch();
toast.success(`Preview running at ${data.url}`); toast.success(`Preview running at ${data.url}`);
}, },
onError: (err) => toast.error(`Preview failed: ${err.message}`), onError: (err) => toast.error(`Preview failed: ${err.message}`),
@@ -115,15 +125,13 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
const previewState = firstProjectId && sourceBranch const previewState = firstProjectId && sourceBranch
? { ? {
status: startPreview.isPending status: preview?.status === "running"
? ("building" as const) ? ("running" as const)
: preview?.status === "running" : preview?.status === "failed"
? ("running" as const) ? ("failed" as const)
: preview?.status === "building" : (startPreview.isPending || preview?.status === "building")
? ("building" as const) ? ("building" as const)
: preview?.status === "failed" : ("idle" as const),
? ("failed" as const)
: ("idle" as const),
url: preview?.url ?? undefined, url: preview?.url ?? undefined,
onStart: () => onStart: () =>
startPreview.mutate({ startPreview.mutate({
@@ -157,6 +165,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
author: c.author, author: c.author,
createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt), createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt),
resolved: c.resolved, resolved: c.resolved,
parentCommentId: c.parentCommentId ?? null,
})); }));
}, [commentsQuery.data]); }, [commentsQuery.data]);
@@ -179,6 +188,20 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
}, },
}); });
const replyToCommentMutation = trpc.replyToReviewComment.useMutation({
onSuccess: () => {
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
},
onError: (err) => toast.error(`Failed to post reply: ${err.message}`),
});
const editCommentMutation = trpc.updateReviewComment.useMutation({
onSuccess: () => {
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
},
onError: (err) => toast.error(`Failed to update comment: ${err.message}`),
});
const approveMutation = trpc.approvePhaseReview.useMutation({ const approveMutation = trpc.approvePhaseReview.useMutation({
onSuccess: () => { onSuccess: () => {
setStatus("approved"); setStatus("approved");
@@ -225,6 +248,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
unresolveCommentMutation.mutate({ id: commentId }); unresolveCommentMutation.mutate({ id: commentId });
}, [unresolveCommentMutation]); }, [unresolveCommentMutation]);
const handleReplyComment = useCallback((parentCommentId: string, body: string) => {
replyToCommentMutation.mutate({ parentCommentId, body });
}, [replyToCommentMutation]);
const handleEditComment = useCallback((commentId: string, body: string) => {
editCommentMutation.mutate({ id: commentId, body });
}, [editCommentMutation]);
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (!activePhaseId) return; if (!activePhaseId) return;
approveMutation.mutate({ phaseId: activePhaseId }); approveMutation.mutate({ phaseId: activePhaseId });
@@ -241,9 +272,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
const handleRequestChanges = useCallback(() => { const handleRequestChanges = useCallback(() => {
if (!activePhaseId) return; if (!activePhaseId) return;
const summary = window.prompt("Optional: describe what needs to change (leave blank for comments only)"); requestChangesMutation.mutate({ phaseId: activePhaseId });
if (summary === null) return; // cancelled
requestChangesMutation.mutate({ phaseId: activePhaseId, summary: summary || undefined });
}, [activePhaseId, requestChangesMutation]); }, [activePhaseId, requestChangesMutation]);
const handleFileClick = useCallback((filePath: string) => { const handleFileClick = useCallback((filePath: string) => {
@@ -253,6 +282,16 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
} }
}, []); }, []);
const handleCommentClick = useCallback((commentId: string) => {
const el = document.querySelector(`[data-comment-id="${commentId}"]`);
if (el) {
el.scrollIntoView({ behavior: "instant", block: "center" });
// Brief highlight flash
el.classList.add("ring-2", "ring-primary/50");
setTimeout(() => el.classList.remove("ring-2", "ring-primary/50"), 1500);
}
}, []);
const handlePhaseSelect = useCallback((id: string) => { const handlePhaseSelect = useCallback((id: string) => {
setSelectedPhaseId(id); setSelectedPhaseId(id);
setSelectedCommit(null); setSelectedCommit(null);
@@ -260,7 +299,18 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
setViewedFiles(new Set()); setViewedFiles(new Set());
}, []); }, []);
const unresolvedCount = comments.filter((c) => !c.resolved).length; const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
const activePhaseName =
diffQuery.data?.phaseName ??
reviewablePhases.find((p) => p.id === activePhaseId)?.name ??
"Phase";
// All files from the full branch diff (for sidebar file list)
const allFiles = useMemo(() => {
if (!diffQuery.data?.rawDiff) return [];
return parseUnifiedDiff(diffQuery.data.rawDiff);
}, [diffQuery.data?.rawDiff]);
// Initiative-level review takes priority // Initiative-level review takes priority
if (isInitiativePendingReview) { if (isInitiativePendingReview) {
@@ -275,7 +325,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
); );
} }
if (pendingReviewPhases.length === 0) { if (reviewablePhases.length === 0) {
return ( return (
<div className="flex h-64 items-center justify-center text-muted-foreground"> <div className="flex h-64 items-center justify-center text-muted-foreground">
<p>No phases pending review</p> <p>No phases pending review</p>
@@ -283,23 +333,17 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
); );
} }
const activePhaseName =
diffQuery.data?.phaseName ??
pendingReviewPhases.find((p) => p.id === activePhaseId)?.name ??
"Phase";
// All files from the full branch diff (for sidebar file list)
const allFiles = useMemo(() => {
if (!diffQuery.data?.rawDiff) return [];
return parseUnifiedDiff(diffQuery.data.rawDiff);
}, [diffQuery.data?.rawDiff]);
return ( return (
<div className="rounded-lg border border-border bg-card"> <div
className="rounded-lg border border-border bg-card"
style={{ '--review-header-h': `${headerHeight}px` } as React.CSSProperties}
>
{/* Header: phase selector + toolbar */} {/* Header: phase selector + toolbar */}
<ReviewHeader <ReviewHeader
phases={pendingReviewPhases.map((p) => ({ id: p.id, name: p.name }))} ref={headerRef}
phases={reviewablePhases.map((p) => ({ id: p.id, name: p.name, status: p.status }))}
activePhaseId={activePhaseId} activePhaseId={activePhaseId}
isReadOnly={isActivePhaseCompleted}
onPhaseSelect={handlePhaseSelect} onPhaseSelect={handlePhaseSelect}
phaseName={activePhaseName} phaseName={activePhaseName}
sourceBranch={sourceBranch} sourceBranch={sourceBranch}
@@ -316,14 +360,21 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
/> />
{/* Main content area — sidebar always rendered to preserve state */} {/* Main content area — sidebar always rendered to preserve state */}
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] overflow-hidden rounded-b-lg"> <div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] rounded-b-lg">
{/* Left: Sidebar — sticky so icon strip stays visible */} {/* Left: Sidebar — sticky to viewport, scrolls independently */}
<div className="border-r border-border"> <div className="border-r border-border">
<div className="sticky top-0 h-[calc(100vh-12rem)]"> <div
className="sticky overflow-hidden"
style={{
top: `${headerHeight}px`,
maxHeight: `calc(100vh - ${headerHeight}px)`,
}}
>
<ReviewSidebar <ReviewSidebar
files={allFiles} files={allFiles}
comments={comments} comments={comments}
onFileClick={handleFileClick} onFileClick={handleFileClick}
onCommentClick={handleCommentClick}
selectedCommit={selectedCommit} selectedCommit={selectedCommit}
activeFiles={files} activeFiles={files}
commits={commits} commits={commits}
@@ -353,6 +404,8 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
onAddComment={handleAddComment} onAddComment={handleAddComment}
onResolveComment={handleResolveComment} onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment} onUnresolveComment={handleUnresolveComment}
onReplyComment={handleReplyComment}
onEditComment={handleEditComment}
viewedFiles={viewedFiles} viewedFiles={viewedFiles}
onToggleViewed={toggleViewed} onToggleViewed={toggleViewed}
onRegisterRef={registerFileRef} onRegisterRef={registerFileRef}

View File

@@ -34,6 +34,7 @@ export interface ReviewComment {
author: string; author: string;
createdAt: string; createdAt: string;
resolved: boolean; resolved: boolean;
parentCommentId?: string | null;
} }
export type ReviewStatus = "pending" | "approved" | "changes_requested"; export type ReviewStatus = "pending" | "approved" | "changes_requested";

View File

@@ -9,6 +9,7 @@ export { useAutoSave } from './useAutoSave.js';
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js'; export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
export { useLiveUpdates } from './useLiveUpdates.js'; export { useLiveUpdates } from './useLiveUpdates.js';
export { useRefineAgent } from './useRefineAgent.js'; export { useRefineAgent } from './useRefineAgent.js';
export { useConflictAgent } from './useConflictAgent.js';
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js'; export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
export type { export type {
@@ -16,3 +17,8 @@ export type {
SpawnRefineAgentOptions, SpawnRefineAgentOptions,
UseRefineAgentResult, UseRefineAgentResult,
} from './useRefineAgent.js'; } from './useRefineAgent.js';
export type {
ConflictAgentState,
UseConflictAgentResult,
} from './useConflictAgent.js';

View File

@@ -0,0 +1,214 @@
import { useCallback, useMemo, useRef } from 'react';
import { trpc } from '@/lib/trpc';
import type { PendingQuestions } from '@codewalk-district/shared';
export type ConflictAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
type ConflictAgent = NonNullable<ReturnType<typeof trpc.getActiveConflictAgent.useQuery>['data']>;
export interface UseConflictAgentResult {
agent: ConflictAgent | null;
state: ConflictAgentState;
questions: PendingQuestions | null;
spawn: {
mutate: (options: { initiativeId: string; provider?: string }) => void;
isPending: boolean;
error: Error | null;
};
resume: {
mutate: (answers: Record<string, string>) => void;
isPending: boolean;
error: Error | null;
};
stop: {
mutate: () => void;
isPending: boolean;
};
dismiss: () => void;
isLoading: boolean;
refresh: () => void;
}
export function useConflictAgent(initiativeId: string): UseConflictAgentResult {
const utils = trpc.useUtils();
const agentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId });
const agent = agentQuery.data ?? null;
const state: ConflictAgentState = useMemo(() => {
if (!agent) return 'none';
switch (agent.status) {
case 'running':
return 'running';
case 'waiting_for_input':
return 'waiting';
case 'idle':
return 'completed';
case 'crashed':
return 'crashed';
default:
return 'none';
}
}, [agent]);
const questionsQuery = trpc.getAgentQuestions.useQuery(
{ id: agent?.id ?? '' },
{ enabled: state === 'waiting' && !!agent },
);
const spawnMutation = trpc.spawnConflictResolutionAgent.useMutation({
onMutate: async () => {
await utils.listAgents.cancel();
await utils.getActiveConflictAgent.cancel({ initiativeId });
const previousAgents = utils.listAgents.getData();
const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId });
const tempAgent = {
id: `temp-${Date.now()}`,
name: `conflict-${Date.now()}`,
mode: 'execute' as const,
status: 'running' as const,
initiativeId,
taskId: null,
phaseId: null,
provider: 'claude',
accountId: null,
instruction: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userDismissedAt: null,
completedAt: null,
};
utils.listAgents.setData(undefined, (old = []) => [tempAgent, ...old]);
utils.getActiveConflictAgent.setData({ initiativeId }, tempAgent as any);
return { previousAgents, previousConflictAgent };
},
onError: (_err, _variables, context) => {
if (context?.previousAgents) {
utils.listAgents.setData(undefined, context.previousAgents);
}
if (context?.previousConflictAgent !== undefined) {
utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent);
}
},
onSettled: () => {
void utils.listAgents.invalidate();
void utils.getActiveConflictAgent.invalidate({ initiativeId });
},
});
const resumeMutation = trpc.resumeAgent.useMutation({
onSuccess: () => {
void utils.listAgents.invalidate();
},
});
const stopMutation = trpc.stopAgent.useMutation({
onSuccess: () => {
void utils.listAgents.invalidate();
void utils.listWaitingAgents.invalidate();
},
});
const dismissMutation = trpc.dismissAgent.useMutation({
onMutate: async ({ id }) => {
await utils.listAgents.cancel();
await utils.getActiveConflictAgent.cancel({ initiativeId });
const previousAgents = utils.listAgents.getData();
const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId });
utils.listAgents.setData(undefined, (old = []) => old.filter(a => a.id !== id));
utils.getActiveConflictAgent.setData({ initiativeId }, null);
return { previousAgents, previousConflictAgent };
},
onError: (_err, _variables, context) => {
if (context?.previousAgents) {
utils.listAgents.setData(undefined, context.previousAgents);
}
if (context?.previousConflictAgent !== undefined) {
utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent);
}
},
onSettled: () => {
void utils.listAgents.invalidate();
void utils.getActiveConflictAgent.invalidate({ initiativeId });
},
});
const spawnMutateRef = useRef(spawnMutation.mutate);
spawnMutateRef.current = spawnMutation.mutate;
const agentRef = useRef(agent);
agentRef.current = agent;
const resumeMutateRef = useRef(resumeMutation.mutate);
resumeMutateRef.current = resumeMutation.mutate;
const stopMutateRef = useRef(stopMutation.mutate);
stopMutateRef.current = stopMutation.mutate;
const dismissMutateRef = useRef(dismissMutation.mutate);
dismissMutateRef.current = dismissMutation.mutate;
const spawnFn = useCallback(({ initiativeId, provider }: { initiativeId: string; provider?: string }) => {
spawnMutateRef.current({ initiativeId, provider });
}, []);
const spawn = useMemo(() => ({
mutate: spawnFn,
isPending: spawnMutation.isPending,
error: spawnMutation.error,
}), [spawnFn, spawnMutation.isPending, spawnMutation.error]);
const resumeFn = useCallback((answers: Record<string, string>) => {
const a = agentRef.current;
if (a) {
resumeMutateRef.current({ id: a.id, answers });
}
}, []);
const resume = useMemo(() => ({
mutate: resumeFn,
isPending: resumeMutation.isPending,
error: resumeMutation.error,
}), [resumeFn, resumeMutation.isPending, resumeMutation.error]);
const stopFn = useCallback(() => {
const a = agentRef.current;
if (a) {
stopMutateRef.current({ id: a.id });
}
}, []);
const stop = useMemo(() => ({
mutate: stopFn,
isPending: stopMutation.isPending,
}), [stopFn, stopMutation.isPending]);
const dismiss = useCallback(() => {
const a = agentRef.current;
if (a) {
dismissMutateRef.current({ id: a.id });
}
}, []);
const refresh = useCallback(() => {
void utils.getActiveConflictAgent.invalidate({ initiativeId });
}, [utils, initiativeId]);
const isLoading = agentQuery.isLoading ||
(state === 'waiting' && questionsQuery.isLoading);
return {
agent,
state,
questions: questionsQuery.data ?? null,
spawn,
resume,
stop,
dismiss,
isLoading,
refresh,
};
}

View File

@@ -52,12 +52,12 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
// --- Phases --- // --- Phases ---
createPhase: ["listPhases", "listInitiativePhaseDependencies"], createPhase: ["listPhases", "listInitiativePhaseDependencies"],
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies"], deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies", "listChangeSets"],
updatePhase: ["listPhases", "getPhase"], updatePhase: ["listPhases", "getPhase"],
approvePhase: ["listPhases", "listInitiativeTasks"], approvePhase: ["listPhases", "listInitiativeTasks"],
queuePhase: ["listPhases"], queuePhase: ["listPhases"],
createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies"], createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies"], removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
// --- Tasks --- // --- Tasks ---
createPhaseTask: ["listPhaseTasks", "listInitiativeTasks", "listTasks"], createPhaseTask: ["listPhaseTasks", "listInitiativeTasks", "listTasks"],
@@ -65,8 +65,10 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
createChildTasks: ["listTasks", "listInitiativeTasks", "listPhaseTasks"], createChildTasks: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"], queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
deleteTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks", "listChangeSets"],
// --- Change Sets --- // --- Change Sets ---
revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage"], revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage", "getChangeSet"],
// --- Pages --- // --- Pages ---
updatePage: ["listPages", "getPage", "getRootPage"], updatePage: ["listPages", "getPage", "getRootPage"],

View File

@@ -29,10 +29,12 @@ function InitiativeDetailPage() {
// Single SSE stream for all live updates // Single SSE stream for all live updates
useLiveUpdates([ useLiveUpdates([
{ prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks'] }, { prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] },
{ prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies'] }, { prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] },
{ prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] }, { prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] },
{ prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] }, { prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] },
{ prefix: 'changeset:', invalidate: ['getChangeSet', 'listChangeSets'] },
{ prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] },
]); ]);
// tRPC queries // tRPC queries

View File

@@ -24,7 +24,7 @@
| `accounts/` | Account discovery, config dir setup, credential management, usage API | | `accounts/` | Account discovery, config dir setup, credential management, usage API |
| `credentials/` | `AccountCredentialManager` — credential injection per account | | `credentials/` | `AccountCredentialManager` — credential injection per account |
| `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions | | `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions |
| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions | | `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions |
## Key Flows ## Key Flows
@@ -236,7 +236,7 @@ All prompts follow a consistent tag ordering:
|------|------|--------------------| |------|------|--------------------|
| **execute** | `execute.ts` | `<task>`, `<execution_protocol>`, `<anti_patterns>`, `<scope_rules>` | | **execute** | `execute.ts` | `<task>`, `<execution_protocol>`, `<anti_patterns>`, `<scope_rules>` |
| **plan** | `plan.ts` | `<phase_design>`, `<dependencies>`, `<file_ownership>`, `<specificity>`, `<existing_context>` | | **plan** | `plan.ts` | `<phase_design>`, `<dependencies>`, `<file_ownership>`, `<specificity>`, `<existing_context>` |
| **detail** | `detail.ts` | `<task_body_requirements>`, `<file_ownership>`, `<task_sizing>`, `<checkpoint_tasks>`, `<existing_context>` | | **detail** | `detail.ts` | `<task_body_requirements>`, `<file_ownership>`, `<task_sizing>`, `<existing_context>` |
| **discuss** | `discuss.ts` | `<analysis_method>`, `<question_quality>`, `<decision_quality>`, `<question_categories>`, `<rules>` | | **discuss** | `discuss.ts` | `<analysis_method>`, `<question_quality>`, `<decision_quality>`, `<question_categories>`, `<rules>` |
| **refine** | `refine.ts` | `<improvement_priorities>`, `<rules>` | | **refine** | `refine.ts` | `<improvement_priorities>`, `<rules>` |
| **chat** | `chat.ts` | `<chat_history>`, `<instruction>` — iterative refinement loop, uses action field (create/update/delete) in output files, signals "questions" after each change to stay alive | | **chat** | `chat.ts` | `<chat_history>`, `<instruction>` — iterative refinement loop, uses action field (create/update/delete) in output files, signals "questions" after each change to stay alive |

View File

@@ -5,7 +5,7 @@ This project uses [drizzle-kit](https://orm.drizzle.team/kit-docs/overview) for
## Overview ## Overview
- **Schema definition:** `apps/server/db/schema.ts` (drizzle-orm table definitions) - **Schema definition:** `apps/server/db/schema.ts` (drizzle-orm table definitions)
- **Migration output:** `apps/server/drizzle/` directory (SQL files + meta journal) - **Migration output:** `apps/server/drizzle/` directory (SQL files + `meta/_journal.json` + `meta/NNNN_snapshot.json`)
- **Config:** `drizzle.config.ts` - **Config:** `drizzle.config.ts`
- **Runtime migrator:** `apps/server/db/ensure-schema.ts` (calls `drizzle-orm/better-sqlite3/migrator`) - **Runtime migrator:** `apps/server/db/ensure-schema.ts` (calls `drizzle-orm/better-sqlite3/migrator`)
@@ -13,6 +13,8 @@ This project uses [drizzle-kit](https://orm.drizzle.team/kit-docs/overview) for
On every server startup, `ensureSchema(db)` runs all pending migrations from the `apps/server/drizzle/` folder. Drizzle tracks applied migrations in a `__drizzle_migrations` table so only new migrations are applied. This is safe to call repeatedly. On every server startup, `ensureSchema(db)` runs all pending migrations from the `apps/server/drizzle/` folder. Drizzle tracks applied migrations in a `__drizzle_migrations` table so only new migrations are applied. This is safe to call repeatedly.
The migrator discovers migrations via `apps/server/drizzle/meta/_journal.json`**not** by scanning the filesystem.
## Workflow ## Workflow
### Making schema changes ### Making schema changes
@@ -23,7 +25,17 @@ On every server startup, `ensureSchema(db)` runs all pending migrations from the
npx drizzle-kit generate npx drizzle-kit generate
``` ```
3. Review the generated SQL in `apps/server/drizzle/NNNN_*.sql` 3. Review the generated SQL in `apps/server/drizzle/NNNN_*.sql`
4. Commit the migration file along with your schema change 4. Verify multi-statement migrations use `--> statement-breakpoint` between statements (required by better-sqlite3 which only allows one statement per `prepare()` call)
5. Commit the migration file, snapshot, and journal update together
### Important: statement breakpoints
better-sqlite3 rejects SQL with multiple statements in a single `prepare()` call. Drizzle-kit splits on `--> statement-breakpoint`. If you hand-write or edit a migration with multiple statements, append `--> statement-breakpoint` to the end of each statement line (before the next statement):
```sql
ALTER TABLE foo ADD COLUMN bar TEXT;--> statement-breakpoint
CREATE INDEX foo_bar_idx ON foo(bar);
```
### Applying migrations ### Applying migrations
@@ -31,20 +43,15 @@ Migrations are applied automatically on server startup. No manual step needed.
For tests, the same `ensureSchema()` function is called on in-memory SQLite databases in `apps/server/db/repositories/drizzle/test-helpers.ts`. For tests, the same `ensureSchema()` function is called on in-memory SQLite databases in `apps/server/db/repositories/drizzle/test-helpers.ts`.
### Checking migration status ## History
```bash Migrations 00000007 were generated by `drizzle-kit generate`. Migrations 00080032 were hand-written (the snapshots fell out of sync). A schema-derived snapshot was restored at 0032, so `drizzle-kit generate` works normally from that point forward.
# See what drizzle-kit would generate (dry run)
npx drizzle-kit generate --dry-run
# Open drizzle studio to inspect the database
npx drizzle-kit studio
```
## Rules ## Rules
- **Never hand-write migration SQL.** Always use `drizzle-kit generate` from the schema. - **Use `drizzle-kit generate`** for new migrations. It reads schema.ts, diffs against the last snapshot, and generates both SQL + snapshot automatically.
- **Never use raw CREATE TABLE statements** for schema initialization. The migration system handles this. - **Never use raw CREATE TABLE statements** for schema initialization. The migration system handles this.
- **Always commit migration files.** They are the source of truth for database evolution. - **Always commit migration files.** They are the source of truth for database evolution.
- **Migration files are immutable.** Once committed, never edit them. Make a new migration instead. - **Migration files are immutable.** Once committed, never edit them. Make a new migration instead.
- **Test with `npx vitest run`** after generating migrations to verify they work with in-memory databases. - **Keep schema.ts in sync.** The schema file is the source of truth for TypeScript types; migrations are the source of truth for database DDL. Both must reflect the same structure.
- **Test with `npm test`** after generating migrations to verify they work with in-memory databases.

View File

@@ -5,8 +5,8 @@
## Architecture ## Architecture
- **Schema**: `apps/server/db/schema.ts` — all tables, columns, relations - **Schema**: `apps/server/db/schema.ts` — all tables, columns, relations
- **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 13 repository interfaces - **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 14 repository interfaces
- **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 13 Drizzle adapters - **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 14 Drizzle adapters
- **Barrel exports**: `apps/server/db/index.ts` re-exports everything - **Barrel exports**: `apps/server/db/index.ts` re-exports everything
All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.returning()` for atomic reads after writes. All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.returning()` for atomic reads after writes.
@@ -29,7 +29,8 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
| initiativeId | text FK → initiatives (cascade) | | | initiativeId | text FK → initiatives (cascade) | |
| name | text NOT NULL | | | name | text NOT NULL | |
| content | text nullable | Tiptap JSON | | content | text nullable | Tiptap JSON |
| status | text enum | 'pending' \| 'approved' \| 'in_progress' \| 'completed' \| 'blocked' | | status | text enum | 'pending' \| 'approved' \| 'in_progress' \| 'completed' \| 'blocked' \| 'pending_review' |
| mergeBase | text nullable | git merge-base hash stored before phase merge, enables diff reconstruction for completed phases |
| createdAt, updatedAt | integer/timestamp | | | createdAt, updatedAt | integer/timestamp | |
### phase_dependencies ### phase_dependencies
@@ -44,12 +45,13 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
| parentTaskId | text nullable self-ref FK (cascade) | decomposition hierarchy | | parentTaskId | text nullable self-ref FK (cascade) | decomposition hierarchy |
| name | text NOT NULL | | | name | text NOT NULL | |
| description | text nullable | | | description | text nullable | |
| type | text enum | 'auto' \| 'checkpoint:human-verify' \| 'checkpoint:decision' \| 'checkpoint:human-action' | | type | text enum | 'auto' |
| category | text enum | 'execute' \| 'research' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' \| 'verify' \| 'merge' \| 'review' | | category | text enum | 'execute' \| 'research' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' \| 'verify' \| 'merge' \| 'review' |
| priority | text enum | 'low' \| 'medium' \| 'high' | | priority | text enum | 'low' \| 'medium' \| 'high' |
| status | text enum | 'pending' \| 'in_progress' \| 'completed' \| 'blocked' | | status | text enum | 'pending' \| 'in_progress' \| 'completed' \| 'blocked' |
| order | integer | default 0 | | order | integer | default 0 |
| summary | text nullable | Agent result summary — propagated to dependent tasks as context | | summary | text nullable | Agent result summary — propagated to dependent tasks as context |
| retryCount | integer NOT NULL | default 0, incremented on agent crash auto-retry, reset on manual retry |
| createdAt, updatedAt | integer/timestamp | | | createdAt, updatedAt | integer/timestamp | |
### task_dependencies ### task_dependencies
@@ -195,6 +197,21 @@ Messages within a chat session.
Index: `(chatSessionId)`. Index: `(chatSessionId)`.
### errands
Tracks errand work items linked to a project branch, optionally assigned to an agent.
| Column | Type | Notes |
|--------|------|-------|
| id | text PK | caller-supplied |
| description | text NOT NULL | human-readable description |
| branch | text NOT NULL | working branch name |
| baseBranch | text NOT NULL | default 'main' |
| agentId | text FK → agents (set null) | assigned agent; null if unassigned |
| projectId | text FK → projects (cascade) | owning project |
| status | text enum | active, pending_review, conflict, merged, abandoned; default 'active' |
| createdAt, updatedAt | integer/timestamp | |
### review_comments ### review_comments
Inline review comments on phase diffs, persisted across page reloads. Inline review comments on phase diffs, persisted across page reloads.
@@ -215,7 +232,7 @@ Index: `(phaseId)`.
## Repository Interfaces ## Repository Interfaces
13 repositories, each with standard CRUD plus domain-specific methods: 14 repositories, each with standard CRUD plus domain-specific methods:
| Repository | Key Methods | | Repository | Key Methods |
|-----------|-------------| |-----------|-------------|
@@ -232,6 +249,7 @@ Index: `(phaseId)`.
| ConversationRepository | create, findById, findPendingForAgent, answer | | ConversationRepository | create, findById, findPendingForAgent, answer |
| ChatSessionRepository | createSession, findActiveSession, findActiveSessionByAgentId, updateSession, createMessage, findMessagesBySessionId | | ChatSessionRepository | createSession, findActiveSession, findActiveSessionByAgentId, updateSession, createMessage, findMessagesBySessionId |
| ReviewCommentRepository | create, findByPhaseId, resolve, unresolve, delete | | ReviewCommentRepository | create, findByPhaseId, resolve, unresolve, delete |
| ErrandRepository | create, findById, findAll (filter by projectId/status), update, delete |
## Migrations ## Migrations
@@ -243,4 +261,4 @@ Key rules:
- See [database-migrations.md](database-migrations.md) for full workflow - See [database-migrations.md](database-migrations.md) for full workflow
- Snapshots stale after 0008; migrations 0008+ are hand-written - Snapshots stale after 0008; migrations 0008+ are hand-written
Current migrations: 0000 through 0030 (31 total). Current migrations: 0000 through 0035 (36 total).

View File

@@ -11,7 +11,7 @@
- **Adapter**: `TypedEventBus` using Node.js `EventEmitter` - **Adapter**: `TypedEventBus` using Node.js `EventEmitter`
- All events implement `BaseEvent { type, timestamp, payload }` - All events implement `BaseEvent { type, timestamp, payload }`
### Event Types (57) ### Event Types (58)
| Category | Events | Count | | Category | Events | Count |
|----------|--------|-------| |----------|--------|-------|
@@ -27,7 +27,7 @@
| **Preview** | `preview:building`, `preview:ready`, `preview:stopped`, `preview:failed` | 4 | | **Preview** | `preview:building`, `preview:ready`, `preview:stopped`, `preview:failed` | 4 |
| **Conversation** | `conversation:created`, `conversation:answered` | 2 | `conversation:created` triggers auto-resume of idle target agents via `resumeForConversation()` | | **Conversation** | `conversation:created`, `conversation:answered` | 2 | `conversation:created` triggers auto-resume of idle target agents via `resumeForConversation()` |
| **Chat** | `chat:message_created`, `chat:session_closed` | 2 | Chat session lifecycle events | | **Chat** | `chat:message_created`, `chat:session_closed` | 2 | Chat session lifecycle events |
| **Initiative** | `initiative:pending_review`, `initiative:review_approved` | 2 | Initiative-level review gate events | | **Initiative** | `initiative:pending_review`, `initiative:review_approved`, `initiative:changes_requested` | 3 | Initiative-level review gate events |
| **Project** | `project:synced`, `project:sync_failed` | 2 | Remote sync results from `ProjectSyncManager` | | **Project** | `project:synced`, `project:sync_failed` | 2 | Remote sync results from `ProjectSyncManager` |
| **Log** | `log:entry` | 1 | | **Log** | `log:entry` | 1 |
@@ -48,6 +48,7 @@ PhaseChangesRequestedEvent { phaseId, initiativeId, taskId, commentCount }
AccountCredentialsRefreshedEvent { accountId, expiresAt, previousExpiresAt? } AccountCredentialsRefreshedEvent { accountId, expiresAt, previousExpiresAt? }
InitiativePendingReviewEvent { initiativeId, branch } InitiativePendingReviewEvent { initiativeId, branch }
InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' | 'merge_and_push' } InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' | 'merge_and_push' }
InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId }
``` ```
## Task Dispatch ## Task Dispatch
@@ -64,8 +65,7 @@ InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' |
2. **Dispatch**`dispatchNext()` finds highest-priority task with all deps complete 2. **Dispatch**`dispatchNext()` finds highest-priority task with all deps complete
3. **Context gathering** — Before spawn, `dispatchNext()` gathers initiative context (initiative, phase, tasks, pages) and passes as `inputContext` to the agent. Agents receive `.cw/input/task.md`, `initiative.md`, `phase.md`, `context/tasks/`, `context/phases/`, and `pages/`. 3. **Context gathering** — Before spawn, `dispatchNext()` gathers initiative context (initiative, phase, tasks, pages) and passes as `inputContext` to the agent. Agents receive `.cw/input/task.md`, `initiative.md`, `phase.md`, `context/tasks/`, `context/phases/`, and `pages/`.
4. **Priority order**: high > medium > low, then oldest first (FIFO within priority) 4. **Priority order**: high > medium > low, then oldest first (FIFO within priority)
5. **Checkpoint skip**Tasks with type starting with `checkpoint:` skip auto-dispatch 5. **Planning skip**Planning-category tasks (research, discuss, plan, detail, refine) skip auto-dispatch — they use the architect flow
6. **Planning skip** — Planning-category tasks (research, discuss, plan, detail, refine) skip auto-dispatch — they use the architect flow
7. **Summary propagation**`completeTask()` reads the completing agent's `result.message` and stores it on the task's `summary` column. Dependent tasks see this summary in `context/tasks/<id>.md` frontmatter. 7. **Summary propagation**`completeTask()` reads the completing agent's `result.message` and stores it on the task's `summary` column. Dependent tasks see this summary in `context/tasks/<id>.md` frontmatter.
8. **Spawn failure** — If `agentManager.spawn()` throws, the task is blocked via `blockTask()` with the error message. The dispatch cycle continues instead of crashing. 8. **Spawn failure** — If `agentManager.spawn()` throws, the task is blocked via `blockTask()` with the error message. The dispatch cycle continues instead of crashing.
9. **Retry blocked**`retryBlockedTask(taskId)` resets a blocked task to pending and re-queues it. Exposed via tRPC `retryBlockedTask` mutation. The UI shows a Retry button in the task slide-over when status is `blocked`. 9. **Retry blocked**`retryBlockedTask(taskId)` resets a blocked task to pending and re-queues it. Exposed via tRPC `retryBlockedTask` mutation. The UI shows a Retry button in the task slide-over when status is `blocked`.
@@ -112,8 +112,21 @@ InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' |
| Event | Action | | Event | Action |
|-------|--------| |-------|--------|
| `phase:queued` | Dispatch ready phases → dispatch their tasks to idle agents | | `phase:queued` | Dispatch ready phases → dispatch their tasks to idle agents |
| `agent:stopped` | Re-dispatch queued tasks (freed agent slot) | | `agent:stopped` | Auto-complete task (unless user_requested), re-dispatch queued tasks (freed agent slot) |
| `task:completed` | Merge task branch, then dispatch next queued task | | `agent:crashed` | Auto-retry crashed task up to `MAX_TASK_RETRIES` (3). Increments `retryCount`, resets status to `pending`, re-queues. Exceeding retries leaves task `in_progress` for manual intervention. |
| `task:completed` | Merge task branch (if branch exists), check phase completion, dispatch next queued task |
### Crash Recovery
When an agent crashes (`agent:crashed` event), the orchestrator automatically retries the task:
1. Finds the task associated with the crashed agent
2. Checks `task.retryCount` against `MAX_TASK_RETRIES` (3)
3. If under limit: increments `retryCount`, resets task to `pending`, re-queues for dispatch
4. If over limit: logs warning, task stays `in_progress` for manual intervention
On server restart, `recoverDispatchQueues()` also recovers stuck `in_progress` tasks whose agents are dead (status is not `running` or `waiting_for_input`). These are reset to `pending` and re-queued.
Manual retry via `retryBlockedTask()` resets `retryCount` to 0, giving the task a fresh set of automatic retries.
### Coalesced Scheduling ### Coalesced Scheduling
@@ -121,6 +134,8 @@ Multiple rapid events (e.g. several `phase:queued` from `queueAllPhases`) are co
### Execution Mode Behavior ### Execution Mode Behavior
- **YOLO**: phase completes → auto-merge → auto-dispatch next phase → auto-dispatch tasks - **YOLO**: phase completes → auto-merge (if branch exists, skipped otherwise) → auto-dispatch next phase → auto-dispatch tasks
- **review_per_phase**: phase completes → set `pending_review` → STOP. User approves → `approveAndMergePhase()` → merge → dispatch next phase → dispatch tasks - **review_per_phase**: phase completes → set `pending_review` → STOP. User approves → `approveAndMergePhase()` → merge → dispatch next phase → dispatch tasks
- **request-changes**: phase `pending_review` → user requests changes → creates revision task (category=`'review'`) → phase reset to `in_progress` → agent fixes → phase returns to `pending_review` - **No branch**: Phase completion check runs regardless of branch existence. Merge steps are skipped; status transitions still fire. `updateTaskStatus` tRPC routes completions through `dispatchManager.completeTask()` to emit `task:completed`.
- **request-changes (phase)**: phase `pending_review` → user requests changes → creates revision task (category=`'review'`, dedup guard skips if active review exists) with threaded comments (`[comment:ID]` tags + reply threads) → phase reset to `in_progress` → agent reads comments, fixes code, writes `.cw/output/comment-responses.json` → OutputHandler creates reply comments and optionally resolves threads → phase returns to `pending_review`
- **request-changes (initiative)**: initiative `pending_review` → user requests changes → creates/reuses "Finalization" phase → creates review task → initiative reset to `active` → agent fixes → initiative returns to `pending_review`

View File

@@ -113,10 +113,12 @@ The initiative detail page has three tabs managed via local state (not URL param
### Review Components (`src/components/review/`) ### Review Components (`src/components/review/`)
| Component | Purpose | | Component | Purpose |
|-----------|---------| |-----------|---------|
| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview | | `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview. Phase-level review has threaded inline comments (with reply support) + Request Changes; initiative-level review has Request Changes (summary prompt) + Push Branch / Merge & Push |
| `ReviewHeader` | Consolidated toolbar: phase selector pills, branch info, stats, preview controls, approve/reject actions | | `ReviewHeader` | Consolidated toolbar: phase selector pills, branch info, stats, preview controls, approve/reject actions |
| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, comment counts, and commit navigation | | `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only comment counts, and commit navigation |
| `DiffViewer` | Unified diff renderer with inline comments | | `DiffViewer` | Unified diff renderer with threaded inline comments (root + reply threads) |
| `CommentThread` | Renders root comment with resolve/reopen + nested reply threads (agent replies styled with primary border). Inline reply form |
| `ConflictResolutionPanel` | Merge conflict detection + agent resolution in initiative review. Shows conflict files, spawns conflict agent, inline questions, re-check on completion |
| `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) | | `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) |
| `ProposalCard` | Individual proposal display | | `ProposalCard` | Individual proposal display |
@@ -128,6 +130,7 @@ shadcn/ui components: badge (6 status variants + xs size), button, card, dialog,
| Hook | Purpose | | Hook | Purpose |
|------|---------| |------|---------|
| `useRefineAgent` | Manages refine agent lifecycle for initiative | | `useRefineAgent` | Manages refine agent lifecycle for initiative |
| `useConflictAgent` | Manages conflict resolution agent lifecycle for initiative review |
| `useDetailAgent` | Manages detail agent for phase planning | | `useDetailAgent` | Manages detail agent for phase planning |
| `useAgentOutput` | Subscribes to live agent output stream | | `useAgentOutput` | Subscribes to live agent output stream |
| `useChatSession` | Manages chat session for phase/task refinement | | `useChatSession` | Manages chat session for phase/task refinement |

View File

@@ -45,6 +45,9 @@ Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a
| `remoteBranchExists(repoPath, branch)` | Check remote tracking branches (`origin/<branch>`) | | `remoteBranchExists(repoPath, branch)` | Check remote tracking branches (`origin/<branch>`) |
| `listCommits(repoPath, base, head)` | List commits head has that base doesn't (with stats) | | `listCommits(repoPath, base, head)` | List commits head has that base doesn't (with stats) |
| `diffCommit(repoPath, commitHash)` | Get unified diff for a single commit | | `diffCommit(repoPath, commitHash)` | Get unified diff for a single commit |
| `getMergeBase(repoPath, branch1, branch2)` | Get common ancestor commit hash |
| `pushBranch(repoPath, branch, remote?)` | Push branch to remote (default: 'origin') |
| `checkMergeability(repoPath, source, target)` | Dry-run merge check via `git merge-tree --write-tree` (git 2.38+). Returns `{ mergeable, conflicts? }` with no side effects |
`remoteBranchExists` is used by `registerProject` and `updateProject` to validate that a project's default branch actually exists in the cloned repository before saving. `remoteBranchExists` is used by `registerProject` and `updateProject` to validate that a project's default branch actually exists in the cloned repository before saving.

View File

@@ -66,6 +66,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| getAgentInputFiles | query | Files written to agent's `.cw/input/` dir (text only, sorted, 500 KB cap) | | getAgentInputFiles | query | Files written to agent's `.cw/input/` dir (text only, sorted, 500 KB 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) | | 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 | | getActiveRefineAgent | query | Active refine agent for initiative |
| getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) |
| listWaitingAgents | query | Agents waiting for input | | listWaitingAgents | query | Agents waiting for input |
| onAgentOutput | subscription | Live raw JSONL output stream via EventBus | | onAgentOutput | subscription | Live raw JSONL output stream via EventBus |
@@ -97,6 +98,9 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| getInitiativeReviewCommits | query | Commits on initiative branch not on default branch | | getInitiativeReviewCommits | query | Commits on initiative branch not on default branch |
| getInitiativeCommitDiff | query | Single commit diff for initiative review | | getInitiativeCommitDiff | query | Single commit diff for initiative review |
| approveInitiativeReview | mutation | Approve initiative review: `{initiativeId, strategy: 'push_branch' \| 'merge_and_push'}` | | approveInitiativeReview | mutation | Approve initiative review: `{initiativeId, strategy: 'push_branch' \| 'merge_and_push'}` |
| requestInitiativeChanges | mutation | Request changes on initiative: `{initiativeId, summary}` → creates review task in Finalization phase, resets initiative to active |
| checkInitiativeMergeability | query | Dry-run merge check: `{initiativeId}``{mergeable, conflictFiles[], targetBranch}` |
| spawnConflictResolutionAgent | mutation | Spawn agent to resolve merge conflicts: `{initiativeId, provider?}` → auto-dismisses stale conflict agents, creates merge task |
### Phases ### Phases
| Procedure | Type | Description | | Procedure | Type | Description |
@@ -117,11 +121,12 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| getPhaseReviewCommits | query | List commits between initiative and phase branch | | getPhaseReviewCommits | query | List commits between initiative and phase branch |
| getCommitDiff | query | Diff for a single commit (by hash) in a phase | | getCommitDiff | query | Diff for a single commit (by hash) in a phase |
| approvePhaseReview | mutation | Approve and merge phase branch | | approvePhaseReview | mutation | Approve and merge phase branch |
| requestPhaseChanges | mutation | Request changes: creates revision task from unresolved comments, resets phase to in_progress | | requestPhaseChanges | mutation | Request changes: creates revision task from unresolved threaded comments (with `[comment:ID]` tags and reply threads), resets phase to in_progress |
| listReviewComments | query | List review comments by phaseId | | listReviewComments | query | List review comments by phaseId (flat list including replies, frontend groups by parentCommentId) |
| createReviewComment | mutation | Create inline review comment on diff | | createReviewComment | mutation | Create inline review comment on diff |
| resolveReviewComment | mutation | Mark review comment as resolved | | resolveReviewComment | mutation | Mark review comment as resolved |
| unresolveReviewComment | mutation | Mark review comment as unresolved | | unresolveReviewComment | mutation | Mark review comment as unresolved |
| replyToReviewComment | mutation | Create a threaded reply to an existing review comment (copies parent's phaseId/filePath/lineNumber) |
### Phase Dispatch ### Phase Dispatch
| Procedure | Type | Description | | Procedure | Type | Description |