feat: add quality-review dispatch hook to intercept agent:stopped events

When an execute-mode agent stops with task_complete and the initiative has
qualityReview=true, the orchestrator now spawns a fresh execute-mode agent
to run /simplify on changed .ts/.tsx/.js files before marking the task
completed. The task transitions through quality_review status as a recursion
guard so the review agent's stop event is handled normally.

- Add apps/server/execution/quality-review.ts with three exported functions:
  computeQualifyingFiles, shouldRunQualityReview, runQualityReview
- Add apps/server/execution/quality-review.test.ts (28 tests)
- Update ExecutionOrchestrator to accept agentManager, replace
  handleAgentStopped with quality-review-aware logic, add getRepoPathForTask
- Update orchestrator.test.ts with 3 quality-review integration tests
- Update container.ts to pass agentManager to ExecutionOrchestrator
- Update docs/dispatch-events.md to reflect new agent:stopped behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 22:01:02 +01:00
parent 5137a60e70
commit c3cace7604
6 changed files with 776 additions and 5 deletions

View File

@@ -18,12 +18,14 @@ 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 { AgentRepository } from '../db/repositories/agent-repository.js';
import type { AgentManager } from '../agent/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js';
import { phaseBranchName, taskBranchName } from '../git/branch-naming.js';
import { ensureProjectClone } from '../git/project-clones.js';
import { createModuleLogger } from '../logger/index.js';
import { phaseMetaCache, fileDiffCache } from '../review/diff-cache.js';
import { shouldRunQualityReview, runQualityReview } from './quality-review.js';
const log = createModuleLogger('execution-orchestrator');
@@ -49,6 +51,7 @@ export class ExecutionOrchestrator {
private conflictResolutionService: ConflictResolutionService,
private eventBus: EventBus,
private workspaceRoot: string,
private agentManager: AgentManager,
private agentRepository?: AgentRepository,
) {}
@@ -108,15 +111,53 @@ export class ExecutionOrchestrator {
private async handleAgentStopped(event: AgentStoppedEvent): Promise<void> {
const { taskId, reason, agentId } = event.payload;
// Auto-complete task for successful agent completions, not manual stops
if (taskId && reason !== 'user_requested') {
try {
await this.dispatchManager.completeTask(taskId, agentId);
log.info({ taskId, agentId, reason }, 'task auto-completed on agent stop');
if (!this.agentRepository) {
// No agent repository — skip quality review, complete task directly
log.warn({ taskId, agentId }, 'agentRepository not available; skipping quality review');
await this.dispatchManager.completeTask(taskId, agentId);
log.info({ taskId, agentId, reason }, 'task auto-completed on agent stop');
} else {
// Get repoPath from first project in initiative (for branch diffing)
const repoPath = await this.getRepoPathForTask(taskId);
const result = await shouldRunQualityReview({
agentId,
taskId,
stopReason: reason,
agentRepository: this.agentRepository,
taskRepository: this.taskRepository,
initiativeRepository: this.initiativeRepository,
phaseRepository: this.phaseRepository,
branchManager: this.branchManager,
repoPath,
});
if (result.run) {
const task = await this.taskRepository.findById(taskId);
const initiative = await this.initiativeRepository.findById(task!.initiativeId!);
const phase = await this.phaseRepository.findById(task!.phaseId!);
const initBranch = initiative!.branch!;
await runQualityReview({
taskId,
taskBranch: taskBranchName(initBranch, taskId),
baseBranch: phaseBranchName(initBranch, phase!.name),
initiativeId: task!.initiativeId!,
qualifyingFiles: result.qualifyingFiles,
taskRepository: this.taskRepository,
agentManager: this.agentManager,
log,
});
} else {
await this.dispatchManager.completeTask(taskId, agentId);
log.info({ taskId, agentId, reason }, 'task auto-completed on agent stop');
}
}
} catch (err) {
log.warn(
{ taskId, agentId, reason, err: err instanceof Error ? err.message : String(err) },
'failed to auto-complete task on agent stop',
'failed to handle agent stop',
);
}
}
@@ -124,6 +165,14 @@ export class ExecutionOrchestrator {
this.scheduleDispatch();
}
private async getRepoPathForTask(taskId: string): Promise<string> {
const task = await this.taskRepository.findById(taskId);
if (!task?.initiativeId) return this.workspaceRoot;
const projects = await this.projectRepository.findProjectsByInitiativeId(task.initiativeId);
if (!projects.length) return this.workspaceRoot;
return ensureProjectClone(projects[0], this.workspaceRoot);
}
private async handleAgentCrashed(event: AgentCrashedEvent): Promise<void> {
const { taskId, agentId, error } = event.payload;
if (!taskId) return;