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:
saravanakumardb1 2026-06-01 02:30:22 -07:00
parent 928edad0af
commit 6c31577cf2
6 changed files with 81 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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: {},
});

View File

@ -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(),