feat(fleet): PR deliverables — jobs target a repo, factory opens a PR, link recorded

Make "shipped" produce a real artifact. A job can now carry an optional repo
(owner/name or clone URL) + baseBranch; the factory's PR mode runs the agent in an
isolated checkout, opens a PR, and records the link.

Backend:
- SubmitJobSchema + FleetJobDoc: optional repo/baseBranch (recorded on submit).
- FleetRunDoc: optional prUrl/branch.
- ReleaseLease report carries prUrl/branch -> stored on the run.
- +2 coordinator tests.

UI (tracker-web):
- New Job form gains optional Repo + Base branch fields (and fixes the priority
  options to the valid critical/high/medium/low; "normal" was rejected by the API).
- Job detail Runs table shows a PR ↗ link from run.prUrl.
- fleet-client: submitJob repo/baseBranch; FleetRun prUrl/branch; OperatorAction +ship.

Docs: FLEET_CONTROL_PLANE.md "PR deliverable (PR mode)" section.

Verified: tsc clean; fleet suite 182; tracker-web 230.

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-05-31 05:27:11 -07:00
parent 8b9ca4fee2
commit 883cf329e5
8 changed files with 131 additions and 9 deletions

View File

@ -351,7 +351,8 @@ export default function FleetJobDetailPage() {
<th className="pb-2 pr-4">Duration</th>
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4 text-right">Tokens (in/out)</th>
<th className="pb-2 text-right">Cost</th>
<th className="pb-2 pr-4 text-right">Cost</th>
<th className="pb-2">PR</th>
</tr>
</thead>
<tbody>
@ -401,6 +402,21 @@ export default function FleetJobDetailPage() {
'—'
)}
</td>
<td className="py-2 text-xs">
{r.prUrl ? (
<a
href={r.prUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
aria-label="Open pull request"
>
PR
</a>
) : (
'—'
)}
</td>
</tr>
);
})}

View File

@ -30,8 +30,10 @@ export default function FleetJobsPage() {
// New-job form state
const [showForm, setShowForm] = useState(false);
const [body, setBody] = useState('');
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('high');
const [priority, setPriority] = useState<'critical' | 'high' | 'medium' | 'low'>('high');
const [caps, setCaps] = useState('build');
const [repo, setRepo] = useState('');
const [baseBranch, setBaseBranch] = useState('main');
const [submitting, setSubmitting] = useState(false);
const [submitMsg, setSubmitMsg] = useState<{ ok: boolean; text: string } | null>(null);
@ -72,6 +74,7 @@ export default function FleetJobsPage() {
bodyMd: body.trim(),
priority,
capabilities,
...(repo.trim() ? { repo: repo.trim(), baseBranch: baseBranch.trim() || 'main' } : {}),
});
setSubmitMsg({ ok: true, text: `Submitted ${job.id} (stage: ${job.stage}).` });
setBody('');
@ -82,7 +85,7 @@ export default function FleetJobsPage() {
} finally {
setSubmitting(false);
}
}, [body, caps, priority, refresh]);
}, [body, caps, priority, repo, baseBranch, refresh]);
return (
<div className="p-6 space-y-6">
@ -122,11 +125,14 @@ export default function FleetJobsPage() {
<select
id="job-priority"
value={priority}
onChange={e => setPriority(e.target.value as 'low' | 'normal' | 'high')}
onChange={e =>
setPriority(e.target.value as 'critical' | 'high' | 'medium' | 'low')
}
className="rounded border bg-background px-2 py-1 text-sm"
>
<option value="critical">critical</option>
<option value="high">high</option>
<option value="normal">normal</option>
<option value="medium">medium</option>
<option value="low">low</option>
</select>
</div>
@ -141,6 +147,31 @@ export default function FleetJobsPage() {
className="rounded border bg-background px-2 py-1 text-sm"
/>
</div>
<div>
<label htmlFor="job-repo" className="mb-1 block text-sm font-medium">
Repo (optional, PR mode)
</label>
<input
id="job-repo"
value={repo}
onChange={e => setRepo(e.target.value)}
placeholder="owner/name"
className="rounded border bg-background px-2 py-1 text-sm font-mono"
/>
</div>
{repo.trim() && (
<div>
<label htmlFor="job-base" className="mb-1 block text-sm font-medium">
Base branch
</label>
<input
id="job-base"
value={baseBranch}
onChange={e => setBaseBranch(e.target.value)}
className="rounded border bg-background px-2 py-1 text-sm font-mono"
/>
</div>
)}
<Button onClick={handleSubmit} disabled={submitting} aria-label="Submit new job">
{submitting ? 'Submitting…' : 'Submit Job'}
</Button>

View File

@ -63,6 +63,8 @@ export interface FleetRun {
endedAt?: string;
result?: string;
insights: FleetRunInsights;
prUrl?: string;
branch?: string;
}
export interface FleetEvent {
@ -195,8 +197,11 @@ export async function getJob(id: string): Promise<FleetJob | null> {
export interface SubmitJobBody {
idempotencyKey: string;
bodyMd: string;
priority?: 'low' | 'normal' | 'high';
priority?: 'critical' | 'high' | 'medium' | 'low';
capabilities?: string[];
/** PR mode: open a PR against this repo (`owner/name` or clone URL) + base branch. */
repo?: string;
baseBranch?: string;
}
/** Submit a new fleet job. Returns the created job. */

View File

@ -150,6 +150,18 @@ release), the coordinator mirrors the outcome onto the latest **run**
run's `insights.costUsd`. So the dashboard's per-run result/cost/tokens stay
consistent with the job stage.
### PR deliverable (PR mode)
A job may carry an optional **`repo`** (`owner/name` or a clone URL) + **`baseBranch`**.
When the factory runs with `AQ_FLEET_PR=1`, it runs the agent in an isolated checkout
on branch `aq/job/<jobId>`, then commits, pushes, and opens a PR via `gh`. The PR URL
- branch are reported on lease release and recorded on the **run** (`run.prUrl`,
`run.branch`) — the dashboard shows a **PR ↗** link in the job's Runs table. Submit
`repo`/`baseBranch` from the dashboard "New Job" form or the `POST /fleet/jobs` body.
This round opens the PR (merge stays a human/CI step); opt-in auto-merge is a planned
follow-up.
## API Reference Summary
| Endpoint | Method | Phase | Notes |

View File

@ -1295,3 +1295,33 @@ describe('fleet coordinator — DAG submitChildren cycle detection', () => {
expect(result.parent.stage).toBe('blocked');
});
});
describe('fleet coordinator — PR target + deliverable (§PR mode)', () => {
beforeEach(() => setProvider(new MemoryDatastoreProvider()));
afterEach(() => _resetDatastoreProvider());
it('submit records repo/baseBranch on the job', async () => {
await coord.submitJob(
PID,
input({ idempotencyKey: 'pr-1', repo: 'acme/widgets', baseBranch: 'develop' })
);
const claim = await coord.claimNextJob(factory());
expect(claim!.job.repo).toBe('acme/widgets');
expect(claim!.job.baseBranch).toBe('develop');
});
it('release report records prUrl + branch on the run', async () => {
await coord.submitJob(PID, input({ idempotencyKey: 'pr-2', repo: 'acme/widgets' }));
const claim = await coord.claimNextJob(factory());
const c = claim!.job;
const rel = await coord.releaseLease(c.id, PID, c.leaseEpoch, 'review', {
result: 'shipped',
prUrl: 'https://github.com/acme/widgets/pull/42',
branch: 'aq/job/pr-2',
});
expect(rel.ok).toBe(true);
const runs = await repo.listRunsByJob(c.id);
expect(runs[0]!.prUrl).toBe('https://github.com/acme/widgets/pull/42');
expect(runs[0]!.branch).toBe('aq/job/pr-2');
});
});

View File

@ -212,6 +212,8 @@ export async function submitJob(productId: string, input: SubmitJobInput): Promi
kind: input.kind,
parentId: input.parentId,
trackerItemId: input.trackerItemId,
repo: input.repo,
baseBranch: input.baseBranch,
attempts: 0,
leaseEpoch: 0,
rev: 0,
@ -886,7 +888,12 @@ export async function releaseLease(
productId: string,
leaseEpoch: number,
stage?: FleetStage,
report?: { insights?: FleetRunDoc['insights']; result?: FleetRunDoc['result'] }
report?: {
insights?: FleetRunDoc['insights'];
result?: FleetRunDoc['result'];
prUrl?: string;
branch?: string;
}
): Promise<FenceResult<FleetLeaseDoc>> {
const job = await repo.getJob(jobId, productId);
if (!job) return { ok: false, reason: 'not_found' };
@ -896,12 +903,14 @@ export async function releaseLease(
const res = await repo.revUpdateLease(jobId, lease.rev, { status: 'released' });
if (!res.ok) return { ok: false, reason: 'conflict' };
if (stage) await repo.revUpdateJob(jobId, productId, job.rev, { stage });
// Record the factory's reported cost/token metrics + outcome on the current run.
if (report?.insights || report?.result) {
// Record the factory's reported metrics + outcome + PR deliverable on the run.
if (report?.insights || report?.result || report?.prUrl || report?.branch) {
const runId = `${jobId}:run:${job.attempts}`;
await repo.updateRun(runId, jobId, {
...(report.insights ? { insights: report.insights } : {}),
...(report.result ? { result: report.result } : {}),
...(report.prUrl ? { prUrl: report.prUrl } : {}),
...(report.branch ? { branch: report.branch } : {}),
endedAt: new Date().toISOString(),
});
}

View File

@ -261,6 +261,8 @@ export async function fleetRoutes(app: FastifyInstance) {
const res = await coordinator.releaseLease(id, pid, parsed.data.leaseEpoch, parsed.data.stage, {
insights: parsed.data.insights,
result: parsed.data.result,
prUrl: parsed.data.prUrl,
branch: parsed.data.branch,
});
if (!res.ok) {
if (res.reason === 'not_found') throw new NotFoundError('Job or lease not found');

View File

@ -177,6 +177,14 @@ export const FleetJobDocSchema = z.object({
kind: z.enum(JOB_KINDS).default('leaf'),
parentId: z.string().optional(),
trackerItemId: z.string().optional(),
/**
* Optional PR target: the repo (e.g. `owner/name` or a clone URL) and base
* branch the factory should open a pull request against (§PR mode). When set
* and the factory runs with PR mode on, its work is committed to a job branch,
* pushed, and a PR is opened; the PR URL is recorded on the run.
*/
repo: z.string().optional(),
baseBranch: z.string().optional(),
checkpoint: CheckpointSchema.optional(),
attempts: z.number().int().nonnegative().default(0),
leaseEpoch: z.number().int().nonnegative().default(0),
@ -214,6 +222,9 @@ export const FleetRunDocSchema = z.object({
verifyResult: z.enum(['pass', 'fail']).optional(),
result: z.enum(RUN_RESULTS).optional(),
insights: InsightsSchema.default({}),
/** PR deliverable produced by this run (§PR mode). */
prUrl: z.string().optional(),
branch: z.string().optional(),
});
export type FleetRunDoc = z.infer<typeof FleetRunDocSchema>;
@ -338,6 +349,9 @@ export const SubmitJobSchema = z.object({
kind: z.enum(JOB_KINDS).default('leaf'),
parentId: z.string().optional(),
trackerItemId: z.string().optional(),
/** Optional PR target (§PR mode): repo (`owner/name` or clone URL) + base branch. */
repo: z.string().optional(),
baseBranch: z.string().optional(),
/** Phase 3 DAG: inline children to create atomically with the parent. */
children: z
.array(
@ -412,6 +426,9 @@ export const ReleaseLeaseSchema = z.object({
// releases the lease at the end of a work unit. Recorded on the current run.
insights: InsightsSchema.optional(),
result: z.enum(RUN_RESULTS).optional(),
// PR deliverable (§PR mode): the pull request the run opened + its branch.
prUrl: z.string().optional(),
branch: z.string().optional(),
});
export type ReleaseLeaseInput = z.infer<typeof ReleaseLeaseSchema>;