mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-09 05:07:53 +00:00
fix: bound metadata preview counts without placeholder allocation
This commit is contained in:
parent
19886ac952
commit
b733d70ac8
9 changed files with 162 additions and 23 deletions
|
|
@ -2405,7 +2405,7 @@ Mounted at `/api/agents`.
|
|||
POST /api/agents/route
|
||||
```
|
||||
|
||||
Accepts either a task ID or ad-hoc metadata:
|
||||
Accepts either a task ID or ad-hoc metadata: Metadata `subtaskCount` must be an integer from 0 through 500; it is evaluated as a count without creating task records.
|
||||
|
||||
**By task ID**:
|
||||
|
||||
|
|
@ -3900,6 +3900,8 @@ Mounted at `/api/cost-prediction`.
|
|||
POST /api/cost-prediction/predict
|
||||
```
|
||||
|
||||
Metadata `subtaskCount` accepts integers from 0 through 500. Existing tasks are evaluated using their stored subtask count.
|
||||
|
||||
**By task ID**:
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -462,7 +462,7 @@ describe('AgentRoutingService', () => {
|
|||
id: 'high-code',
|
||||
name: 'High-priority code owner',
|
||||
enabled: true,
|
||||
match: { type: 'code', priority: 'high' },
|
||||
match: { type: 'code', priority: 'high', minSubtasks: 5 },
|
||||
memberId: 'ops-lead',
|
||||
},
|
||||
],
|
||||
|
|
@ -472,6 +472,7 @@ describe('AgentRoutingService', () => {
|
|||
const result = await service.resolveAgentWithTrace({
|
||||
type: 'code',
|
||||
priority: 'high',
|
||||
subtaskCount: 5,
|
||||
});
|
||||
|
||||
expect(result.result.agent).toBe('amp');
|
||||
|
|
@ -695,6 +696,36 @@ describe('AgentRoutingService', () => {
|
|||
expect(result.rule).toBe('complex');
|
||||
});
|
||||
|
||||
it('routes scalar counts like real subtask collections', async () => {
|
||||
const config = structuredClone(BASE_CONFIG);
|
||||
requireRouting(config).rules = [
|
||||
{
|
||||
id: 'complex',
|
||||
name: 'Complex tasks',
|
||||
match: { minSubtasks: 5 },
|
||||
agent: 'amp',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
mockGetConfig.mockResolvedValue(config);
|
||||
expect(
|
||||
(await service.resolveAgent({ type: 'feature', priority: 'medium', subtaskCount: 5 })).rule
|
||||
).toBe('complex');
|
||||
expect(
|
||||
(await service.resolveAgent({ type: 'feature', priority: 'medium', subtaskCount: 4 })).rule
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
await service.resolveAgent({
|
||||
type: 'feature',
|
||||
priority: 'medium',
|
||||
subtasks: [],
|
||||
subtaskCount: 5,
|
||||
})
|
||||
).rule
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT match when subtasks below threshold', async () => {
|
||||
const config = structuredClone(BASE_CONFIG);
|
||||
requireRouting(config).rules = [
|
||||
|
|
|
|||
23
server/src/__tests__/cost-prediction-count.test.ts
Normal file
23
server/src/__tests__/cost-prediction-count.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
vi.mock('../services/telemetry-service.js', () => ({
|
||||
getTelemetryService: () => ({ getEvents: async () => [] }),
|
||||
}));
|
||||
vi.mock('../services/task-service.js', () => ({ getTaskService: () => ({}) }));
|
||||
import { getCostPredictionService } from '../services/cost-prediction-service.js';
|
||||
|
||||
describe('cost prediction subtask counts', () => {
|
||||
it.each([0, 1, 2, 3, 5, 6, 500])('preserves prediction factors for count %i', async (count) => {
|
||||
const service = getCostPredictionService();
|
||||
const scalar = await service.predict({ subtaskCount: count });
|
||||
const stored = await service.predict({ subtasks: Array.from({ length: count }) });
|
||||
expect(scalar.factors).toEqual(stored.factors);
|
||||
expect(scalar.estimatedCost).toBe(stored.estimatedCost);
|
||||
});
|
||||
|
||||
it('uses actual task subtasks when both representations are present', async () => {
|
||||
const service = getCostPredictionService();
|
||||
const actual = await service.predict({ subtasks: [], subtaskCount: 500 });
|
||||
const empty = await service.predict({ subtasks: [] });
|
||||
expect(actual.factors).toEqual(empty.factors);
|
||||
});
|
||||
});
|
||||
|
|
@ -383,7 +383,7 @@ describe('admin-only governance routes', () => {
|
|||
type: 'feature',
|
||||
priority: 'medium',
|
||||
project: undefined,
|
||||
subtasks: undefined,
|
||||
subtaskCount: undefined,
|
||||
},
|
||||
{ requiredRuntimeCapabilities: undefined }
|
||||
);
|
||||
|
|
|
|||
90
server/src/__tests__/routes/metadata-preview-bounds.test.ts
Normal file
90
server/src/__tests__/routes/metadata-preview-bounds.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthenticatedRequest } from '../../middleware/auth.js';
|
||||
import { authorizeWrite } from '../../middleware/auth.js';
|
||||
import { agentRoutingAccess, costPredictionAccess } from '../../routes/v1/permissions.js';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
route: vi.fn(),
|
||||
predict: vi.fn(),
|
||||
record: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../services/agent-routing-service.js', () => ({
|
||||
getAgentRoutingService: () => ({ resolveAgentWithTrace: mocks.route }),
|
||||
}));
|
||||
vi.mock('../../services/cost-prediction-service.js', () => ({
|
||||
getCostPredictionService: () => ({ predict: mocks.predict }),
|
||||
}));
|
||||
vi.mock('../../services/governance-trace-service.js', () => ({
|
||||
getGovernanceTraceService: () => ({ record: mocks.record }),
|
||||
}));
|
||||
vi.mock('../../services/task-service.js', () => ({
|
||||
getTaskService: () => ({ getTask: mocks.getTask }),
|
||||
}));
|
||||
import { agentRoutingRoutes } from '../../routes/agent-routing.js';
|
||||
import { costPredictionRoutes } from '../../routes/cost-prediction.js';
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as AuthenticatedRequest).auth = {
|
||||
role: 'agent',
|
||||
isLocalhost: false,
|
||||
permissions: ['agent:read', 'task:write'],
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use(authorizeWrite);
|
||||
app.use(['/api/agents', '/api/v1/agents'], agentRoutingAccess, agentRoutingRoutes);
|
||||
app.use(
|
||||
['/api/cost-prediction', '/api/v1/cost-prediction'],
|
||||
costPredictionAccess,
|
||||
costPredictionRoutes
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('bounded metadata previews', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.route.mockResolvedValue({ result: { agent: 'fixture' }, trace: {} });
|
||||
mocks.predict.mockResolvedValue({ estimatedCost: 1 });
|
||||
mocks.record.mockResolvedValue({ id: 'trace_fixture' });
|
||||
});
|
||||
|
||||
for (const prefix of ['/api', '/api/v1']) {
|
||||
for (const endpoint of ['/agents/route', '/cost-prediction/predict']) {
|
||||
it(`rejects invalid counts before evaluation at ${prefix}${endpoint}`, async () => {
|
||||
const app = createApp();
|
||||
// 501 proves the bound without risking a large allocation on a regressed build.
|
||||
for (const subtaskCount of [-1, 0.5, 501, '5', null]) {
|
||||
const response = await request(app)
|
||||
.post(prefix + endpoint)
|
||||
.send({ subtaskCount });
|
||||
expect(response.status).toBe(400);
|
||||
expect(mocks.route).not.toHaveBeenCalled();
|
||||
expect(mocks.predict).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it(`passes valid counts without materializing subtasks at ${prefix}${endpoint}`, async () => {
|
||||
const app = createApp();
|
||||
for (const subtaskCount of [0, 1, 500]) {
|
||||
await request(app)
|
||||
.post(prefix + endpoint)
|
||||
.send({ subtaskCount })
|
||||
.expect(200);
|
||||
const call =
|
||||
endpoint === '/agents/route' ? mocks.route.mock.lastCall : mocks.predict.mock.lastCall;
|
||||
expect(call?.[0].subtaskCount).toBe(subtaskCount);
|
||||
expect(call?.[0]).not.toHaveProperty('subtasks');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -41,7 +41,7 @@ const routeByMetadataSchema = z
|
|||
type: z.string().optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
subtaskCount: z.number().int().nonnegative().optional(),
|
||||
subtaskCount: z.number().int().min(0).max(500).optional(),
|
||||
requiredRuntimeCapabilities: requiredRuntimeCapabilitiesSchema,
|
||||
})
|
||||
.strict();
|
||||
|
|
@ -129,14 +129,7 @@ router.post(
|
|||
type: type || 'feature',
|
||||
priority: priority || 'medium',
|
||||
project,
|
||||
subtasks: subtaskCount
|
||||
? Array.from({ length: subtaskCount }, (_, i) => ({
|
||||
id: `stub_${i}`,
|
||||
title: '',
|
||||
completed: false,
|
||||
created: new Date().toISOString(),
|
||||
}))
|
||||
: undefined,
|
||||
subtaskCount,
|
||||
},
|
||||
{ requiredRuntimeCapabilities }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const predictByMetadataSchema = z.object({
|
|||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
subtaskCount: z.number().int().nonnegative().optional(),
|
||||
subtaskCount: z.number().int().min(0).max(500).optional(),
|
||||
});
|
||||
|
||||
// ─── Routes ──────────────────────────────────────────────────────
|
||||
|
|
@ -77,7 +77,7 @@ router.post(
|
|||
priority,
|
||||
project,
|
||||
description,
|
||||
subtasks: subtaskCount ? Array.from({ length: subtaskCount }) : undefined,
|
||||
subtaskCount,
|
||||
});
|
||||
return res.json(prediction);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ import { selectProviderRuntimeManifest } from './provider-runtime-capability-ser
|
|||
|
||||
const log = createLogger('agent-routing');
|
||||
|
||||
type RoutableTask = Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>;
|
||||
type RoutableTask = Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'> & {
|
||||
subtaskCount?: number;
|
||||
};
|
||||
|
||||
interface RoutingTraceContext {
|
||||
taskId?: string;
|
||||
|
|
@ -114,7 +116,7 @@ export class AgentRoutingService {
|
|||
type: task.type,
|
||||
priority: task.priority,
|
||||
project: task.project,
|
||||
subtaskCount: task.subtasks?.length,
|
||||
subtaskCount: task.subtasks?.length ?? task.subtaskCount,
|
||||
},
|
||||
config.teamRoster
|
||||
);
|
||||
|
|
@ -433,7 +435,7 @@ export class AgentRoutingService {
|
|||
* Used when an agent fails and `fallbackOnFailure` is enabled.
|
||||
*/
|
||||
async getFallback(
|
||||
task: Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>,
|
||||
task: RoutableTask,
|
||||
failedAgent: AgentType,
|
||||
context: FallbackRoutingContext = {}
|
||||
): Promise<RoutingResult | null> {
|
||||
|
|
@ -636,10 +638,7 @@ export class AgentRoutingService {
|
|||
* All specified criteria must match (AND logic).
|
||||
* Unspecified criteria are ignored (wildcard).
|
||||
*/
|
||||
private matchesRule(
|
||||
task: Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>,
|
||||
match: RoutingMatchCriteria
|
||||
): boolean {
|
||||
private matchesRule(task: RoutableTask, match: RoutingMatchCriteria): boolean {
|
||||
// Type check
|
||||
if (match.type !== undefined) {
|
||||
if (!this.matchesValue(task.type, match.type)) return false;
|
||||
|
|
@ -658,7 +657,7 @@ export class AgentRoutingService {
|
|||
|
||||
// Complexity (subtask count)
|
||||
if (match.minSubtasks !== undefined) {
|
||||
const subtaskCount = task.subtasks?.length ?? 0;
|
||||
const subtaskCount = task.subtasks?.length ?? task.subtaskCount ?? 0;
|
||||
if (subtaskCount < match.minSubtasks) return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ class CostPredictionService {
|
|||
project?: string;
|
||||
description?: string;
|
||||
subtasks?: Array<unknown>;
|
||||
subtaskCount?: number;
|
||||
}): Promise<CostPrediction> {
|
||||
// 1. Get historical base cost from telemetry
|
||||
const historicalBase = await this.getHistoricalBaseCost(task.type, task.project);
|
||||
|
|
@ -138,7 +139,7 @@ class CostPredictionService {
|
|||
|
||||
// 4. Estimate complexity from description length + subtask count
|
||||
const descLength = (task.description || '').length;
|
||||
const subtaskCount = task.subtasks?.length || 0;
|
||||
const subtaskCount = task.subtasks?.length ?? task.subtaskCount ?? 0;
|
||||
let complexityMultiplier: number;
|
||||
if (descLength < COMPLEXITY_THRESHOLDS.simple && subtaskCount === 0) {
|
||||
complexityMultiplier = COMPLEXITY_MULTIPLIERS.simple;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue