diff --git a/apps/server/dispatch/manager.ts b/apps/server/dispatch/manager.ts index 0d87e9d..6ea5425 100644 --- a/apps/server/dispatch/manager.ts +++ b/apps/server/dispatch/manager.ts @@ -15,7 +15,7 @@ import type { TaskDispatchedEvent, TaskPendingApprovalEvent, } from '../events/index.js'; -import type { AgentManager, AgentResult } from '../agent/types.js'; +import type { AgentManager, AgentResult, AgentInfo } from '../agent/types.js'; import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { MessageRepository } from '../db/repositories/message-repository.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; @@ -397,15 +397,23 @@ export class DefaultDispatchManager implements DispatchManager { } // Spawn agent with task (alias auto-generated by agent manager) - const agent = await this.agentManager.spawn({ - taskId: nextTask.taskId, - initiativeId: task.initiativeId ?? undefined, - phaseId: task.phaseId ?? undefined, - prompt: buildExecutePrompt(task.description || task.name), - baseBranch, - branchName, - inputContext, - }); + let agent: AgentInfo; + try { + agent = await this.agentManager.spawn({ + taskId: nextTask.taskId, + initiativeId: task.initiativeId ?? undefined, + phaseId: task.phaseId ?? undefined, + prompt: buildExecutePrompt(task.description || task.name), + baseBranch, + branchName, + inputContext, + }); + } catch (err) { + const reason = `Spawn failed: ${err instanceof Error ? err.message : String(err)}`; + log.error({ taskId: nextTask.taskId, err: reason }, 'agent spawn failed, blocking task'); + await this.blockTask(nextTask.taskId, reason); + return { success: false, taskId: nextTask.taskId, reason }; + } log.info({ taskId: nextTask.taskId, agentId: agent.id }, 'task dispatched'); diff --git a/apps/server/dispatch/phase-manager.ts b/apps/server/dispatch/phase-manager.ts index 7702aff..6b9ec57 100644 --- a/apps/server/dispatch/phase-manager.ts +++ b/apps/server/dispatch/phase-manager.ts @@ -167,10 +167,7 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager { }; } - // Update phase status to 'in_progress' - await this.phaseRepository.update(nextPhase.phaseId, { status: 'in_progress' }); - - // Create phase branch in all linked project clones + // Create phase branch in all linked project clones (must succeed before marking in_progress) if (this.initiativeRepository && this.projectRepository && this.branchManager && this.workspaceRoot) { try { const initiative = await this.initiativeRepository.findById(phase.initiativeId); @@ -204,10 +201,16 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager { log.info({ phaseId: nextPhase.phaseId, phBranch, initBranch }, 'phase branch created'); } } catch (err) { - log.error({ phaseId: nextPhase.phaseId, err: err instanceof Error ? err.message : String(err) }, 'failed to create phase branch'); + const reason = `Branch creation failed: ${err instanceof Error ? err.message : String(err)}`; + log.error({ phaseId: nextPhase.phaseId, err: reason }, 'phase branch creation failed, blocking phase'); + await this.blockPhase(nextPhase.phaseId, reason); + return { success: false, phaseId: nextPhase.phaseId, reason }; } } + // Update phase status to 'in_progress' (only after branches confirmed) + await this.phaseRepository.update(nextPhase.phaseId, { status: 'in_progress' }); + // Remove from queue (now being worked on) this.phaseQueue.delete(nextPhase.phaseId); diff --git a/apps/server/drizzle/0020_add_change_sets.sql b/apps/server/drizzle/0020_add_change_sets.sql index 1567a69..9fcc300 100644 --- a/apps/server/drizzle/0020_add_change_sets.sql +++ b/apps/server/drizzle/0020_add_change_sets.sql @@ -9,7 +9,8 @@ CREATE TABLE `change_sets` ( `status` text NOT NULL DEFAULT 'applied', `reverted_at` integer, `created_at` integer NOT NULL -);--> statement-breakpoint +); +--> statement-breakpoint CREATE TABLE `change_set_entries` ( `id` text PRIMARY KEY NOT NULL, `change_set_id` text NOT NULL REFERENCES `change_sets`(`id`) ON DELETE CASCADE, @@ -20,6 +21,8 @@ CREATE TABLE `change_set_entries` ( `new_state` text, `sort_order` integer NOT NULL DEFAULT 0, `created_at` integer NOT NULL -);--> statement-breakpoint -CREATE INDEX `change_sets_initiative_id_idx` ON `change_sets`(`initiative_id`);--> statement-breakpoint +); +--> statement-breakpoint +CREATE INDEX `change_sets_initiative_id_idx` ON `change_sets`(`initiative_id`); +--> statement-breakpoint CREATE INDEX `change_set_entries_change_set_id_idx` ON `change_set_entries`(`change_set_id`); diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index f897bb1..658fcdd 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -66,6 +66,7 @@ AccountCredentialsRefreshedEvent { accountId, expiresAt, previousExpiresAt? } 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/.md` frontmatter. 8. **Approval check** — `completeTask()` checks `requiresApproval` (task-level, then initiative-level) 9. **Approval flow** — If approval required: status → `pending_approval`, emit `task:pending_approval` +10. **Spawn failure** — If `agentManager.spawn()` throws, the task is blocked via `blockTask()` with the error message. The dispatch cycle continues instead of crashing. ### DispatchManager Methods @@ -85,8 +86,9 @@ AccountCredentialsRefreshedEvent { accountId, expiresAt, previousExpiresAt? } 1. **Queue** — `queuePhase(phaseId)` validates phase is approved, gets dependencies 2. **Dispatch** — `dispatchNextPhase()` finds phase with all deps complete -3. **Auto-queue tasks** — When phase starts, pending execution tasks are queued (planning-category tasks excluded) -4. **Events** — `phase:queued`, `phase:started`, `phase:completed`, `phase:blocked` +3. **Branch creation** — Initiative and phase branches are created in all linked project clones. On failure, the phase is blocked via `blockPhase()` and tasks are NOT queued. +4. **Auto-queue tasks** — When phase starts (branches confirmed), pending execution tasks are queued (planning-category tasks excluded) +5. **Events** — `phase:queued`, `phase:started`, `phase:completed`, `phase:blocked` ### PhaseDispatchManager Methods