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,
|
requestReview,
|
||||||
submitReview,
|
submitReview,
|
||||||
subscribeJobEvents,
|
subscribeJobEvents,
|
||||||
|
FLEET_ENGINES,
|
||||||
type OperatorAction,
|
type OperatorAction,
|
||||||
|
type FleetEngine,
|
||||||
type FleetJob,
|
type FleetJob,
|
||||||
type FleetRun,
|
type FleetRun,
|
||||||
type FleetEvent,
|
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 editable = job.stage === 'draft' || job.stage === 'queued' || job.stage === 'blocked';
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draftBody, setDraftBody] = useState(job.bodyMd ?? '');
|
const [draftBody, setDraftBody] = useState(job.bodyMd ?? '');
|
||||||
|
const [editEngine, setEditEngine] = useState<FleetEngine>(job.engine ?? 'devin');
|
||||||
const [editRepo, setEditRepo] = useState(job.repo ?? '');
|
const [editRepo, setEditRepo] = useState(job.repo ?? '');
|
||||||
const [editBranch, setEditBranch] = useState(job.baseBranch ?? '');
|
const [editBranch, setEditBranch] = useState(job.baseBranch ?? '');
|
||||||
const [editVerify, setEditVerify] = useState(job.verify ?? '');
|
const [editVerify, setEditVerify] = useState(job.verify ?? '');
|
||||||
@ -647,6 +650,7 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
|
|||||||
|
|
||||||
const beginEdit = () => {
|
const beginEdit = () => {
|
||||||
setDraftBody(job.bodyMd ?? '');
|
setDraftBody(job.bodyMd ?? '');
|
||||||
|
setEditEngine(job.engine ?? 'devin');
|
||||||
setEditRepo(job.repo ?? '');
|
setEditRepo(job.repo ?? '');
|
||||||
setEditBranch(job.baseBranch ?? '');
|
setEditBranch(job.baseBranch ?? '');
|
||||||
setEditVerify(job.verify ?? '');
|
setEditVerify(job.verify ?? '');
|
||||||
@ -666,6 +670,7 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
|
|||||||
const repo = editRepo.trim();
|
const repo = editRepo.trim();
|
||||||
await updateDraft(job.id, {
|
await updateDraft(job.id, {
|
||||||
bodyMd: draftBody.trim(),
|
bodyMd: draftBody.trim(),
|
||||||
|
engine: editEngine,
|
||||||
...(repo
|
...(repo
|
||||||
? {
|
? {
|
||||||
repo,
|
repo,
|
||||||
@ -698,6 +703,7 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cfg: Array<[string, string | undefined]> = [
|
const cfg: Array<[string, string | undefined]> = [
|
||||||
|
['Engine', job.engine],
|
||||||
['Repo', job.repo],
|
['Repo', job.repo],
|
||||||
['Base branch', job.repo ? (job.baseBranch ?? 'main') : undefined],
|
['Base branch', job.repo ? (job.baseBranch ?? 'main') : undefined],
|
||||||
['Verify', job.verify],
|
['Verify', job.verify],
|
||||||
@ -754,6 +760,20 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
|
|||||||
aria-label="Edit job prompt"
|
aria-label="Edit job prompt"
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<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">
|
<label className="text-xs text-muted-foreground">
|
||||||
Repo (optional — opens a PR)
|
Repo (optional — opens a PR)
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -5,7 +5,13 @@ import Link from 'next/link';
|
|||||||
import { PageHeader } from '@bytelyst/dashboard-components';
|
import { PageHeader } from '@bytelyst/dashboard-components';
|
||||||
import { Button } from '@/components/ui/Primitives';
|
import { Button } from '@/components/ui/Primitives';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
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 = [
|
const STAGES = [
|
||||||
'queued',
|
'queued',
|
||||||
@ -92,6 +98,7 @@ export default function FleetJobsPage() {
|
|||||||
const [factoryId, setFactoryId] = useState(FLEET_FACTORIES[0].id);
|
const [factoryId, setFactoryId] = useState(FLEET_FACTORIES[0].id);
|
||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
const [priority, setPriority] = useState<'critical' | 'high' | 'medium' | 'low'>('high');
|
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
|
// Empty by default: no agent-queue factory advertises a `build` capability
|
||||||
// (caps are os:* / engine:* / node:* / has:*), so a non-empty default here makes
|
// (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.
|
// the job unroutable. Leave blank ⇒ any capable factory for the product claims it.
|
||||||
@ -169,6 +176,7 @@ export default function FleetJobsPage() {
|
|||||||
bodyMd: body.trim(),
|
bodyMd: body.trim(),
|
||||||
priority,
|
priority,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
engine,
|
||||||
...(asDraft ? { draft: true } : {}),
|
...(asDraft ? { draft: true } : {}),
|
||||||
...(repo
|
...(repo
|
||||||
? {
|
? {
|
||||||
@ -203,7 +211,7 @@ export default function FleetJobsPage() {
|
|||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[body, caps, priority, repo, verifyCmd, autoMerge, factoryId, refresh]
|
[body, caps, priority, engine, repo, verifyCmd, autoMerge, factoryId, refresh]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -272,6 +280,23 @@ export default function FleetJobsPage() {
|
|||||||
<option value="low">low</option>
|
<option value="low">low</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label htmlFor="job-caps" className="mb-1 block text-sm font-medium">
|
<label htmlFor="job-caps" className="mb-1 block text-sm font-medium">
|
||||||
Capabilities (comma-separated)
|
Capabilities (comma-separated)
|
||||||
|
|||||||
@ -8,6 +8,10 @@ import { createApiClient } from '@bytelyst/api-client';
|
|||||||
|
|
||||||
// ── Types ───────────────────────────────────────────────────────────────────
|
// ── 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 {
|
export interface FleetJob {
|
||||||
id: string;
|
id: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
@ -25,6 +29,8 @@ export interface FleetJob {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
reviewPolicy?: ReviewPolicy;
|
reviewPolicy?: ReviewPolicy;
|
||||||
reviewDecisions?: ReviewDecision[];
|
reviewDecisions?: ReviewDecision[];
|
||||||
|
/** Concrete engine to run (overrides engineClass); falls back to factory default. */
|
||||||
|
engine?: FleetEngine;
|
||||||
repo?: string;
|
repo?: string;
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
/** PR mode: verify command run in the checkout before the PR opens. */
|
/** 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. */
|
/** PR mode: verify command run in the checkout before the PR opens; auto-merge the PR. */
|
||||||
verify?: string;
|
verify?: string;
|
||||||
autoMerge?: boolean;
|
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. */
|
/** Save as a non-claimable, editable draft (stage `draft`) instead of queued. */
|
||||||
draft?: boolean;
|
draft?: boolean;
|
||||||
}
|
}
|
||||||
@ -267,6 +275,7 @@ export interface UpdateDraftBody {
|
|||||||
bodyMd?: string;
|
bodyMd?: string;
|
||||||
priority?: 'critical' | 'high' | 'medium' | 'low';
|
priority?: 'critical' | 'high' | 'medium' | 'low';
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
engine?: FleetEngine;
|
||||||
repo?: string;
|
repo?: string;
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
verify?: string;
|
verify?: string;
|
||||||
|
|||||||
@ -46,6 +46,16 @@ describe('fleet coordinator', () => {
|
|||||||
expect(job.productId).toBe(PID);
|
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) ──
|
// ── ATOMIC CLAIM RACE (TRUE concurrency, not sequential) ──
|
||||||
it('atomic claim: two TRULY concurrent contenders on the same job version — exactly one wins', async () => {
|
it('atomic claim: two TRULY concurrent contenders on the same job version — exactly one wins', async () => {
|
||||||
const { job } = await coord.submitJob(PID, input());
|
const { job } = await coord.submitJob(PID, input());
|
||||||
|
|||||||
@ -210,6 +210,7 @@ export async function submitJob(productId: string, input: SubmitJobInput): Promi
|
|||||||
priority: input.priority,
|
priority: input.priority,
|
||||||
priorityOrder: PRIORITY_ORDER[input.priority],
|
priorityOrder: PRIORITY_ORDER[input.priority],
|
||||||
capabilities: input.capabilities,
|
capabilities: input.capabilities,
|
||||||
|
engine: input.engine,
|
||||||
engineClass: input.engineClass,
|
engineClass: input.engineClass,
|
||||||
profile: input.profile,
|
profile: input.profile,
|
||||||
deps: input.deps,
|
deps: input.deps,
|
||||||
@ -339,6 +340,7 @@ export async function updateDraft(
|
|||||||
updates.priorityOrder = PRIORITY_ORDER[patch.priority];
|
updates.priorityOrder = PRIORITY_ORDER[patch.priority];
|
||||||
}
|
}
|
||||||
if (patch.capabilities !== undefined) updates.capabilities = patch.capabilities;
|
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.repo !== undefined) updates.repo = patch.repo;
|
||||||
if (patch.baseBranch !== undefined) updates.baseBranch = patch.baseBranch;
|
if (patch.baseBranch !== undefined) updates.baseBranch = patch.baseBranch;
|
||||||
if (patch.verify !== undefined) updates.verify = patch.verify;
|
if (patch.verify !== undefined) updates.verify = patch.verify;
|
||||||
@ -384,6 +386,7 @@ function applyInputToJob(job: FleetJobDoc, input: SubmitJobInput, hash: string):
|
|||||||
priority: input.priority,
|
priority: input.priority,
|
||||||
priorityOrder: PRIORITY_ORDER[input.priority],
|
priorityOrder: PRIORITY_ORDER[input.priority],
|
||||||
capabilities: input.capabilities,
|
capabilities: input.capabilities,
|
||||||
|
engine: input.engine,
|
||||||
engineClass: input.engineClass,
|
engineClass: input.engineClass,
|
||||||
profile: input.profile,
|
profile: input.profile,
|
||||||
deps: input.deps,
|
deps: input.deps,
|
||||||
@ -509,7 +512,9 @@ export async function tryClaimJob(
|
|||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
attempt,
|
attempt,
|
||||||
factoryId: ctx.factoryId,
|
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,
|
startedAt: nowIso,
|
||||||
insights: {},
|
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 const FLEET_ENGINE_CLASSES = ['agentic-coder', 'chat-coder', 'review-only'] as const;
|
||||||
export type FleetEngineClass = (typeof FLEET_ENGINE_CLASSES)[number];
|
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 const DEPS_MODES = ['hard', 'soft'] as const;
|
||||||
export type DepsMode = (typeof DEPS_MODES)[number];
|
export type DepsMode = (typeof DEPS_MODES)[number];
|
||||||
|
|
||||||
@ -177,6 +182,7 @@ export const FleetJobDocSchema = z.object({
|
|||||||
priority: z.enum(FLEET_PRIORITIES),
|
priority: z.enum(FLEET_PRIORITIES),
|
||||||
priorityOrder: z.number().int(),
|
priorityOrder: z.number().int(),
|
||||||
capabilities: z.array(z.string()).default([]),
|
capabilities: z.array(z.string()).default([]),
|
||||||
|
engine: z.enum(FLEET_ENGINES).optional(),
|
||||||
engineClass: z.enum(FLEET_ENGINE_CLASSES).optional(),
|
engineClass: z.enum(FLEET_ENGINE_CLASSES).optional(),
|
||||||
profile: z.string().optional(),
|
profile: z.string().optional(),
|
||||||
deps: z.array(z.string()).default([]),
|
deps: z.array(z.string()).default([]),
|
||||||
@ -368,6 +374,8 @@ export const SubmitJobSchema = z.object({
|
|||||||
* and whether to auto-merge the PR once opened. */
|
* and whether to auto-merge the PR once opened. */
|
||||||
verify: z.string().optional(),
|
verify: z.string().optional(),
|
||||||
autoMerge: z.boolean().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`. */
|
/** Save as a non-claimable, editable draft (stage `draft`) instead of `queued`. */
|
||||||
draft: z.boolean().optional(),
|
draft: z.boolean().optional(),
|
||||||
/** Phase 3 DAG: inline children to create atomically with the parent. */
|
/** 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(),
|
bodyMd: z.string().min(1).optional(),
|
||||||
priority: z.enum(FLEET_PRIORITIES).optional(),
|
priority: z.enum(FLEET_PRIORITIES).optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
|
engine: z.enum(FLEET_ENGINES).optional(),
|
||||||
repo: z.string().optional(),
|
repo: z.string().optional(),
|
||||||
baseBranch: z.string().optional(),
|
baseBranch: z.string().optional(),
|
||||||
verify: z.string().optional(),
|
verify: z.string().optional(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user