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:
Lukas May
2026-02-09 22:33:28 +01:00
parent 43e2c8b0ba
commit fab7706f5c
81 changed files with 4113 additions and 495 deletions

View File

@@ -133,7 +133,6 @@ describe('DefaultDispatchManager', () => {
});
const phase = await phaseRepo.create({
initiativeId: initiative.id,
number: 1,
name: 'Test Phase',
});
testPhaseId = phase.id;

View File

@@ -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);

View File

@@ -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',