feat: Phase schema refactor, agent lifecycle module, and log chunks
Phase model changes:
- Drop `number` column (ordering now by createdAt + dependency DAG)
- Replace `description` (plain text) with `content` (Tiptap JSON)
- Add `approved` status as dispatch gate
- Add phase dependency management (list, remove, dependents)
- Approval gate in PhaseDispatchManager.queuePhase()
Agent log chunks:
- New `agent_log_chunks` table for DB-first output persistence
- LogChunkRepository port + DrizzleLogChunkRepository adapter
- FileTailer onRawContent callback streams chunks to DB
- getAgentOutput reads from DB first, falls back to file
Agent lifecycle module (src/agent/lifecycle/):
- SignalManager: atomic signal.json read/write/wait operations
- RetryPolicy: exponential backoff with error-specific strategies
- ErrorAnalyzer: pattern-based error classification
- CleanupStrategy: debug archival vs production cleanup
- AgentLifecycleController: orchestrates retry/recovery flow
- Missing signal recovery with instruction injection
Completion detection fixes:
- Read signal.json file instead of parsing stdout as JSON
- Cancellable pollForCompletion with { cancel } handle
- Centralized state cleanup via cleanupAgentState()
- Credential handler consolidation (prepareProcessEnv)
Prompts refactor:
- Split monolithic prompts.ts into per-mode modules
- Add workspace layout section to agent prompts
- Fix markdown-to-tiptap double-serialization
Server/tRPC:
- Subscription heartbeat (30s) and bounded queue (1000 max)
- Phase CRUD: approvePhase, deletePhase, dependency queries
- Page: findByIds, getPageUpdatedAtMap
- Wire new repositories through container and context
This commit is contained in:
@@ -133,7 +133,6 @@ describe('DefaultDispatchManager', () => {
|
||||
});
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
number: 1,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
testPhaseId = phase.id;
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { DefaultPhaseDispatchManager } from './phase-manager.js';
|
||||
import { DrizzlePhaseRepository } from '../db/repositories/drizzle/phase.js';
|
||||
import { DrizzleTaskRepository } from '../db/repositories/drizzle/task.js';
|
||||
import { DrizzleInitiativeRepository } from '../db/repositories/drizzle/initiative.js';
|
||||
import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js';
|
||||
import type { DrizzleDatabase } from '../db/index.js';
|
||||
import type { EventBus, DomainEvent } from '../events/types.js';
|
||||
import type { DispatchManager } from './types.js';
|
||||
|
||||
// =============================================================================
|
||||
// Test Helpers
|
||||
@@ -38,11 +40,28 @@ function createMockEventBus(): EventBus & { emittedEvents: DomainEvent[] } {
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock DispatchManager (stub, not used in phase dispatch tests).
|
||||
*/
|
||||
function createMockDispatchManager(): DispatchManager {
|
||||
return {
|
||||
queue: vi.fn(),
|
||||
getNextDispatchable: vi.fn().mockResolvedValue(null),
|
||||
dispatchNext: vi.fn().mockResolvedValue({ success: false, reason: 'mock' }),
|
||||
completeTask: vi.fn(),
|
||||
approveTask: vi.fn(),
|
||||
blockTask: vi.fn(),
|
||||
getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DefaultPhaseDispatchManager', () => {
|
||||
let db: DrizzleDatabase;
|
||||
let phaseRepository: DrizzlePhaseRepository;
|
||||
let taskRepository: DrizzleTaskRepository;
|
||||
let initiativeRepository: DrizzleInitiativeRepository;
|
||||
let eventBus: EventBus & { emittedEvents: DomainEvent[] };
|
||||
let dispatchManager: DispatchManager;
|
||||
let phaseDispatchManager: DefaultPhaseDispatchManager;
|
||||
let testInitiativeId: string;
|
||||
|
||||
@@ -50,6 +69,7 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
// Set up test database
|
||||
db = createTestDatabase();
|
||||
phaseRepository = new DrizzlePhaseRepository(db);
|
||||
taskRepository = new DrizzleTaskRepository(db);
|
||||
initiativeRepository = new DrizzleInitiativeRepository(db);
|
||||
|
||||
// Create required initiative for phases
|
||||
@@ -58,12 +78,15 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
});
|
||||
testInitiativeId = initiative.id;
|
||||
|
||||
// Create mock event bus
|
||||
// Create mock event bus and dispatch manager
|
||||
eventBus = createMockEventBus();
|
||||
dispatchManager = createMockDispatchManager();
|
||||
|
||||
// Create phase dispatch manager
|
||||
phaseDispatchManager = new DefaultPhaseDispatchManager(
|
||||
phaseRepository,
|
||||
taskRepository,
|
||||
dispatchManager,
|
||||
eventBus
|
||||
);
|
||||
});
|
||||
@@ -76,9 +99,9 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should add phase to queue', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
await phaseRepository.update(phase.id, { status: 'approved' as const });
|
||||
|
||||
await phaseDispatchManager.queuePhase(phase.id);
|
||||
|
||||
@@ -91,9 +114,9 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should emit PhaseQueuedEvent', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
await phaseRepository.update(phase.id, { status: 'approved' as const });
|
||||
|
||||
await phaseDispatchManager.queuePhase(phase.id);
|
||||
|
||||
@@ -107,14 +130,13 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should include dependencies in queued phase', async () => {
|
||||
const phase1 = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
const phase2 = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 2,
|
||||
name: 'Phase 2',
|
||||
});
|
||||
await phaseRepository.update(phase2.id, { status: 'approved' as const });
|
||||
|
||||
// Phase 2 depends on Phase 1
|
||||
await phaseRepository.createDependency(phase2.id, phase1.id);
|
||||
@@ -133,6 +155,30 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
'Phase not found'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject non-approved phase', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Pending Phase',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
await expect(phaseDispatchManager.queuePhase(phase.id)).rejects.toThrow(
|
||||
'must be approved before queuing'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject in_progress phase', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'In Progress Phase',
|
||||
status: 'in_progress',
|
||||
});
|
||||
|
||||
await expect(phaseDispatchManager.queuePhase(phase.id)).rejects.toThrow(
|
||||
'must be approved before queuing'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
@@ -148,14 +194,14 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should return phase with no dependencies first', async () => {
|
||||
const phase1 = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Phase 1 (no deps)',
|
||||
});
|
||||
const phase2 = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 2,
|
||||
name: 'Phase 2 (depends on 1)',
|
||||
});
|
||||
await phaseRepository.update(phase1.id, { status: 'approved' as const });
|
||||
await phaseRepository.update(phase2.id, { status: 'approved' as const });
|
||||
|
||||
// Phase 2 depends on Phase 1
|
||||
await phaseRepository.createDependency(phase2.id, phase1.id);
|
||||
@@ -173,15 +219,14 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should skip phases with incomplete dependencies', async () => {
|
||||
const phase1 = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Phase 1',
|
||||
status: 'pending', // Not completed
|
||||
});
|
||||
const phase2 = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 2,
|
||||
name: 'Phase 2',
|
||||
});
|
||||
await phaseRepository.update(phase2.id, { status: 'approved' as const });
|
||||
|
||||
// Phase 2 depends on Phase 1
|
||||
await phaseRepository.createDependency(phase2.id, phase1.id);
|
||||
@@ -197,14 +242,14 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should return oldest phase when multiple ready', async () => {
|
||||
const phase1 = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
const phase2 = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 2,
|
||||
name: 'Phase 2',
|
||||
});
|
||||
await phaseRepository.update(phase1.id, { status: 'approved' as const });
|
||||
await phaseRepository.update(phase2.id, { status: 'approved' as const });
|
||||
|
||||
// Queue phase1 first, then phase2
|
||||
await phaseDispatchManager.queuePhase(phase1.id);
|
||||
@@ -226,9 +271,9 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should update phase status to in_progress', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
await phaseRepository.update(phase.id, { status: 'approved' as const });
|
||||
|
||||
await phaseDispatchManager.queuePhase(phase.id);
|
||||
const result = await phaseDispatchManager.dispatchNextPhase();
|
||||
@@ -244,9 +289,9 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should emit PhaseStartedEvent', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
await phaseRepository.update(phase.id, { status: 'approved' as const });
|
||||
|
||||
await phaseDispatchManager.queuePhase(phase.id);
|
||||
await phaseDispatchManager.dispatchNextPhase();
|
||||
@@ -270,9 +315,9 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should remove phase from queue after dispatch', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
await phaseRepository.update(phase.id, { status: 'approved' as const });
|
||||
|
||||
await phaseDispatchManager.queuePhase(phase.id);
|
||||
await phaseDispatchManager.dispatchNextPhase();
|
||||
@@ -290,7 +335,6 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should update phase status to completed', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Test Phase',
|
||||
status: 'in_progress',
|
||||
});
|
||||
@@ -304,9 +348,9 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should remove from queue', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
await phaseRepository.update(phase.id, { status: 'approved' as const });
|
||||
|
||||
await phaseDispatchManager.queuePhase(phase.id);
|
||||
await phaseDispatchManager.completePhase(phase.id);
|
||||
@@ -318,7 +362,6 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should emit PhaseCompletedEvent', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Test Phase',
|
||||
status: 'in_progress',
|
||||
});
|
||||
@@ -350,9 +393,9 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should update phase status', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
await phaseRepository.update(phase.id, { status: 'approved' as const });
|
||||
|
||||
await phaseDispatchManager.queuePhase(phase.id);
|
||||
await phaseDispatchManager.blockPhase(phase.id, 'Waiting for user input');
|
||||
@@ -364,9 +407,9 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should add to blocked list', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
await phaseRepository.update(phase.id, { status: 'approved' as const });
|
||||
|
||||
await phaseDispatchManager.queuePhase(phase.id);
|
||||
await phaseDispatchManager.blockPhase(phase.id, 'Waiting for user input');
|
||||
@@ -380,9 +423,9 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should emit PhaseBlockedEvent', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
await phaseRepository.update(phase.id, { status: 'approved' as const });
|
||||
|
||||
await phaseDispatchManager.queuePhase(phase.id);
|
||||
await phaseDispatchManager.blockPhase(phase.id, 'External dependency');
|
||||
@@ -399,9 +442,9 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
it('should remove from queue when blocked', async () => {
|
||||
const phase = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
await phaseRepository.update(phase.id, { status: 'approved' as const });
|
||||
|
||||
await phaseDispatchManager.queuePhase(phase.id);
|
||||
await phaseDispatchManager.blockPhase(phase.id, 'Some reason');
|
||||
@@ -421,24 +464,24 @@ describe('DefaultPhaseDispatchManager', () => {
|
||||
// Phase A (no deps) -> Phase B & C -> Phase D (depends on B and C)
|
||||
const phaseA = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 1,
|
||||
name: 'Phase A - Foundation',
|
||||
});
|
||||
const phaseB = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 2,
|
||||
name: 'Phase B - Build on A',
|
||||
});
|
||||
const phaseC = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 3,
|
||||
name: 'Phase C - Also build on A',
|
||||
});
|
||||
const phaseD = await phaseRepository.create({
|
||||
initiativeId: testInitiativeId,
|
||||
number: 4,
|
||||
name: 'Phase D - Needs B and C',
|
||||
});
|
||||
await phaseRepository.update(phaseA.id, { status: 'approved' as const });
|
||||
await phaseRepository.update(phaseB.id, { status: 'approved' as const });
|
||||
await phaseRepository.update(phaseC.id, { status: 'approved' as const });
|
||||
await phaseRepository.update(phaseD.id, { status: 'approved' as const });
|
||||
|
||||
// Set up dependencies
|
||||
await phaseRepository.createDependency(phaseB.id, phaseA.id);
|
||||
|
||||
@@ -15,7 +15,8 @@ import type {
|
||||
PhaseBlockedEvent,
|
||||
} from '../events/index.js';
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { PhaseDispatchManager, QueuedPhase, PhaseDispatchResult } from './types.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { PhaseDispatchManager, DispatchManager, QueuedPhase, PhaseDispatchResult } from './types.js';
|
||||
|
||||
// =============================================================================
|
||||
// Internal Types
|
||||
@@ -48,11 +49,14 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
|
||||
|
||||
constructor(
|
||||
private phaseRepository: PhaseRepository,
|
||||
private taskRepository: TaskRepository,
|
||||
private dispatchManager: DispatchManager,
|
||||
private eventBus: EventBus
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Queue a phase for dispatch.
|
||||
* Only approved phases can be queued.
|
||||
* Fetches phase dependencies and adds to internal queue.
|
||||
*/
|
||||
async queuePhase(phaseId: string): Promise<void> {
|
||||
@@ -62,6 +66,11 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
|
||||
throw new Error(`Phase not found: ${phaseId}`);
|
||||
}
|
||||
|
||||
// Approval gate: only approved phases can be queued
|
||||
if (phase.status !== 'approved') {
|
||||
throw new Error(`Phase '${phaseId}' must be approved before queuing (current status: ${phase.status})`);
|
||||
}
|
||||
|
||||
// Get dependencies for this phase
|
||||
const dependsOn = await this.phaseRepository.getDependencies(phaseId);
|
||||
|
||||
@@ -120,7 +129,7 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
|
||||
|
||||
/**
|
||||
* Dispatch next available phase.
|
||||
* Updates phase status to 'in_progress' and emits PhaseStartedEvent.
|
||||
* Updates phase status to 'in_progress', queues its tasks, and emits PhaseStartedEvent.
|
||||
*/
|
||||
async dispatchNextPhase(): Promise<PhaseDispatchResult> {
|
||||
// Get next dispatchable phase
|
||||
@@ -150,6 +159,14 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
|
||||
// Remove from queue (now being worked on)
|
||||
this.phaseQueue.delete(nextPhase.phaseId);
|
||||
|
||||
// Auto-queue pending tasks for this phase
|
||||
const phaseTasks = await this.taskRepository.findByPhaseId(nextPhase.phaseId);
|
||||
for (const task of phaseTasks) {
|
||||
if (task.status === 'pending') {
|
||||
await this.dispatchManager.queue(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit PhaseStartedEvent
|
||||
const event: PhaseStartedEvent = {
|
||||
type: 'phase:started',
|
||||
|
||||
Reference in New Issue
Block a user