feat(fleet): per-job engine picker (devin/claude/codex/copilot, default devin)
Add an optional concrete `engine` to a job (overrides engineClass; resolved by the runner's resolve_engine where an explicit engine wins). All additive + optional, so existing engineless jobs keep falling back to the factory default. - types: FLEET_ENGINES enum; engine on SubmitJob/FleetJobDoc/UpdateDraft. - coordinator: store engine on create/supersede/updateDraft; run.engine at claim prefers job.engine, then engineClass, then 'unknown'. - tracker-web: Engine dropdown on the New-Job form (default devin) + editable on draft/queued jobs; shown in the detail config grid. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
928edad0af
commit
6c31577cf2
@ -21,7 +21,9 @@ import {
|
||||
requestReview,
|
||||
submitReview,
|
||||
subscribeJobEvents,
|
||||
FLEET_ENGINES,
|
||||
type OperatorAction,
|
||||
type FleetEngine,
|
||||
type FleetJob,
|
||||
type FleetRun,
|
||||
type FleetEvent,
|
||||
@ -638,6 +640,7 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
|
||||
const editable = job.stage === 'draft' || job.stage === 'queued' || job.stage === 'blocked';
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftBody, setDraftBody] = useState(job.bodyMd ?? '');
|
||||
const [editEngine, setEditEngine] = useState<FleetEngine>(job.engine ?? 'devin');
|
||||
const [editRepo, setEditRepo] = useState(job.repo ?? '');
|
||||
const [editBranch, setEditBranch] = useState(job.baseBranch ?? '');
|
||||
const [editVerify, setEditVerify] = useState(job.verify ?? '');
|
||||
@ -647,6 +650,7 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
|
||||
|
||||
const beginEdit = () => {
|
||||
setDraftBody(job.bodyMd ?? '');
|
||||
setEditEngine(job.engine ?? 'devin');
|
||||
setEditRepo(job.repo ?? '');
|
||||
setEditBranch(job.baseBranch ?? '');
|
||||
setEditVerify(job.verify ?? '');
|
||||
@ -666,6 +670,7 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
|
||||
const repo = editRepo.trim();
|
||||
await updateDraft(job.id, {
|
||||
bodyMd: draftBody.trim(),
|
||||
engine: editEngine,
|
||||
...(repo
|
||||
? {
|
||||
repo,
|
||||
@ -698,6 +703,7 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
|
||||
};
|
||||
|
||||
const cfg: Array<[string, string | undefined]> = [
|
||||
['Engine', job.engine],
|
||||
['Repo', job.repo],
|
||||
['Base branch', job.repo ? (job.baseBranch ?? 'main') : undefined],
|
||||
['Verify', job.verify],
|
||||
@ -754,6 +760,20 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
|
||||
aria-label="Edit job prompt"
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Engine
|
||||
<select
|
||||
value={editEngine}
|
||||
onChange={e => setEditEngine(e.target.value as FleetEngine)}
|
||||
className="mt-1 w-full rounded border bg-background px-2 py-1 text-sm"
|
||||
>
|
||||
{FLEET_ENGINES.map(e => (
|
||||
<option key={e} value={e}>
|
||||
{e}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Repo (optional — opens a PR)
|
||||
<input
|
||||
|
||||
@ -5,7 +5,13 @@ import Link from 'next/link';
|
||||
import { PageHeader } from '@bytelyst/dashboard-components';
|
||||
import { Button } from '@/components/ui/Primitives';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { listJobs, submitJob, type FleetJob } from '@/lib/fleet-client';
|
||||
import {
|
||||
listJobs,
|
||||
submitJob,
|
||||
FLEET_ENGINES,
|
||||
type FleetJob,
|
||||
type FleetEngine,
|
||||
} from '@/lib/fleet-client';
|
||||
|
||||
const STAGES = [
|
||||
'queued',
|
||||
@ -92,6 +98,7 @@ export default function FleetJobsPage() {
|
||||
const [factoryId, setFactoryId] = useState(FLEET_FACTORIES[0].id);
|
||||
const [body, setBody] = useState('');
|
||||
const [priority, setPriority] = useState<'critical' | 'high' | 'medium' | 'low'>('high');
|
||||
const [engine, setEngine] = useState<FleetEngine>('devin');
|
||||
// Empty by default: no agent-queue factory advertises a `build` capability
|
||||
// (caps are os:* / engine:* / node:* / has:*), so a non-empty default here makes
|
||||
// the job unroutable. Leave blank ⇒ any capable factory for the product claims it.
|
||||
@ -169,6 +176,7 @@ export default function FleetJobsPage() {
|
||||
bodyMd: body.trim(),
|
||||
priority,
|
||||
capabilities,
|
||||
engine,
|
||||
...(asDraft ? { draft: true } : {}),
|
||||
...(repo
|
||||
? {
|
||||
@ -203,7 +211,7 @@ export default function FleetJobsPage() {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[body, caps, priority, repo, verifyCmd, autoMerge, factoryId, refresh]
|
||||
[body, caps, priority, engine, repo, verifyCmd, autoMerge, factoryId, refresh]
|
||||
);
|
||||
|
||||
return (
|
||||
@ -272,6 +280,23 @@ export default function FleetJobsPage() {
|
||||
<option value="low">low</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="job-engine" className="mb-1 block text-sm font-medium">
|
||||
Engine
|
||||
</label>
|
||||
<select
|
||||
id="job-engine"
|
||||
value={engine}
|
||||
onChange={e => setEngine(e.target.value as FleetEngine)}
|
||||
className="rounded border bg-background px-2 py-1 text-sm"
|
||||
>
|
||||
{FLEET_ENGINES.map(e => (
|
||||
<option key={e} value={e}>
|
||||
{e}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="job-caps" className="mb-1 block text-sm font-medium">
|
||||
Capabilities (comma-separated)
|
||||
|
||||
@ -8,6 +8,10 @@ import { createApiClient } from '@bytelyst/api-client';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Concrete coding-agent engines a factory can run. */
|
||||
export const FLEET_ENGINES = ['devin', 'claude', 'codex', 'copilot'] as const;
|
||||
export type FleetEngine = (typeof FLEET_ENGINES)[number];
|
||||
|
||||
export interface FleetJob {
|
||||
id: string;
|
||||
productId: string;
|
||||
@ -25,6 +29,8 @@ export interface FleetJob {
|
||||
updatedAt: string;
|
||||
reviewPolicy?: ReviewPolicy;
|
||||
reviewDecisions?: ReviewDecision[];
|
||||
/** Concrete engine to run (overrides engineClass); falls back to factory default. */
|
||||
engine?: FleetEngine;
|
||||
repo?: string;
|
||||
baseBranch?: string;
|
||||
/** PR mode: verify command run in the checkout before the PR opens. */
|
||||
@ -220,6 +226,8 @@ export interface SubmitJobBody {
|
||||
/** PR mode: verify command run in the checkout before the PR opens; auto-merge the PR. */
|
||||
verify?: string;
|
||||
autoMerge?: boolean;
|
||||
/** Concrete engine to run (devin/claude/codex/copilot); overrides engineClass. */
|
||||
engine?: FleetEngine;
|
||||
/** Save as a non-claimable, editable draft (stage `draft`) instead of queued. */
|
||||
draft?: boolean;
|
||||
}
|
||||
@ -267,6 +275,7 @@ export interface UpdateDraftBody {
|
||||
bodyMd?: string;
|
||||
priority?: 'critical' | 'high' | 'medium' | 'low';
|
||||
capabilities?: string[];
|
||||
engine?: FleetEngine;
|
||||
repo?: string;
|
||||
baseBranch?: string;
|
||||
verify?: string;
|
||||
|
||||
@ -46,6 +46,16 @@ describe('fleet coordinator', () => {
|
||||
expect(job.productId).toBe(PID);
|
||||
});
|
||||
|
||||
it('submit with an explicit engine stores it and the run carries it at claim', async () => {
|
||||
const { job } = await coord.submitJob(PID, input({ engine: 'claude' }));
|
||||
expect(job.engine).toBe('claude');
|
||||
const claim = await coord.claimNextJob(factory());
|
||||
expect(claim?.run.engine).toBe('claude');
|
||||
// engineless job falls back (no concrete engine forced onto the run)
|
||||
const { job: j2 } = await coord.submitJob(PID, input({ idempotencyKey: 'task-2' }));
|
||||
expect(j2.engine).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── ATOMIC CLAIM RACE (TRUE concurrency, not sequential) ──
|
||||
it('atomic claim: two TRULY concurrent contenders on the same job version — exactly one wins', async () => {
|
||||
const { job } = await coord.submitJob(PID, input());
|
||||
|
||||
@ -210,6 +210,7 @@ export async function submitJob(productId: string, input: SubmitJobInput): Promi
|
||||
priority: input.priority,
|
||||
priorityOrder: PRIORITY_ORDER[input.priority],
|
||||
capabilities: input.capabilities,
|
||||
engine: input.engine,
|
||||
engineClass: input.engineClass,
|
||||
profile: input.profile,
|
||||
deps: input.deps,
|
||||
@ -339,6 +340,7 @@ export async function updateDraft(
|
||||
updates.priorityOrder = PRIORITY_ORDER[patch.priority];
|
||||
}
|
||||
if (patch.capabilities !== undefined) updates.capabilities = patch.capabilities;
|
||||
if (patch.engine !== undefined) updates.engine = patch.engine;
|
||||
if (patch.repo !== undefined) updates.repo = patch.repo;
|
||||
if (patch.baseBranch !== undefined) updates.baseBranch = patch.baseBranch;
|
||||
if (patch.verify !== undefined) updates.verify = patch.verify;
|
||||
@ -384,6 +386,7 @@ function applyInputToJob(job: FleetJobDoc, input: SubmitJobInput, hash: string):
|
||||
priority: input.priority,
|
||||
priorityOrder: PRIORITY_ORDER[input.priority],
|
||||
capabilities: input.capabilities,
|
||||
engine: input.engine,
|
||||
engineClass: input.engineClass,
|
||||
profile: input.profile,
|
||||
deps: input.deps,
|
||||
@ -509,7 +512,9 @@ export async function tryClaimJob(
|
||||
jobId: job.id,
|
||||
attempt,
|
||||
factoryId: ctx.factoryId,
|
||||
engine: job.engineClass ?? 'unknown',
|
||||
// Prefer the job's concrete engine, then its abstract class; the worker later
|
||||
// reports the actually-resolved engine (insights.engine) on release.
|
||||
engine: job.engine ?? job.engineClass ?? 'unknown',
|
||||
startedAt: nowIso,
|
||||
insights: {},
|
||||
});
|
||||
|
||||
@ -48,6 +48,11 @@ export const PRIORITY_ORDER: Record<FleetPriority, number> = {
|
||||
export const FLEET_ENGINE_CLASSES = ['agentic-coder', 'chat-coder', 'review-only'] as const;
|
||||
export type FleetEngineClass = (typeof FLEET_ENGINE_CLASSES)[number];
|
||||
|
||||
/** Concrete coding-agent engines a factory can run. An explicit `engine` on a job
|
||||
* wins over `engineClass`/`prefersEngine` (the runner's `resolve_engine`). */
|
||||
export const FLEET_ENGINES = ['devin', 'claude', 'codex', 'copilot'] as const;
|
||||
export type FleetEngine = (typeof FLEET_ENGINES)[number];
|
||||
|
||||
export const DEPS_MODES = ['hard', 'soft'] as const;
|
||||
export type DepsMode = (typeof DEPS_MODES)[number];
|
||||
|
||||
@ -177,6 +182,7 @@ export const FleetJobDocSchema = z.object({
|
||||
priority: z.enum(FLEET_PRIORITIES),
|
||||
priorityOrder: z.number().int(),
|
||||
capabilities: z.array(z.string()).default([]),
|
||||
engine: z.enum(FLEET_ENGINES).optional(),
|
||||
engineClass: z.enum(FLEET_ENGINE_CLASSES).optional(),
|
||||
profile: z.string().optional(),
|
||||
deps: z.array(z.string()).default([]),
|
||||
@ -368,6 +374,8 @@ export const SubmitJobSchema = z.object({
|
||||
* and whether to auto-merge the PR once opened. */
|
||||
verify: z.string().optional(),
|
||||
autoMerge: z.boolean().optional(),
|
||||
/** Concrete engine to run (devin/claude/codex/copilot); overrides engineClass. */
|
||||
engine: z.enum(FLEET_ENGINES).optional(),
|
||||
/** Save as a non-claimable, editable draft (stage `draft`) instead of `queued`. */
|
||||
draft: z.boolean().optional(),
|
||||
/** Phase 3 DAG: inline children to create atomically with the parent. */
|
||||
@ -398,6 +406,7 @@ export const UpdateDraftSchema = z.object({
|
||||
bodyMd: z.string().min(1).optional(),
|
||||
priority: z.enum(FLEET_PRIORITIES).optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
engine: z.enum(FLEET_ENGINES).optional(),
|
||||
repo: z.string().optional(),
|
||||
baseBranch: z.string().optional(),
|
||||
verify: z.string().optional(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user