Merge branch 'main' into cw/continuous-code-quality-conflict-1772832123778

# Conflicts:
#	apps/server/drizzle/meta/0037_snapshot.json
#	apps/server/drizzle/meta/_journal.json
This commit is contained in:
Lukas May
2026-03-06 22:30:21 +01:00
39 changed files with 5291 additions and 419 deletions

View File

@@ -369,6 +369,7 @@ export class MultiProviderAgentManager implements AgentManager {
agentId, pid, agentId, pid,
() => this.handleDetachedAgentCompletion(agentId), () => this.handleDetachedAgentCompletion(agentId),
() => this.activeAgents.get(agentId)?.tailer, () => this.activeAgents.get(agentId)?.tailer,
this.createEarlyCompletionChecker(agentId),
); );
activeEntry.cancelPoll = cancel; activeEntry.cancelPoll = cancel;
@@ -406,6 +407,20 @@ export class MultiProviderAgentManager implements AgentManager {
return this.toAgentInfo(agent); return this.toAgentInfo(agent);
} }
/**
* Create a callback that checks if an agent has a valid signal.json,
* used by pollForCompletion to detect hung processes.
*/
private createEarlyCompletionChecker(agentId: string): () => Promise<boolean> {
return async () => {
const agent = await this.repository.findById(agentId);
if (!agent?.worktreeId) return false;
const agentWorkdir = this.processManager.getAgentWorkdir(agent.worktreeId);
const signal = await this.outputHandler.readSignalCompletion(agentWorkdir);
return signal !== null;
};
}
/** /**
* Handle completion of a detached agent. * Handle completion of a detached agent.
*/ */
@@ -525,6 +540,7 @@ export class MultiProviderAgentManager implements AgentManager {
agentId, pid, agentId, pid,
() => this.handleDetachedAgentCompletion(agentId), () => this.handleDetachedAgentCompletion(agentId),
() => this.activeAgents.get(agentId)?.tailer, () => this.activeAgents.get(agentId)?.tailer,
this.createEarlyCompletionChecker(agentId),
); );
commitActiveEntry.cancelPoll = commitCancel; commitActiveEntry.cancelPoll = commitCancel;
@@ -633,6 +649,7 @@ export class MultiProviderAgentManager implements AgentManager {
agentId, pid, agentId, pid,
() => this.handleDetachedAgentCompletion(agentId), () => this.handleDetachedAgentCompletion(agentId),
() => this.activeAgents.get(agentId)?.tailer, () => this.activeAgents.get(agentId)?.tailer,
this.createEarlyCompletionChecker(agentId),
); );
activeEntry.cancelPoll = cancel; activeEntry.cancelPoll = cancel;
@@ -704,6 +721,7 @@ export class MultiProviderAgentManager implements AgentManager {
agentId, pid, agentId, pid,
() => this.handleDetachedAgentCompletion(agentId), () => this.handleDetachedAgentCompletion(agentId),
() => this.activeAgents.get(agentId)?.tailer, () => this.activeAgents.get(agentId)?.tailer,
this.createEarlyCompletionChecker(agentId),
); );
activeEntry.cancelPoll = cancel; activeEntry.cancelPoll = cancel;
@@ -890,6 +908,7 @@ export class MultiProviderAgentManager implements AgentManager {
agentId, pid, agentId, pid,
() => this.handleDetachedAgentCompletion(agentId), () => this.handleDetachedAgentCompletion(agentId),
() => this.activeAgents.get(agentId)?.tailer, () => this.activeAgents.get(agentId)?.tailer,
this.createEarlyCompletionChecker(agentId),
); );
resumeActiveEntry.cancelPoll = resumeCancel; resumeActiveEntry.cancelPoll = resumeCancel;
} }
@@ -1013,6 +1032,7 @@ export class MultiProviderAgentManager implements AgentManager {
agentId, pid, agentId, pid,
() => this.handleDetachedAgentCompletion(agentId), () => this.handleDetachedAgentCompletion(agentId),
() => this.activeAgents.get(agentId)?.tailer, () => this.activeAgents.get(agentId)?.tailer,
this.createEarlyCompletionChecker(agentId),
); );
const active = this.activeAgents.get(agentId); const active = this.activeAgents.get(agentId);
if (active) active.cancelPoll = cancel; if (active) active.cancelPoll = cancel;

View File

@@ -1133,7 +1133,7 @@ export class OutputHandler {
* Uses SignalManager for atomic read-and-validate when available. * Uses SignalManager for atomic read-and-validate when available.
* Returns the raw JSON string on success, null if missing/invalid. * Returns the raw JSON string on success, null if missing/invalid.
*/ */
private async readSignalCompletion(agentWorkdir: string): Promise<string | null> { async readSignalCompletion(agentWorkdir: string): Promise<string | null> {
// Prefer SignalManager (unified implementation with proper validation) // Prefer SignalManager (unified implementation with proper validation)
if (this.signalManager) { if (this.signalManager) {
const signal = await this.signalManager.readSignal(agentWorkdir); const signal = await this.signalManager.readSignal(agentWorkdir);

View File

@@ -328,27 +328,63 @@ export class ProcessManager {
* When the process exits, calls onComplete callback. * When the process exits, calls onComplete callback.
* Returns a cancel handle to stop polling (e.g. on agent cleanup or re-resume). * Returns a cancel handle to stop polling (e.g. on agent cleanup or re-resume).
* *
* Optionally checks signal.json after a grace period to detect hung processes
* that completed work but failed to exit. If a valid signal is found while the
* process is still alive, SIGTERM is sent and normal completion proceeds.
*
* @param onComplete - Called when the process is no longer alive * @param onComplete - Called when the process is no longer alive
* @param getTailer - Function to get the current tailer for final flush * @param getTailer - Function to get the current tailer for final flush
* @param checkEarlyCompletion - Optional callback that returns true if signal.json indicates completion
*/ */
pollForCompletion( pollForCompletion(
agentId: string, agentId: string,
pid: number, pid: number,
onComplete: () => Promise<void>, onComplete: () => Promise<void>,
getTailer: () => FileTailer | undefined, getTailer: () => FileTailer | undefined,
checkEarlyCompletion?: () => Promise<boolean>,
): { cancel: () => void } { ): { cancel: () => void } {
let cancelled = false; let cancelled = false;
const check = async () => { const startTime = Date.now();
if (cancelled) return; const GRACE_PERIOD_MS = 60_000;
if (!isPidAlive(pid)) { const SIGNAL_CHECK_INTERVAL_MS = 30_000;
let lastSignalCheck = 0;
const finalize = async () => {
const tailer = getTailer(); const tailer = getTailer();
if (tailer) { if (tailer) {
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
await tailer.stop(); await tailer.stop();
} }
if (!cancelled) await onComplete(); if (!cancelled) await onComplete();
};
const check = async () => {
if (cancelled) return;
if (!isPidAlive(pid)) {
await finalize();
return; return;
} }
// Defensive signal check: after grace period, periodically check signal.json
if (checkEarlyCompletion) {
const elapsed = Date.now() - startTime;
if (elapsed >= GRACE_PERIOD_MS && Date.now() - lastSignalCheck >= SIGNAL_CHECK_INTERVAL_MS) {
lastSignalCheck = Date.now();
try {
const hasSignal = await checkEarlyCompletion();
if (hasSignal) {
log.warn({ agentId, pid, elapsedMs: elapsed }, 'signal.json found but process still alive — sending SIGTERM');
try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
await new Promise((resolve) => setTimeout(resolve, 2000));
await finalize();
return;
}
} catch (err) {
log.debug({ agentId, err: err instanceof Error ? err.message : String(err) }, 'early completion check failed');
}
}
}
if (!cancelled) setTimeout(check, 1000); if (!cancelled) setTimeout(check, 1000);
}; };
check(); check();

View File

@@ -13,6 +13,8 @@ import { createDefaultTrpcClient } from './trpc-client.js';
import { createContainer } from '../container.js'; import { createContainer } from '../container.js';
import { findWorkspaceRoot, writeCwrc, defaultCwConfig } from '../config/index.js'; import { findWorkspaceRoot, writeCwrc, defaultCwConfig } from '../config/index.js';
import { createModuleLogger } from '../logger/index.js'; import { createModuleLogger } from '../logger/index.js';
import { backfillMetricsFromPath } from '../scripts/backfill-metrics.js';
import { getDbPath } from '../db/index.js';
/** Environment variable for custom port */ /** Environment variable for custom port */
const CW_PORT_ENV = 'CW_PORT'; const CW_PORT_ENV = 'CW_PORT';
@@ -134,6 +136,22 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
} }
}); });
// Backfill metrics command (standalone — no server, no tRPC)
program
.command('backfill-metrics')
.description('Populate agent_metrics table from existing agent_log_chunks (run once after upgrading)')
.option('--db <path>', 'Path to the SQLite database file (defaults to configured DB path)')
.action(async (options: { db?: string }) => {
const dbPath = options.db ?? getDbPath();
console.log(`Backfilling metrics from ${dbPath}...`);
try {
await backfillMetricsFromPath(dbPath);
} catch (error) {
console.error('Backfill failed:', (error as Error).message);
process.exit(1);
}
});
// Agent command group // Agent command group
const agentCommand = program const agentCommand = program
.command('agent') .command('agent')

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { createTestDatabase } from './test-helpers.js';
import { agentMetrics } from '../../schema.js';
describe('agentMetrics table', () => {
it('select from empty agentMetrics returns []', async () => {
const db = createTestDatabase();
const rows = await db.select().from(agentMetrics);
expect(rows).toEqual([]);
});
it('insert and select a metrics row round-trips correctly', async () => {
const db = createTestDatabase();
await db.insert(agentMetrics).values({
agentId: 'agent-abc',
questionsCount: 3,
subagentsCount: 1,
compactionsCount: 0,
updatedAt: new Date('2024-01-01T00:00:00Z'),
});
const rows = await db.select().from(agentMetrics);
expect(rows).toHaveLength(1);
expect(rows[0].agentId).toBe('agent-abc');
expect(rows[0].questionsCount).toBe(3);
expect(rows[0].subagentsCount).toBe(1);
expect(rows[0].compactionsCount).toBe(0);
});
it('agentId is primary key — duplicate insert throws', async () => {
const db = createTestDatabase();
await db.insert(agentMetrics).values({
agentId: 'agent-dup',
questionsCount: 0,
subagentsCount: 0,
compactionsCount: 0,
updatedAt: new Date(),
});
await expect(
db.insert(agentMetrics).values({
agentId: 'agent-dup',
questionsCount: 1,
subagentsCount: 0,
compactionsCount: 0,
updatedAt: new Date(),
})
).rejects.toThrow();
});
});

View File

@@ -0,0 +1,129 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleLogChunkRepository } from './log-chunk.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
describe('DrizzleLogChunkRepository', () => {
let db: DrizzleDatabase;
let repo: DrizzleLogChunkRepository;
const testAgentId = 'agent-test-001';
beforeEach(() => {
db = createTestDatabase();
repo = new DrizzleLogChunkRepository(db);
});
it('AskUserQuestion chunk — questionsCount upserted correctly', async () => {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}] } }),
});
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([{
agentId: testAgentId,
questionsCount: 2,
subagentsCount: 0,
compactionsCount: 0,
}]);
});
it('Agent tool chunk — subagentsCount incremented', async () => {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'tool_use', name: 'Agent' }),
});
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([{
agentId: testAgentId,
questionsCount: 0,
subagentsCount: 1,
compactionsCount: 0,
}]);
});
it('Compaction event — compactionsCount incremented', async () => {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'system', subtype: 'init', source: 'compact' }),
});
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([{
agentId: testAgentId,
questionsCount: 0,
subagentsCount: 0,
compactionsCount: 1,
}]);
});
it('Irrelevant chunk type — no metrics row created', async () => {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'text', text: 'hello' }),
});
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([]);
});
it('Malformed JSON chunk — chunk persisted, metrics row absent', async () => {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: 'not-valid-json',
});
const chunks = await repo.findByAgentId(testAgentId);
expect(chunks).toHaveLength(1);
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([]);
});
it('Multiple inserts, same agent — counts accumulate additively', async () => {
// 3 Agent tool chunks
for (let i = 0; i < 3; i++) {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'tool_use', name: 'Agent' }),
});
}
// 1 AskUserQuestion with 2 questions
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}] } }),
});
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([{
agentId: testAgentId,
questionsCount: 2,
subagentsCount: 3,
compactionsCount: 0,
}]);
});
it('findMetricsByAgentIds with empty array — returns []', async () => {
const metrics = await repo.findMetricsByAgentIds([]);
expect(metrics).toEqual([]);
});
it('findMetricsByAgentIds with agentId that has no metrics row — returns []', async () => {
await repo.insertChunk({
agentId: testAgentId,
agentName: 'test-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'text', text: 'hello' }),
});
const metrics = await repo.findMetricsByAgentIds([testAgentId]);
expect(metrics).toEqual([]);
});
});

View File

@@ -4,10 +4,10 @@
* Implements LogChunkRepository interface using Drizzle ORM. * Implements LogChunkRepository interface using Drizzle ORM.
*/ */
import { eq, asc, max, inArray } from 'drizzle-orm'; import { eq, asc, max, inArray, sql } from 'drizzle-orm';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js'; import type { DrizzleDatabase } from '../../index.js';
import { agentLogChunks } from '../../schema.js'; import { agentLogChunks, agentMetrics } from '../../schema.js';
import type { LogChunkRepository } from '../log-chunk-repository.js'; import type { LogChunkRepository } from '../log-chunk-repository.js';
export class DrizzleLogChunkRepository implements LogChunkRepository { export class DrizzleLogChunkRepository implements LogChunkRepository {
@@ -19,13 +19,58 @@ export class DrizzleLogChunkRepository implements LogChunkRepository {
sessionNumber: number; sessionNumber: number;
content: string; content: string;
}): Promise<void> { }): Promise<void> {
await this.db.insert(agentLogChunks).values({ // better-sqlite3 is synchronous — transaction callback must be sync, use .run() not await
this.db.transaction((tx) => {
// 1. Always insert the chunk row first
tx.insert(agentLogChunks).values({
id: nanoid(), id: nanoid(),
agentId: data.agentId, agentId: data.agentId,
agentName: data.agentName, agentName: data.agentName,
sessionNumber: data.sessionNumber, sessionNumber: data.sessionNumber,
content: data.content, content: data.content,
createdAt: new Date(), createdAt: new Date(),
}).run();
// 2. Parse content and determine metric increments
// Wrap only the parse + upsert block — chunk insert is not rolled back on parse failure
try {
const parsed = JSON.parse(data.content);
let deltaQuestions = 0;
let deltaSubagents = 0;
let deltaCompactions = 0;
if (parsed.type === 'tool_use' && parsed.name === 'AskUserQuestion') {
deltaQuestions = parsed.input?.questions?.length ?? 0;
} else if (parsed.type === 'tool_use' && parsed.name === 'Agent') {
deltaSubagents = 1;
} else if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.source === 'compact') {
deltaCompactions = 1;
}
// 3. Only upsert if there is something to increment
if (deltaQuestions > 0 || deltaSubagents > 0 || deltaCompactions > 0) {
tx.insert(agentMetrics)
.values({
agentId: data.agentId,
questionsCount: deltaQuestions,
subagentsCount: deltaSubagents,
compactionsCount: deltaCompactions,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: agentMetrics.agentId,
set: {
questionsCount: sql`${agentMetrics.questionsCount} + ${deltaQuestions}`,
subagentsCount: sql`${agentMetrics.subagentsCount} + ${deltaSubagents}`,
compactionsCount: sql`${agentMetrics.compactionsCount} + ${deltaCompactions}`,
updatedAt: new Date(),
},
})
.run();
}
} catch {
// Malformed JSON — skip metric upsert, chunk insert already committed within transaction
}
}); });
} }
@@ -69,4 +114,22 @@ export class DrizzleLogChunkRepository implements LogChunkRepository {
return result[0]?.maxSession ?? 0; return result[0]?.maxSession ?? 0;
} }
async findMetricsByAgentIds(agentIds: string[]): Promise<{
agentId: string;
questionsCount: number;
subagentsCount: number;
compactionsCount: number;
}[]> {
if (agentIds.length === 0) return [];
return this.db
.select({
agentId: agentMetrics.agentId,
questionsCount: agentMetrics.questionsCount,
subagentsCount: agentMetrics.subagentsCount,
compactionsCount: agentMetrics.compactionsCount,
})
.from(agentMetrics)
.where(inArray(agentMetrics.agentId, agentIds));
}
} }

View File

@@ -27,4 +27,16 @@ export interface LogChunkRepository {
deleteByAgentId(agentId: string): Promise<void>; deleteByAgentId(agentId: string): Promise<void>;
getSessionCount(agentId: string): Promise<number>; getSessionCount(agentId: string): Promise<number>;
/**
* Batch-fetch pre-computed metrics for multiple agent IDs.
* Returns one row per agent that has metrics. Agents with no
* matching row in agent_metrics are omitted (not returned as zeros).
*/
findMetricsByAgentIds(agentIds: string[]): Promise<{
agentId: string;
questionsCount: number;
subagentsCount: number;
compactionsCount: number;
}[]>;
} }

View File

@@ -514,6 +514,21 @@ export const agentLogChunks = sqliteTable('agent_log_chunks', {
export type AgentLogChunk = InferSelectModel<typeof agentLogChunks>; export type AgentLogChunk = InferSelectModel<typeof agentLogChunks>;
export type NewAgentLogChunk = InferInsertModel<typeof agentLogChunks>; export type NewAgentLogChunk = InferInsertModel<typeof agentLogChunks>;
// ============================================================================
// AGENT METRICS
// ============================================================================
export const agentMetrics = sqliteTable('agent_metrics', {
agentId: text('agent_id').primaryKey(),
questionsCount: integer('questions_count').notNull().default(0),
subagentsCount: integer('subagents_count').notNull().default(0),
compactionsCount: integer('compactions_count').notNull().default(0),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export type AgentMetrics = InferSelectModel<typeof agentMetrics>;
export type NewAgentMetrics = InferInsertModel<typeof agentMetrics>;
// ============================================================================ // ============================================================================
// CONVERSATIONS (inter-agent communication) // CONVERSATIONS (inter-agent communication)
// ============================================================================ // ============================================================================

View File

@@ -0,0 +1,7 @@
CREATE TABLE `agent_metrics` (
`agent_id` text PRIMARY KEY NOT NULL,
`questions_count` integer DEFAULT 0 NOT NULL,
`subagents_count` integer DEFAULT 0 NOT NULL,
`compactions_count` integer DEFAULT 0 NOT NULL,
`updated_at` integer NOT NULL
);

View File

@@ -0,0 +1 @@
ALTER TABLE `initiatives` ADD `quality_review` integer DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "ef1d4ce7-a9c4-4e86-9cac-2e9cbbd0e688", "id": "eb30417e-d030-457f-911e-6566dce54fc9",
"prevId": "c84e499f-7df8-4091-b2a5-6b12847898bd", "prevId": "f85b9df3-dead-4c46-90ac-cf36bcaa6eb4",
"tables": { "tables": {
"accounts": { "accounts": {
"name": "accounts", "name": "accounts",
@@ -155,6 +155,54 @@
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "checkConstraints": {}
}, },
"agent_metrics": {
"name": "agent_metrics",
"columns": {
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"questions_count": {
"name": "questions_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"subagents_count": {
"name": "subagents_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"compactions_count": {
"name": "compactions_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"agents": { "agents": {
"name": "agents", "name": "agents",
"columns": { "columns": {
@@ -1137,14 +1185,6 @@
"autoincrement": false, "autoincrement": false,
"default": "'review_per_phase'" "default": "'review_per_phase'"
}, },
"quality_review": {
"name": "quality_review",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "integer", "type": "integer",

File diff suppressed because it is too large Load Diff

View File

@@ -264,8 +264,15 @@
{ {
"idx": 37, "idx": 37,
"version": "6", "version": "6",
"when": 1772828694292,
"tag": "0037_eager_devos",
"breakpoints": true
},
{
"idx": 38,
"version": "6",
"when": 1772829916655, "when": 1772829916655,
"tag": "0037_worthless_princess_powerful", "tag": "0038_worthless_princess_powerful",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@@ -21,7 +21,7 @@ import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { AgentManager } from '../agent/types.js'; import type { AgentManager } from '../agent/types.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, isPlanningCategory } from '../git/branch-naming.js';
import { ensureProjectClone } from '../git/project-clones.js'; import { ensureProjectClone } from '../git/project-clones.js';
import { createModuleLogger } from '../logger/index.js'; import { createModuleLogger } from '../logger/index.js';
import { phaseMetaCache, fileDiffCache } from '../review/diff-cache.js'; import { phaseMetaCache, fileDiffCache } from '../review/diff-cache.js';
@@ -708,6 +708,23 @@ export class ExecutionOrchestrator {
} }
} }
// Clean up stale duplicate planning tasks (e.g. a crashed detail task
// that was reset to pending, then a new detail task was created and completed).
const tasksAfterRecovery = await this.taskRepository.findByPhaseId(phase.id);
const completedPlanningNames = new Set<string>();
for (const t of tasksAfterRecovery) {
if (isPlanningCategory(t.category) && t.status === 'completed') {
completedPlanningNames.add(`${t.category}:${t.phaseId}`);
}
}
for (const t of tasksAfterRecovery) {
if (isPlanningCategory(t.category) && t.status === 'pending' && completedPlanningNames.has(`${t.category}:${t.phaseId}`)) {
await this.taskRepository.update(t.id, { status: 'completed', summary: 'Superseded by retry' });
tasksRecovered++;
log.info({ taskId: t.id, category: t.category }, 'recovered stale duplicate planning task');
}
}
// Re-read tasks after recovery updates and check if phase is now fully done // Re-read tasks after recovery updates and check if phase is now fully done
const updatedTasks = await this.taskRepository.findByPhaseId(phase.id); const updatedTasks = await this.taskRepository.findByPhaseId(phase.id);
const allDone = updatedTasks.every((t) => t.status === 'completed'); const allDone = updatedTasks.every((t) => t.status === 'completed');

View File

@@ -46,8 +46,17 @@ export class SimpleGitBranchManager implements BranchManager {
return; return;
} }
try {
await git.branch([branch, baseBranch]); await git.branch([branch, baseBranch]);
log.info({ repoPath, branch, baseBranch }, 'branch created'); log.info({ repoPath, branch, baseBranch }, 'branch created');
} catch (err) {
// Handle TOCTOU race: branch may have been created between the exists check and now
if (err instanceof Error && err.message.includes('already exists')) {
log.debug({ repoPath, branch }, 'branch created by concurrent process');
return;
}
throw err;
}
} }
async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult> { async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult> {

View File

@@ -0,0 +1,131 @@
/**
* Tests for the backfill-metrics script.
*
* Uses an in-memory test database to verify that backfillMetrics correctly
* accumulates counts from agent_log_chunks and upserts into agent_metrics.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js';
import type { DrizzleDatabase } from '../db/index.js';
import { agentLogChunks, agentMetrics } from '../db/index.js';
import { backfillMetrics } from './backfill-metrics.js';
import { nanoid } from 'nanoid';
import { eq } from 'drizzle-orm';
async function insertChunk(db: DrizzleDatabase, agentId: string, content: object | string) {
await db.insert(agentLogChunks).values({
id: nanoid(),
agentId,
agentName: 'test-agent',
sessionNumber: 1,
content: typeof content === 'string' ? content : JSON.stringify(content),
createdAt: new Date(),
});
}
describe('backfillMetrics', () => {
let db: DrizzleDatabase;
beforeEach(() => {
db = createTestDatabase();
});
it('AskUserQuestion chunks — questionsCount correct', async () => {
await insertChunk(db, 'agent-a', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}] } });
await insertChunk(db, 'agent-a', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}] } });
await backfillMetrics(db);
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-a'));
expect(rows).toHaveLength(1);
expect(rows[0].questionsCount).toBe(3);
expect(rows[0].subagentsCount).toBe(0);
expect(rows[0].compactionsCount).toBe(0);
});
it('Agent tool chunks — subagentsCount correct', async () => {
await insertChunk(db, 'agent-b', { type: 'tool_use', name: 'Agent' });
await insertChunk(db, 'agent-b', { type: 'tool_use', name: 'Agent' });
await backfillMetrics(db);
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-b'));
expect(rows).toHaveLength(1);
expect(rows[0].questionsCount).toBe(0);
expect(rows[0].subagentsCount).toBe(2);
expect(rows[0].compactionsCount).toBe(0);
});
it('Compaction chunks — compactionsCount correct', async () => {
await insertChunk(db, 'agent-c', { type: 'system', subtype: 'init', source: 'compact' });
await backfillMetrics(db);
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-c'));
expect(rows).toHaveLength(1);
expect(rows[0].questionsCount).toBe(0);
expect(rows[0].subagentsCount).toBe(0);
expect(rows[0].compactionsCount).toBe(1);
});
it('Irrelevant chunk type — no metrics row created', async () => {
await insertChunk(db, 'agent-d', { type: 'text', text: 'hello' });
await backfillMetrics(db);
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-d'));
expect(rows).toEqual([]);
});
it('Malformed JSON chunk — skipped, no crash', async () => {
await insertChunk(db, 'agent-e', 'not-valid-json');
await insertChunk(db, 'agent-e', { type: 'tool_use', name: 'Agent' });
await expect(backfillMetrics(db)).resolves.not.toThrow();
const rows = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-e'));
expect(rows).toHaveLength(1);
expect(rows[0].subagentsCount).toBe(1);
});
it('Multiple agents — counts isolated per agent', async () => {
await insertChunk(db, 'agent-f', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}, {}] } });
await insertChunk(db, 'agent-f', { type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{}, {}, {}] } });
await insertChunk(db, 'agent-g', { type: 'tool_use', name: 'Agent' });
await backfillMetrics(db);
const rowsF = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-f'));
expect(rowsF).toHaveLength(1);
expect(rowsF[0].questionsCount).toBe(6);
expect(rowsF[0].subagentsCount).toBe(0);
expect(rowsF[0].compactionsCount).toBe(0);
const rowsG = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-g'));
expect(rowsG).toHaveLength(1);
expect(rowsG[0].questionsCount).toBe(0);
expect(rowsG[0].subagentsCount).toBe(1);
expect(rowsG[0].compactionsCount).toBe(0);
});
it('Empty database — completes without error', async () => {
await expect(backfillMetrics(db)).resolves.not.toThrow();
const rows = await db.select().from(agentMetrics);
expect(rows).toEqual([]);
});
it('Re-run idempotency note — second run doubles counts', async () => {
// Documented behavior: run only once against a fresh agent_metrics table
await insertChunk(db, 'agent-h', { type: 'tool_use', name: 'Agent' });
await backfillMetrics(db);
const rowsAfterFirst = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-h'));
expect(rowsAfterFirst[0].subagentsCount).toBe(1);
await backfillMetrics(db);
const rowsAfterSecond = await db.select().from(agentMetrics).where(eq(agentMetrics.agentId, 'agent-h'));
expect(rowsAfterSecond[0].subagentsCount).toBe(2);
});
});

View File

@@ -0,0 +1,128 @@
/**
* Backfill script for agent_metrics table.
*
* Reads all existing agent_log_chunks rows and populates agent_metrics with
* accumulated counts of questions, subagent spawns, and compaction events.
*
* Intended to be run once per production database after applying the migration
* that introduces the agent_metrics table.
*
* Idempotency note: Uses ON CONFLICT DO UPDATE with additive increments to match
* the ongoing insertChunk write-path behavior. Running against an empty
* agent_metrics table is fully safe. Running a second time will double-count —
* only run this script once per database, immediately after applying the migration.
*/
import { asc, sql } from 'drizzle-orm';
import { createDatabase, DrizzleDatabase, agentLogChunks, agentMetrics } from '../db/index.js';
const BATCH_SIZE = 500;
const LOG_EVERY = 1000;
/**
* Core backfill function. Accepts a DrizzleDatabase for testability.
*/
export async function backfillMetrics(db: DrizzleDatabase): Promise<void> {
const accumulator = new Map<string, { questionsCount: number; subagentsCount: number; compactionsCount: number }>();
let offset = 0;
let totalChunks = 0;
let malformedCount = 0;
while (true) {
const batch = await db
.select({ agentId: agentLogChunks.agentId, content: agentLogChunks.content })
.from(agentLogChunks)
.orderBy(asc(agentLogChunks.createdAt))
.limit(BATCH_SIZE)
.offset(offset);
if (batch.length === 0) break;
for (const chunk of batch) {
let parsed: unknown;
try {
parsed = JSON.parse(chunk.content);
} catch {
malformedCount++;
totalChunks++;
if (totalChunks % LOG_EVERY === 0) {
console.log(`Processed ${totalChunks} chunks...`);
}
continue;
}
if (typeof parsed !== 'object' || parsed === null) {
totalChunks++;
if (totalChunks % LOG_EVERY === 0) {
console.log(`Processed ${totalChunks} chunks...`);
}
continue;
}
const obj = parsed as Record<string, unknown>;
const type = obj['type'];
const name = obj['name'];
if (type === 'tool_use' && name === 'AskUserQuestion') {
const input = obj['input'] as Record<string, unknown> | undefined;
const questions = input?.['questions'];
const count = Array.isArray(questions) ? questions.length : 0;
if (count > 0) {
const entry = accumulator.get(chunk.agentId) ?? { questionsCount: 0, subagentsCount: 0, compactionsCount: 0 };
entry.questionsCount += count;
accumulator.set(chunk.agentId, entry);
}
} else if (type === 'tool_use' && name === 'Agent') {
const entry = accumulator.get(chunk.agentId) ?? { questionsCount: 0, subagentsCount: 0, compactionsCount: 0 };
entry.subagentsCount += 1;
accumulator.set(chunk.agentId, entry);
} else if (type === 'system' && obj['subtype'] === 'init' && obj['source'] === 'compact') {
const entry = accumulator.get(chunk.agentId) ?? { questionsCount: 0, subagentsCount: 0, compactionsCount: 0 };
entry.compactionsCount += 1;
accumulator.set(chunk.agentId, entry);
}
totalChunks++;
if (totalChunks % LOG_EVERY === 0) {
console.log(`Processed ${totalChunks} chunks...`);
}
}
offset += BATCH_SIZE;
}
// Upsert accumulated counts into agent_metrics.
// Uses additive ON CONFLICT DO UPDATE to match the ongoing insertChunk behavior.
for (const [agentId, counts] of accumulator) {
await db
.insert(agentMetrics)
.values({
agentId,
questionsCount: counts.questionsCount,
subagentsCount: counts.subagentsCount,
compactionsCount: counts.compactionsCount,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: agentMetrics.agentId,
set: {
questionsCount: sql`${agentMetrics.questionsCount} + ${counts.questionsCount}`,
subagentsCount: sql`${agentMetrics.subagentsCount} + ${counts.subagentsCount}`,
compactionsCount: sql`${agentMetrics.compactionsCount} + ${counts.compactionsCount}`,
updatedAt: new Date(),
},
});
}
console.log(
`Backfill complete: ${accumulator.size} agents updated, ${totalChunks} chunks processed, ${malformedCount} malformed chunks skipped`
);
}
/**
* CLI wrapper — opens a database from a path, then delegates to backfillMetrics.
*/
export async function backfillMetricsFromPath(dbPath: string): Promise<void> {
const db = createDatabase(dbPath);
await backfillMetrics(db);
}

View File

@@ -22,6 +22,7 @@ import type { LogChunkRepository } from '../db/repositories/log-chunk-repository
import type { ConversationRepository } from '../db/repositories/conversation-repository.js'; import type { ConversationRepository } from '../db/repositories/conversation-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 { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
import type { ErrandRepository } from '../db/repositories/errand-repository.js';
import type { AccountCredentialManager } from '../agent/credentials/types.js'; import type { AccountCredentialManager } from '../agent/credentials/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js'; import type { CoordinationManager } from '../coordination/types.js';
@@ -82,6 +83,8 @@ export interface TrpcAdapterOptions {
reviewCommentRepository?: ReviewCommentRepository; reviewCommentRepository?: ReviewCommentRepository;
/** Project sync manager for remote fetch/sync operations */ /** Project sync manager for remote fetch/sync operations */
projectSyncManager?: ProjectSyncManager; projectSyncManager?: ProjectSyncManager;
/** Errand repository for errand CRUD operations */
errandRepository?: ErrandRepository;
/** Absolute path to the workspace root (.cwrc directory) */ /** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string; workspaceRoot?: string;
} }
@@ -166,6 +169,7 @@ export function createTrpcHandler(options: TrpcAdapterOptions) {
chatSessionRepository: options.chatSessionRepository, chatSessionRepository: options.chatSessionRepository,
reviewCommentRepository: options.reviewCommentRepository, reviewCommentRepository: options.reviewCommentRepository,
projectSyncManager: options.projectSyncManager, projectSyncManager: options.projectSyncManager,
errandRepository: options.errandRepository,
workspaceRoot: options.workspaceRoot, workspaceRoot: options.workspaceRoot,
}), }),
}); });

View File

@@ -326,6 +326,47 @@ describe('agent.listForRadar', () => {
expect(row!.subagentsCount).toBe(0); expect(row!.subagentsCount).toBe(0);
expect(row!.compactionsCount).toBe(0); expect(row!.compactionsCount).toBe(0);
}); });
it('returns zero counts for agent with no metrics row', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const now = new Date();
// Agent with no log chunks at all — no agent_metrics row will exist
agents.addAgent({ id: 'agent-no-chunks', name: 'no-chunks-agent', status: 'running', createdAt: now });
const caller = createAgentCaller(ctx);
const result = await caller.listForRadar({ timeRange: 'all' });
const row = result.find(r => r.id === 'agent-no-chunks');
expect(row).toBeDefined();
expect(row!.questionsCount).toBe(0);
expect(row!.subagentsCount).toBe(0);
expect(row!.compactionsCount).toBe(0);
});
it('listForRadar response does not contain chunk content field', async () => {
const agents = new MockAgentManager();
const ctx = makeCtx(agents);
const { logChunkRepo } = getRepos(ctx);
const now = new Date();
agents.addAgent({ id: 'agent-content', name: 'content-agent', status: 'running', createdAt: now });
await logChunkRepo.insertChunk({
agentId: 'agent-content',
agentName: 'content-agent',
sessionNumber: 1,
content: JSON.stringify({ type: 'tool_use', name: 'Agent', input: { description: 'do stuff', prompt: 'some prompt' } }),
});
const caller = createAgentCaller(ctx);
const result = await caller.listForRadar({ timeRange: 'all' });
for (const row of result) {
expect(row).not.toHaveProperty('content');
}
});
}); });
// ============================================================================= // =============================================================================

View File

@@ -475,8 +475,8 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
const uniqueTaskIds = [...new Set(filteredAgents.map(a => a.taskId).filter(Boolean) as string[])]; const uniqueTaskIds = [...new Set(filteredAgents.map(a => a.taskId).filter(Boolean) as string[])];
const uniqueInitiativeIds = [...new Set(filteredAgents.map(a => a.initiativeId).filter(Boolean) as string[])]; const uniqueInitiativeIds = [...new Set(filteredAgents.map(a => a.initiativeId).filter(Boolean) as string[])];
const [chunks, messageCounts, taskResults, initiativeResults] = await Promise.all([ const [metrics, messageCounts, taskResults, initiativeResults] = await Promise.all([
logChunkRepo.findByAgentIds(matchingIds), logChunkRepo.findMetricsByAgentIds(matchingIds),
conversationRepo.countByFromAgentIds(matchingIds), conversationRepo.countByFromAgentIds(matchingIds),
Promise.all(uniqueTaskIds.map(id => taskRepo.findById(id))), Promise.all(uniqueTaskIds.map(id => taskRepo.findById(id))),
Promise.all(uniqueInitiativeIds.map(id => initiativeRepo.findById(id))), Promise.all(uniqueInitiativeIds.map(id => initiativeRepo.findById(id))),
@@ -486,37 +486,14 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
const taskMap = new Map(taskResults.filter(Boolean).map(t => [t!.id, t!.name])); const taskMap = new Map(taskResults.filter(Boolean).map(t => [t!.id, t!.name]));
const initiativeMap = new Map(initiativeResults.filter(Boolean).map(i => [i!.id, i!.name])); const initiativeMap = new Map(initiativeResults.filter(Boolean).map(i => [i!.id, i!.name]));
const messagesMap = new Map(messageCounts.map(m => [m.agentId, m.count])); const messagesMap = new Map(messageCounts.map(m => [m.agentId, m.count]));
const metricsMap = new Map(metrics.map(m => [m.agentId, m]));
// Group chunks by agentId
const chunksByAgent = new Map<string, typeof chunks>();
for (const chunk of chunks) {
const existing = chunksByAgent.get(chunk.agentId);
if (existing) {
existing.push(chunk);
} else {
chunksByAgent.set(chunk.agentId, [chunk]);
}
}
// Build result rows // Build result rows
return filteredAgents.map(agent => { return filteredAgents.map(agent => {
const agentChunks = chunksByAgent.get(agent.id) ?? []; const agentMetrics = metricsMap.get(agent.id);
let questionsCount = 0; const questionsCount = agentMetrics?.questionsCount ?? 0;
let subagentsCount = 0; const subagentsCount = agentMetrics?.subagentsCount ?? 0;
let compactionsCount = 0; const compactionsCount = agentMetrics?.compactionsCount ?? 0;
for (const chunk of agentChunks) {
try {
const parsed = JSON.parse(chunk.content);
if (parsed.type === 'tool_use' && parsed.name === 'AskUserQuestion') {
questionsCount += parsed.input?.questions?.length ?? 0;
} else if (parsed.type === 'tool_use' && parsed.name === 'Agent') {
subagentsCount++;
} else if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.source === 'compact') {
compactionsCount++;
}
} catch { /* skip malformed */ }
}
return { return {
id: agent.id, id: agent.id,

View File

@@ -337,6 +337,14 @@ export function architectProcedures(publicProcedure: ProcedureBuilder) {
}); });
} }
// Clean up orphaned pending/in_progress detail tasks from previous failed attempts
const phaseTasks = await taskRepo.findByPhaseId(input.phaseId);
for (const t of phaseTasks) {
if (t.category === 'detail' && (t.status === 'pending' || t.status === 'in_progress')) {
await taskRepo.update(t.id, { status: 'completed', summary: 'Superseded by retry' });
}
}
const detailTaskName = input.taskName ?? `Detail: ${phase.name}`; const detailTaskName = input.taskName ?? `Detail: ${phase.name}`;
const task = await taskRepo.create({ const task = await taskRepo.create({
phaseId: phase.id, phaseId: phase.id,

View File

@@ -184,11 +184,11 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
.input(z.object({ .input(z.object({
projectId: z.string().optional(), projectId: z.string().optional(),
status: z.enum(ErrandStatusValues).optional(), status: z.enum(ErrandStatusValues).optional(),
})) }).optional())
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return requireErrandRepository(ctx).findAll({ return requireErrandRepository(ctx).findAll({
projectId: input.projectId, projectId: input?.projectId,
status: input.status, status: input?.status,
}); });
}), }),

View File

@@ -4,10 +4,35 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import type { Task } from '../../db/schema.js'; import type { Phase, Task } from '../../db/schema.js';
import type { ProcedureBuilder } from '../trpc.js'; import type { ProcedureBuilder } from '../trpc.js';
import { requirePhaseDispatchManager, requirePhaseRepository, requireTaskRepository } from './_helpers.js'; import { requirePhaseDispatchManager, requirePhaseRepository, requireTaskRepository } from './_helpers.js';
const INTEGRATION_PHASE_NAME = 'Integration';
const INTEGRATION_TASK_DESCRIPTION = `Verify that all phase branches integrate correctly after merging into the initiative branch.
Steps:
1. Build the project — fix any compilation errors
2. Run the full test suite — fix any failing tests
3. Run type checking and linting — fix any errors
4. Review cross-phase imports and shared interfaces for compatibility
5. Smoke test key user flows affected by the merged changes
Only fix integration issues (type mismatches, conflicting exports, broken tests). Do not refactor or improve existing code.`;
/**
* Find phase IDs that have no dependents (no other phase depends on them).
* These are the "end" / "leaf" phases in the dependency graph.
*/
function findEndPhaseIds(
phases: Phase[],
edges: Array<{ phaseId: string; dependsOnPhaseId: string }>,
): string[] {
const dependedOn = new Set(edges.map((e) => e.dependsOnPhaseId));
return phases.filter((p) => !dependedOn.has(p.id)).map((p) => p.id);
}
export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) { export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) {
return { return {
queuePhase: publicProcedure queuePhase: publicProcedure
@@ -23,7 +48,40 @@ export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) {
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const phaseDispatchManager = requirePhaseDispatchManager(ctx); const phaseDispatchManager = requirePhaseDispatchManager(ctx);
const phaseRepo = requirePhaseRepository(ctx); const phaseRepo = requirePhaseRepository(ctx);
const phases = await phaseRepo.findByInitiativeId(input.initiativeId); const taskRepo = requireTaskRepository(ctx);
let phases = await phaseRepo.findByInitiativeId(input.initiativeId);
const edges = await phaseRepo.findDependenciesByInitiativeId(input.initiativeId);
// Auto-create Integration phase if multiple end phases exist
const existingIntegration = phases.find((p) => p.name === INTEGRATION_PHASE_NAME);
if (!existingIntegration) {
const endPhaseIds = findEndPhaseIds(phases, edges);
if (endPhaseIds.length > 1) {
const integrationPhase = await phaseRepo.create({
initiativeId: input.initiativeId,
name: INTEGRATION_PHASE_NAME,
status: 'approved',
});
for (const endPhaseId of endPhaseIds) {
await phaseRepo.createDependency(integrationPhase.id, endPhaseId);
}
await taskRepo.create({
phaseId: integrationPhase.id,
initiativeId: input.initiativeId,
name: 'Verify integration',
description: INTEGRATION_TASK_DESCRIPTION,
category: 'verify',
status: 'pending',
});
// Re-fetch so the new phase gets queued in the loop below
phases = await phaseRepo.findByInitiativeId(input.initiativeId);
}
}
let queued = 0; let queued = 0;
for (const phase of phases) { for (const phase of phases) {
if (phase.status === 'approved') { if (phase.status === 'approved') {

View File

@@ -331,8 +331,8 @@ export function ErrandDetailPanel({ errandId, onClose }: ErrandDetailPanelProps)
{/* Info line */} {/* Info line */}
<div className="px-5 pt-4 text-sm text-muted-foreground"> <div className="px-5 pt-4 text-sm text-muted-foreground">
{errand.status === 'merged' {errand.status === 'merged'
? `Merged into ${errand.baseBranch} · ${formatRelativeTime(errand.updatedAt.toISOString())}` ? `Merged into ${errand.baseBranch} · ${formatRelativeTime(String(errand.updatedAt))}`
: `Abandoned · ${formatRelativeTime(errand.updatedAt.toISOString())}`} : `Abandoned · ${formatRelativeTime(String(errand.updatedAt))}`}
</div> </div>
{/* Read-only diff */} {/* Read-only diff */}

View File

@@ -22,6 +22,8 @@ interface RefineSpawnDialogProps {
showInstructionInput?: boolean; showInstructionInput?: boolean;
/** Placeholder text for the instruction textarea */ /** Placeholder text for the instruction textarea */
instructionPlaceholder?: string; instructionPlaceholder?: string;
/** Pre-populate the instruction field (e.g. from a crashed agent's original instruction) */
defaultInstruction?: string;
/** Whether the spawn mutation is pending */ /** Whether the spawn mutation is pending */
isSpawning: boolean; isSpawning: boolean;
/** Error message if spawn failed */ /** Error message if spawn failed */
@@ -38,6 +40,7 @@ export function RefineSpawnDialog({
description, description,
showInstructionInput = true, showInstructionInput = true,
instructionPlaceholder = "What should the agent focus on? (optional)", instructionPlaceholder = "What should the agent focus on? (optional)",
defaultInstruction,
isSpawning, isSpawning,
error, error,
onSpawn, onSpawn,
@@ -53,18 +56,25 @@ export function RefineSpawnDialog({
onSpawn(finalInstruction); onSpawn(finalInstruction);
}; };
const openDialog = () => {
setInstruction(defaultInstruction ?? "");
setShowDialog(true);
};
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
setShowDialog(open); if (open) {
if (!open) { setInstruction(defaultInstruction ?? "");
} else {
setInstruction(""); setInstruction("");
} }
setShowDialog(open);
}; };
const defaultTrigger = ( const defaultTrigger = (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setShowDialog(true)} onClick={openDialog}
className="gap-1.5" className="gap-1.5"
> >
<Sparkles className="h-3.5 w-3.5" /> <Sparkles className="h-3.5 w-3.5" />
@@ -75,7 +85,7 @@ export function RefineSpawnDialog({
return ( return (
<> <>
{trigger ? ( {trigger ? (
<div onClick={() => setShowDialog(true)}> <div onClick={openDialog}>
{trigger} {trigger}
</div> </div>
) : ( ) : (

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { Loader2, AlertCircle } from "lucide-react"; import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { QuestionForm } from "@/components/QuestionForm"; import { QuestionForm } from "@/components/QuestionForm";
@@ -6,6 +6,12 @@ import { ChangeSetBanner } from "@/components/ChangeSetBanner";
import { RefineSpawnDialog } from "../RefineSpawnDialog"; import { RefineSpawnDialog } from "../RefineSpawnDialog";
import { useRefineAgent } from "@/hooks"; import { useRefineAgent } from "@/hooks";
function extractInstruction(prompt: string | null | undefined): string | undefined {
if (!prompt) return undefined;
const match = prompt.match(/<user_instruction>\n([\s\S]*?)\n<\/user_instruction>/);
return match?.[1] || undefined;
}
interface RefineAgentPanelProps { interface RefineAgentPanelProps {
initiativeId: string; initiativeId: string;
} }
@@ -123,6 +129,11 @@ export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
} }
// Crashed // Crashed
const crashedInstruction = useMemo(
() => (state === "crashed" ? extractInstruction(agent?.prompt) : undefined),
[state, agent?.prompt],
);
if (state === "crashed") { if (state === "crashed") {
return ( return (
<div className="mb-3 rounded-lg border border-destructive/50 bg-destructive/5 px-3 py-2"> <div className="mb-3 rounded-lg border border-destructive/50 bg-destructive/5 px-3 py-2">
@@ -134,6 +145,7 @@ export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
title="Refine Initiative Content" title="Refine Initiative Content"
description="An agent will review all pages and suggest improvements." description="An agent will review all pages and suggest improvements."
instructionPlaceholder="What should the agent focus on? (optional)" instructionPlaceholder="What should the agent focus on? (optional)"
defaultInstruction={crashedInstruction}
isSpawning={spawn.isPending} isSpawning={spawn.isPending}
error={spawn.error?.message} error={spawn.error?.message}
onSpawn={handleSpawn} onSpawn={handleSpawn}

View File

@@ -1,65 +0,0 @@
import { useNavigate } from '@tanstack/react-router'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from '@/components/ui/tooltip'
import { formatRelativeTime } from '@/lib/utils'
import type { WaitingForInputItem } from './types'
interface Props {
items: WaitingForInputItem[]
}
export function HQWaitingForInputSection({ items }: Props) {
const navigate = useNavigate()
return (
<div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Waiting for Input
</h2>
<div className="space-y-2">
{items.map((item) => {
const truncated =
item.questionText.slice(0, 120) +
(item.questionText.length > 120 ? '…' : '')
return (
<Card key={item.agentId} className="p-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="font-bold text-sm">
{item.agentName}
{item.initiativeName && (
<span className="font-normal text-muted-foreground"> · {item.initiativeName}</span>
)}
</p>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-sm text-muted-foreground truncate">{truncated}</p>
</TooltipTrigger>
<TooltipContent forceMount>{item.questionText}</TooltipContent>
</Tooltip>
</TooltipProvider>
<p className="text-xs text-muted-foreground">
waiting {formatRelativeTime(item.waitingSince)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigate({ to: '/inbox' })}
>
Answer
</Button>
</Card>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
// @vitest-environment happy-dom
import { render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
import { AppLayout } from './AppLayout'
// Mock dependencies
vi.mock('@tanstack/react-router', () => ({
Link: ({ children, to }: any) => {
const content = typeof children === 'function' ? children({ isActive: false }) : children
return <a href={to}>{content}</a>
},
}))
vi.mock('@/components/ThemeToggle', () => ({ ThemeToggle: () => null }))
vi.mock('@/components/HealthDot', () => ({ HealthDot: () => null }))
vi.mock('@/components/NavBadge', () => ({
NavBadge: ({ count }: { count: number }) => (
count > 0 ? <span data-testid="nav-badge">{count}</span> : null
),
}))
const mockUseQuery = vi.hoisted(() => vi.fn())
vi.mock('@/lib/trpc', () => ({
trpc: {
listAgents: { useQuery: mockUseQuery },
},
}))
beforeEach(() => {
vi.clearAllMocks()
mockUseQuery.mockReturnValue({ data: [] })
})
describe('AppLayout navItems', () => {
it('renders HQ nav link', () => {
render(<AppLayout connectionState="connected">{null}</AppLayout>)
expect(screen.getByRole('link', { name: /hq/i })).toBeInTheDocument()
})
it('does not render Inbox nav link', () => {
render(<AppLayout connectionState="connected">{null}</AppLayout>)
expect(screen.queryByRole('link', { name: /inbox/i })).not.toBeInTheDocument()
})
it('shows badge on HQ when agents are waiting_for_input', () => {
mockUseQuery.mockReturnValue({
data: [
{ id: '1', status: 'waiting_for_input' },
{ id: '2', status: 'running' },
],
})
render(<AppLayout connectionState="connected">{null}</AppLayout>)
// NavBadge rendered next to HQ link (count=1)
const hqLink = screen.getByRole('link', { name: /hq/i })
const badge = within(hqLink).getByTestId('nav-badge')
expect(badge).toHaveTextContent('1')
})
it('does not show questions badge on any Inbox link (Inbox removed)', () => {
mockUseQuery.mockReturnValue({
data: [{ id: '1', status: 'waiting_for_input' }],
})
render(<AppLayout connectionState="connected">{null}</AppLayout>)
expect(screen.queryByRole('link', { name: /inbox/i })).not.toBeInTheDocument()
})
})

View File

@@ -7,11 +7,11 @@ import { trpc } from '@/lib/trpc'
import type { ConnectionState } from '@/hooks/useConnectionStatus' import type { ConnectionState } from '@/hooks/useConnectionStatus'
const navItems = [ const navItems = [
{ label: 'HQ', to: '/hq', badgeKey: null }, { label: 'HQ', to: '/hq', badgeKey: 'questions' as const },
{ label: 'Initiatives', to: '/initiatives', badgeKey: null }, { label: 'Initiatives', to: '/initiatives', badgeKey: null },
{ label: 'Agents', to: '/agents', badgeKey: 'running' as const }, { label: 'Agents', to: '/agents', badgeKey: 'running' as const },
{ label: 'Errands', to: '/errands', badgeKey: null },
{ label: 'Radar', to: '/radar', badgeKey: null }, { label: 'Radar', to: '/radar', badgeKey: null },
{ label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const },
{ label: 'Settings', to: '/settings', badgeKey: null }, { label: 'Settings', to: '/settings', badgeKey: null },
] as const ] as const

View File

@@ -17,6 +17,7 @@ import { Route as AgentsRouteImport } from './routes/agents'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as SettingsIndexRouteImport } from './routes/settings/index' import { Route as SettingsIndexRouteImport } from './routes/settings/index'
import { Route as InitiativesIndexRouteImport } from './routes/initiatives/index' import { Route as InitiativesIndexRouteImport } from './routes/initiatives/index'
import { Route as ErrandsIndexRouteImport } from './routes/errands/index'
import { Route as SettingsProjectsRouteImport } from './routes/settings/projects' import { Route as SettingsProjectsRouteImport } from './routes/settings/projects'
import { Route as SettingsHealthRouteImport } from './routes/settings/health' import { Route as SettingsHealthRouteImport } from './routes/settings/health'
import { Route as InitiativesIdRouteImport } from './routes/initiatives/$id' import { Route as InitiativesIdRouteImport } from './routes/initiatives/$id'
@@ -61,6 +62,11 @@ const InitiativesIndexRoute = InitiativesIndexRouteImport.update({
path: '/initiatives/', path: '/initiatives/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ErrandsIndexRoute = ErrandsIndexRouteImport.update({
id: '/errands/',
path: '/errands/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ const SettingsProjectsRoute = SettingsProjectsRouteImport.update({
id: '/projects', id: '/projects',
path: '/projects', path: '/projects',
@@ -87,6 +93,7 @@ export interface FileRoutesByFullPath {
'/initiatives/$id': typeof InitiativesIdRoute '/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute '/settings/health': typeof SettingsHealthRoute
'/settings/projects': typeof SettingsProjectsRoute '/settings/projects': typeof SettingsProjectsRoute
'/errands/': typeof ErrandsIndexRoute
'/initiatives/': typeof InitiativesIndexRoute '/initiatives/': typeof InitiativesIndexRoute
'/settings/': typeof SettingsIndexRoute '/settings/': typeof SettingsIndexRoute
} }
@@ -99,6 +106,7 @@ export interface FileRoutesByTo {
'/initiatives/$id': typeof InitiativesIdRoute '/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute '/settings/health': typeof SettingsHealthRoute
'/settings/projects': typeof SettingsProjectsRoute '/settings/projects': typeof SettingsProjectsRoute
'/errands': typeof ErrandsIndexRoute
'/initiatives': typeof InitiativesIndexRoute '/initiatives': typeof InitiativesIndexRoute
'/settings': typeof SettingsIndexRoute '/settings': typeof SettingsIndexRoute
} }
@@ -113,6 +121,7 @@ export interface FileRoutesById {
'/initiatives/$id': typeof InitiativesIdRoute '/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute '/settings/health': typeof SettingsHealthRoute
'/settings/projects': typeof SettingsProjectsRoute '/settings/projects': typeof SettingsProjectsRoute
'/errands/': typeof ErrandsIndexRoute
'/initiatives/': typeof InitiativesIndexRoute '/initiatives/': typeof InitiativesIndexRoute
'/settings/': typeof SettingsIndexRoute '/settings/': typeof SettingsIndexRoute
} }
@@ -128,6 +137,7 @@ export interface FileRouteTypes {
| '/initiatives/$id' | '/initiatives/$id'
| '/settings/health' | '/settings/health'
| '/settings/projects' | '/settings/projects'
| '/errands/'
| '/initiatives/' | '/initiatives/'
| '/settings/' | '/settings/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -140,6 +150,7 @@ export interface FileRouteTypes {
| '/initiatives/$id' | '/initiatives/$id'
| '/settings/health' | '/settings/health'
| '/settings/projects' | '/settings/projects'
| '/errands'
| '/initiatives' | '/initiatives'
| '/settings' | '/settings'
id: id:
@@ -153,6 +164,7 @@ export interface FileRouteTypes {
| '/initiatives/$id' | '/initiatives/$id'
| '/settings/health' | '/settings/health'
| '/settings/projects' | '/settings/projects'
| '/errands/'
| '/initiatives/' | '/initiatives/'
| '/settings/' | '/settings/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -165,6 +177,7 @@ export interface RootRouteChildren {
RadarRoute: typeof RadarRoute RadarRoute: typeof RadarRoute
SettingsRoute: typeof SettingsRouteWithChildren SettingsRoute: typeof SettingsRouteWithChildren
InitiativesIdRoute: typeof InitiativesIdRoute InitiativesIdRoute: typeof InitiativesIdRoute
ErrandsIndexRoute: typeof ErrandsIndexRoute
InitiativesIndexRoute: typeof InitiativesIndexRoute InitiativesIndexRoute: typeof InitiativesIndexRoute
} }
@@ -226,6 +239,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof InitiativesIndexRouteImport preLoaderRoute: typeof InitiativesIndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/errands/': {
id: '/errands/'
path: '/errands'
fullPath: '/errands/'
preLoaderRoute: typeof ErrandsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/projects': { '/settings/projects': {
id: '/settings/projects' id: '/settings/projects'
path: '/projects' path: '/projects'
@@ -274,6 +294,7 @@ const rootRouteChildren: RootRouteChildren = {
RadarRoute: RadarRoute, RadarRoute: RadarRoute,
SettingsRoute: SettingsRouteWithChildren, SettingsRoute: SettingsRouteWithChildren,
InitiativesIdRoute: InitiativesIdRoute, InitiativesIdRoute: InitiativesIdRoute,
ErrandsIndexRoute: ErrandsIndexRoute,
InitiativesIndexRoute: InitiativesIndexRoute, InitiativesIndexRoute: InitiativesIndexRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View File

@@ -103,7 +103,7 @@ function ErrandsPage() {
{e.agentAlias ?? '—'} {e.agentAlias ?? '—'}
</td> </td>
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap"> <td className="px-4 py-3 text-muted-foreground whitespace-nowrap">
{formatRelativeTime(e.createdAt.toISOString())} {formatRelativeTime(String(e.createdAt))}
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -4,9 +4,24 @@ import { render, screen, fireEvent } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest' import { vi, describe, it, expect, beforeEach } from 'vitest'
const mockUseQuery = vi.hoisted(() => vi.fn()) const mockUseQuery = vi.hoisted(() => vi.fn())
const mockListWaitingAgentsQuery = vi.hoisted(() => vi.fn())
const mockListMessagesQuery = vi.hoisted(() => vi.fn())
const mockGetAgentQuestionsQuery = vi.hoisted(() => vi.fn())
const mockResumeAgentMutation = vi.hoisted(() => vi.fn())
const mockStopAgentMutation = vi.hoisted(() => vi.fn())
const mockRespondToMessageMutation = vi.hoisted(() => vi.fn())
const mockUseUtils = vi.hoisted(() => vi.fn())
vi.mock('@/lib/trpc', () => ({ vi.mock('@/lib/trpc', () => ({
trpc: { trpc: {
getHeadquartersDashboard: { useQuery: mockUseQuery }, getHeadquartersDashboard: { useQuery: mockUseQuery },
listWaitingAgents: { useQuery: mockListWaitingAgentsQuery },
listMessages: { useQuery: mockListMessagesQuery },
getAgentQuestions: { useQuery: mockGetAgentQuestionsQuery },
resumeAgent: { useMutation: mockResumeAgentMutation },
stopAgent: { useMutation: mockStopAgentMutation },
respondToMessage: { useMutation: mockRespondToMessageMutation },
useUtils: mockUseUtils,
}, },
})) }))
@@ -15,8 +30,33 @@ vi.mock('@/hooks', () => ({
LiveUpdateRule: undefined, LiveUpdateRule: undefined,
})) }))
vi.mock('@/components/hq/HQWaitingForInputSection', () => ({ vi.mock('@/components/InboxList', () => ({
HQWaitingForInputSection: ({ items }: any) => <div data-testid="waiting">{items.length}</div>, InboxList: ({ agents, selectedAgentId, onSelectAgent }: any) => (
<div data-testid="inbox-list">
{agents.map((a: any) => (
<button
key={a.id}
data-testid={`agent-${a.id}`}
aria-selected={selectedAgentId === a.id}
onClick={() => onSelectAgent(a.id)}
>
{a.name}
</button>
))}
</div>
),
}))
vi.mock('@/components/InboxDetailPanel', () => ({
InboxDetailPanel: ({ agent, onSubmitAnswers, onDismissQuestions, onDismissMessage, onBack }: any) => (
<div data-testid="inbox-detail">
<span data-testid="selected-agent-name">{agent.name}</span>
<button onClick={() => onSubmitAnswers({ q1: 'answer' })}>Submit</button>
<button onClick={onDismissQuestions}>Dismiss Questions</button>
<button onClick={onDismissMessage}>Dismiss Message</button>
<button onClick={onBack}>Back</button>
</div>
),
})) }))
vi.mock('@/components/hq/HQNeedsReviewSection', () => ({ vi.mock('@/components/hq/HQNeedsReviewSection', () => ({
@@ -56,6 +96,16 @@ const emptyData = {
describe('HeadquartersPage', () => { describe('HeadquartersPage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockListWaitingAgentsQuery.mockReturnValue({ data: [], isLoading: false })
mockListMessagesQuery.mockReturnValue({ data: [], isLoading: false })
mockGetAgentQuestionsQuery.mockReturnValue({ data: null, isLoading: false, isError: false })
mockResumeAgentMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false })
mockStopAgentMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false })
mockRespondToMessageMutation.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false })
mockUseUtils.mockReturnValue({
listWaitingAgents: { invalidate: vi.fn() },
listMessages: { invalidate: vi.fn() },
})
}) })
it('renders skeleton loading state', () => { it('renders skeleton loading state', () => {
@@ -68,7 +118,7 @@ describe('HeadquartersPage', () => {
const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]') const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]')
expect(skeletons.length).toBeGreaterThan(0) expect(skeletons.length).toBeGreaterThan(0)
// No section components // No section components
expect(screen.queryByTestId('waiting')).not.toBeInTheDocument() expect(screen.queryByTestId('inbox-list')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
@@ -93,24 +143,25 @@ describe('HeadquartersPage', () => {
render(<HeadquartersPage />) render(<HeadquartersPage />)
expect(screen.getByTestId('empty-state')).toBeInTheDocument() expect(screen.getByTestId('empty-state')).toBeInTheDocument()
expect(screen.queryByTestId('waiting')).not.toBeInTheDocument() expect(screen.queryByTestId('inbox-list')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
}) })
it('renders WaitingForInput section when items exist', () => { it('renders InboxList when waitingForInput items exist', () => {
mockUseQuery.mockReturnValue({ mockUseQuery.mockReturnValue({
isLoading: false, isLoading: false,
isError: false, isError: false,
data: { ...emptyData, waitingForInput: [{ id: '1' }] }, data: { ...emptyData, waitingForInput: [{ id: '1' }] },
}) })
mockListWaitingAgentsQuery.mockReturnValue({
data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
isLoading: false,
})
render(<HeadquartersPage />) render(<HeadquartersPage />)
expect(screen.getByTestId('waiting')).toBeInTheDocument() expect(screen.getByTestId('inbox-list')).toBeInTheDocument()
expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument()
expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument()
expect(screen.queryByTestId('blocked')).not.toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
}) })
@@ -127,9 +178,13 @@ describe('HeadquartersPage', () => {
blockedPhases: [{ id: '6' }], blockedPhases: [{ id: '6' }],
}, },
}) })
mockListWaitingAgentsQuery.mockReturnValue({
data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
isLoading: false,
})
render(<HeadquartersPage />) render(<HeadquartersPage />)
expect(screen.getByTestId('waiting')).toBeInTheDocument() expect(screen.getByTestId('inbox-list')).toBeInTheDocument()
expect(screen.getByTestId('needs-review')).toBeInTheDocument() expect(screen.getByTestId('needs-review')).toBeInTheDocument()
expect(screen.getByTestId('needs-approval')).toBeInTheDocument() expect(screen.getByTestId('needs-approval')).toBeInTheDocument()
expect(screen.getByTestId('resolving-conflicts')).toBeInTheDocument() expect(screen.getByTestId('resolving-conflicts')).toBeInTheDocument()
@@ -160,4 +215,41 @@ describe('HeadquartersPage', () => {
expect(screen.getByTestId('needs-review')).toBeInTheDocument() expect(screen.getByTestId('needs-review')).toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
}) })
it('shows InboxDetailPanel when an agent is selected from InboxList', async () => {
mockUseQuery.mockReturnValue({
isLoading: false,
isError: false,
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
})
mockListWaitingAgentsQuery.mockReturnValue({
data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
isLoading: false,
})
render(<HeadquartersPage />)
fireEvent.click(screen.getByTestId('agent-a1'))
expect(await screen.findByTestId('inbox-detail')).toBeInTheDocument()
expect(screen.getByTestId('selected-agent-name')).toHaveTextContent('Agent 1')
})
it('clears selection when Back is clicked in InboxDetailPanel', async () => {
mockUseQuery.mockReturnValue({
isLoading: false,
isError: false,
data: { ...emptyData, waitingForInput: [{ id: '1' }] },
})
mockListWaitingAgentsQuery.mockReturnValue({
data: [{ id: 'a1', name: 'Agent 1', status: 'waiting_for_input', taskId: null, updatedAt: new Date() }],
isLoading: false,
})
render(<HeadquartersPage />)
fireEvent.click(screen.getByTestId('agent-a1'))
expect(screen.getByTestId('inbox-detail')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /back/i }))
expect(screen.queryByTestId('inbox-detail')).not.toBeInTheDocument()
})
}) })

View File

@@ -1,10 +1,13 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { useLiveUpdates, type LiveUpdateRule } from "@/hooks"; import { useLiveUpdates, type LiveUpdateRule } from "@/hooks";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection"; import { InboxList } from "@/components/InboxList";
import { InboxDetailPanel } from "@/components/InboxDetailPanel";
import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection"; import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection";
import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection"; import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection";
import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection"; import { HQResolvingConflictsSection } from "@/components/hq/HQResolvingConflictsSection";
@@ -19,12 +22,108 @@ const HQ_LIVE_UPDATE_RULES: LiveUpdateRule[] = [
{ prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] }, { prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] },
{ prefix: "phase:", invalidate: ["getHeadquartersDashboard"] }, { prefix: "phase:", invalidate: ["getHeadquartersDashboard"] },
{ prefix: "agent:", invalidate: ["getHeadquartersDashboard"] }, { prefix: "agent:", invalidate: ["getHeadquartersDashboard"] },
{ prefix: "agent:", invalidate: ["listWaitingAgents", "listMessages"] },
]; ];
export function HeadquartersPage() { export function HeadquartersPage() {
useLiveUpdates(HQ_LIVE_UPDATE_RULES); useLiveUpdates(HQ_LIVE_UPDATE_RULES);
const query = trpc.getHeadquartersDashboard.useQuery(); const query = trpc.getHeadquartersDashboard.useQuery();
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const utils = trpc.useUtils();
const agentsQuery = trpc.listWaitingAgents.useQuery();
const messagesQuery = trpc.listMessages.useQuery({});
const questionsQuery = trpc.getAgentQuestions.useQuery(
{ id: selectedAgentId! },
{ enabled: !!selectedAgentId }
);
const resumeAgentMutation = trpc.resumeAgent.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Answer submitted");
},
onError: () => {
toast.error("Failed to submit answer");
},
});
const dismissQuestionsMutation = trpc.stopAgent.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Questions dismissed");
},
onError: () => {
toast.error("Failed to dismiss questions");
},
});
const respondToMessageMutation = trpc.respondToMessage.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Response sent");
},
onError: () => {
toast.error("Failed to send response");
},
});
const agents = agentsQuery.data ?? [];
const messages = messagesQuery.data ?? [];
const selectedAgent = selectedAgentId
? agents.find((a) => a.id === selectedAgentId) ?? null
: null;
const selectedMessage = selectedAgentId
? messages
.filter((m) => m.senderId === selectedAgentId)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)[0] ?? null
: null;
const pendingQuestions = questionsQuery.data ?? null;
function handleRefresh() {
void utils.listWaitingAgents.invalidate();
void utils.listMessages.invalidate();
}
function handleSubmitAnswers(answers: Record<string, string>) {
if (!selectedAgentId) return;
resumeAgentMutation.mutate({ id: selectedAgentId, answers });
}
function handleDismissQuestions() {
if (!selectedAgentId) return;
dismissQuestionsMutation.mutate({ id: selectedAgentId });
}
function handleDismiss() {
if (!selectedMessage) return;
respondToMessageMutation.mutate({
id: selectedMessage.id,
response: "Acknowledged",
});
}
const serializedAgents = agents.map((a) => ({
id: a.id,
name: a.name,
status: a.status,
taskId: a.taskId ?? "",
updatedAt: String(a.updatedAt),
}));
const serializedMessages = messages.map((m) => ({
id: m.id,
senderId: m.senderId,
content: m.content,
requiresResponse: m.requiresResponse,
status: m.status,
createdAt: String(m.createdAt),
}));
if (query.isLoading) { if (query.isLoading) {
return ( return (
<motion.div <motion.div
@@ -97,7 +196,80 @@ export function HeadquartersPage() {
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
{data.waitingForInput.length > 0 && ( {data.waitingForInput.length > 0 && (
<HQWaitingForInputSection items={data.waitingForInput} /> <div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Waiting for Input
</h2>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
{/* Left: agent list — hidden on mobile when an agent is selected */}
<div className={selectedAgent ? "hidden lg:block" : undefined}>
<InboxList
agents={serializedAgents}
messages={serializedMessages}
selectedAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
onRefresh={handleRefresh}
/>
</div>
{/* Right: detail panel */}
{selectedAgent ? (
<InboxDetailPanel
agent={{
id: selectedAgent.id,
name: selectedAgent.name,
status: selectedAgent.status,
taskId: selectedAgent.taskId ?? null,
updatedAt: String(selectedAgent.updatedAt),
}}
message={
selectedMessage
? {
id: selectedMessage.id,
content: selectedMessage.content,
requiresResponse: selectedMessage.requiresResponse,
}
: null
}
questions={
pendingQuestions
? pendingQuestions.questions.map((q) => ({
id: q.id,
question: q.question,
options: q.options,
multiSelect: q.multiSelect,
}))
: null
}
isLoadingQuestions={questionsQuery.isLoading}
questionsError={
questionsQuery.isError ? questionsQuery.error.message : null
}
onBack={() => setSelectedAgentId(null)}
onSubmitAnswers={handleSubmitAnswers}
onDismissQuestions={handleDismissQuestions}
onDismissMessage={handleDismiss}
isSubmitting={resumeAgentMutation.isPending}
isDismissingQuestions={dismissQuestionsMutation.isPending}
isDismissingMessage={respondToMessageMutation.isPending}
submitError={
resumeAgentMutation.isError
? resumeAgentMutation.error.message
: null
}
dismissMessageError={
respondToMessageMutation.isError
? respondToMessageMutation.error.message
: null
}
/>
) : (
<div className="hidden flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border p-8 lg:flex">
<p className="text-sm font-medium text-muted-foreground">No agent selected</p>
<p className="text-xs text-muted-foreground/70">Select an agent from the list to answer their questions</p>
</div>
)}
</div>
</div>
)} )}
{(data.pendingReviewInitiatives.length > 0 || {(data.pendingReviewInitiatives.length > 0 ||
data.pendingReviewPhases.length > 0) && ( data.pendingReviewPhases.length > 0) && (

View File

@@ -1,269 +1,7 @@
import { useState } from "react"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle, MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/Skeleton";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { InboxList } from "@/components/InboxList";
import { InboxDetailPanel } from "@/components/InboxDetailPanel";
import { useLiveUpdates } from "@/hooks";
export const Route = createFileRoute("/inbox")({ export const Route = createFileRoute("/inbox")({
component: InboxPage, beforeLoad: () => {
}); throw redirect({ to: "/hq" });
function InboxPage() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
// Single SSE stream for live updates
useLiveUpdates([
{ prefix: 'agent:', invalidate: ['listWaitingAgents', 'listMessages'] },
]);
const utils = trpc.useUtils();
// Data fetching
const agentsQuery = trpc.listWaitingAgents.useQuery();
const messagesQuery = trpc.listMessages.useQuery({});
const questionsQuery = trpc.getAgentQuestions.useQuery(
{ id: selectedAgentId! },
{ enabled: !!selectedAgentId }
);
// Mutations
const resumeAgentMutation = trpc.resumeAgent.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Answer submitted");
},
onError: () => {
toast.error("Failed to submit answer");
}, },
}); });
const dismissQuestionsMutation = trpc.stopAgent.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Questions dismissed");
},
onError: () => {
toast.error("Failed to dismiss questions");
},
});
const respondToMessageMutation = trpc.respondToMessage.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Response sent");
},
onError: () => {
toast.error("Failed to send response");
},
});
// Find selected agent info
const agents = agentsQuery.data ?? [];
const messages = messagesQuery.data ?? [];
const selectedAgent = selectedAgentId
? agents.find((a) => a.id === selectedAgentId) ?? null
: null;
// Find the latest message for the selected agent
const selectedMessage = selectedAgentId
? messages
.filter((m) => m.senderId === selectedAgentId)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)[0] ?? null
: null;
const pendingQuestions = questionsQuery.data ?? null;
// Handlers
function handleRefresh() {
void utils.listWaitingAgents.invalidate();
void utils.listMessages.invalidate();
}
function handleSubmitAnswers(answers: Record<string, string>) {
if (!selectedAgentId) return;
resumeAgentMutation.mutate({ id: selectedAgentId, answers });
}
function handleDismissQuestions() {
if (!selectedAgentId) return;
dismissQuestionsMutation.mutate({ id: selectedAgentId });
}
function handleDismiss() {
if (!selectedMessage) return;
respondToMessageMutation.mutate({
id: selectedMessage.id,
response: "Acknowledged",
});
}
// Loading state
if (agentsQuery.isLoading && messagesQuery.isLoading) {
return (
<div className="space-y-4">
{/* Skeleton header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-28" />
<Skeleton className="h-5 w-8 rounded-full" />
</div>
<Skeleton className="h-8 w-20" />
</div>
{/* Skeleton message rows */}
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="mt-2 ml-5 h-3 w-full" />
</div>
<Skeleton className="h-3 w-16" />
</div>
</Card>
))}
</div>
</div>
);
}
// Error state
if (agentsQuery.isError || messagesQuery.isError) {
const errorMessage =
agentsQuery.error?.message ?? messagesQuery.error?.message ?? "Unknown error";
return (
<div className="flex flex-col items-center justify-center gap-4 py-12">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">
Failed to load inbox: {errorMessage}
</p>
<Button variant="outline" size="sm" onClick={handleRefresh}>
Retry
</Button>
</div>
);
}
// Serialize agents for InboxList (convert Date to string for wire format)
const serializedAgents = agents.map((a) => ({
id: a.id,
name: a.name,
status: a.status,
taskId: a.taskId,
updatedAt: String(a.updatedAt),
}));
// Serialize messages for InboxList
const serializedMessages = messages.map((m) => ({
id: m.id,
senderId: m.senderId,
content: m.content,
requiresResponse: m.requiresResponse,
status: m.status,
createdAt: String(m.createdAt),
}));
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
className="space-y-6"
>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
{/* Left: Inbox List -- hidden on mobile when agent selected */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05, ease: [0, 0, 0.2, 1] }}
className={selectedAgent ? "hidden lg:block" : undefined}
>
<InboxList
agents={serializedAgents}
messages={serializedMessages}
selectedAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
onRefresh={handleRefresh}
/>
</motion.div>
{/* Right: Detail Panel */}
{selectedAgent && (
<InboxDetailPanel
agent={{
id: selectedAgent.id,
name: selectedAgent.name,
status: selectedAgent.status,
taskId: selectedAgent.taskId,
updatedAt: String(selectedAgent.updatedAt),
}}
message={
selectedMessage
? {
id: selectedMessage.id,
content: selectedMessage.content,
requiresResponse: selectedMessage.requiresResponse,
}
: null
}
questions={
pendingQuestions
? pendingQuestions.questions.map((q) => ({
id: q.id,
question: q.question,
options: q.options,
multiSelect: q.multiSelect,
}))
: null
}
isLoadingQuestions={questionsQuery.isLoading}
questionsError={
questionsQuery.isError ? questionsQuery.error.message : null
}
onBack={() => setSelectedAgentId(null)}
onSubmitAnswers={handleSubmitAnswers}
onDismissQuestions={handleDismissQuestions}
onDismissMessage={handleDismiss}
isSubmitting={resumeAgentMutation.isPending}
isDismissingQuestions={dismissQuestionsMutation.isPending}
isDismissingMessage={respondToMessageMutation.isPending}
submitError={
resumeAgentMutation.isError
? resumeAgentMutation.error.message
: null
}
dismissMessageError={
respondToMessageMutation.isError
? respondToMessageMutation.error.message
: null
}
/>
)}
{/* Empty detail panel placeholder */}
{!selectedAgent && (
<div className="hidden flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border p-8 lg:flex">
<MessageSquare className="h-10 w-10 text-muted-foreground/30" />
<div className="space-y-1 text-center">
<p className="text-sm font-medium text-muted-foreground">No message selected</p>
<p className="text-xs text-muted-foreground/70">Select an agent from the inbox to view details</p>
</div>
</div>
)}
</div>
</motion.div>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -55,3 +55,27 @@ Migrations 00000007 were generated by `drizzle-kit generate`. Migrations 0008
- **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.
- **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. - **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. - **Test with `npm test`** after generating migrations to verify they work with in-memory databases.
## Post-migration backfill scripts
Some schema additions require a one-time data backfill because SQLite migrations cannot execute Node.js logic (e.g., JSON parsing). In these cases, the migration creates the table structure, and a separate Node.js script populates it from existing data.
### agent_metrics backfill
**When to run:** After deploying the migration that creates the `agent_metrics` table (introduced in the Radar Screen Performance initiative). Run this once per production database after upgrading.
**Command:**
```sh
cw backfill-metrics
# Or with a custom DB path:
cw backfill-metrics --db /path/to/codewalkers.db
```
**What it does:**
- Reads all existing `agent_log_chunks` rows in batches of 500 (ordered by `createdAt ASC`)
- Parses each chunk's `content` JSON to count `AskUserQuestion` tool calls, `Agent` spawns, and compaction events
- Upserts the accumulated counts into `agent_metrics` using additive conflict resolution
**Idempotency:** The script uses `ON CONFLICT DO UPDATE` with additive increments, matching the ongoing write-path behavior. Running it against an empty `agent_metrics` table is fully safe. Running it a second time will double-count — only run it once per database, immediately after applying the migration.
**Batch size:** 500 rows per query, to avoid loading the full `agent_log_chunks` table into memory. Progress is logged every 1,000 chunks.

View File

@@ -93,6 +93,18 @@ InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId }
4. **Auto-queue tasks** — When phase starts (branches confirmed), pending execution tasks are queued (planning-category tasks excluded) 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` 5. **Events**`phase:queued`, `phase:started`, `phase:completed`, `phase:blocked`
### Auto-Integration Phase
When `queueAllPhases` is called (i.e. the user clicks "Execute"), it auto-creates an **Integration** phase if the initiative has multiple end phases (leaf nodes with no dependents). This catches cross-phase incompatibilities before the initiative reaches review.
- **Trigger**: `queueAllPhases` in `apps/server/trpc/routers/phase-dispatch.ts`
- **Guard**: Only created when `endPhaseIds.length > 1` and no existing "Integration" phase
- **Status**: Created as `approved` (same pattern as Finalization in orchestrator.ts)
- **Dependencies**: Integration depends on all end phases — dispatched last
- **Task**: A single `verify` category task instructs the agent to build, run tests, check types, and review cross-phase imports
- **Idempotency**: Name-based check prevents duplicates on re-execution
- **Coexistence**: Independent of the Finalization phase (different purpose, different trigger)
### PhaseDispatchManager Methods ### PhaseDispatchManager Methods
| Method | Purpose | | Method | Purpose |
@@ -137,8 +149,11 @@ When an agent crashes (`agent:crashed` event), the orchestrator automatically re
On server restart, `recoverDispatchQueues()` also recovers: On server restart, `recoverDispatchQueues()` also recovers:
- Stuck `in_progress` tasks whose agents are dead (status is not `running` or `waiting_for_input`) — reset to `pending` and re-queued - Stuck `in_progress` tasks whose agents are dead (status is not `running` or `waiting_for_input`) — reset to `pending` and re-queued
- Erroneously `blocked` tasks whose agents completed successfully (status is `idle` or `stopped`) — marked `completed` so the phase can progress. This handles the legacy case where conflict resolution incorrectly blocked already-completed tasks. - Erroneously `blocked` tasks whose agents completed successfully (status is `idle` or `stopped`) — marked `completed` so the phase can progress. This handles the legacy case where conflict resolution incorrectly blocked already-completed tasks.
- Stale duplicate planning tasks — if a phase has both a completed and a pending task of the same planning category (e.g. two `detail` tasks from a crash-and-retry), the pending one is marked `completed` with summary "Superseded by retry"
- Fully-completed `in_progress` phases — after task recovery, if all tasks in an `in_progress` phase are completed, triggers `handlePhaseAllTasksDone` to complete/review the phase - Fully-completed `in_progress` phases — after task recovery, if all tasks in an `in_progress` phase are completed, triggers `handlePhaseAllTasksDone` to complete/review the phase
The `detailPhase` mutation in `architect.ts` also cleans up orphaned pending/in_progress detail tasks before creating new ones, preventing duplicates at the source.
Manual retry via `retryBlockedTask()` resets `retryCount` to 0, giving the task a fresh set of automatic retries. Manual retry via `retryBlockedTask()` resets `retryCount` to 0, giving the task a fresh set of automatic retries.
### Coalesced Scheduling ### Coalesced Scheduling