diff --git a/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx b/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx
index 41370ecc..68e00d95 100644
--- a/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx
+++ b/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx
@@ -118,8 +118,8 @@ export default function FleetJobDetailPage() {
if (!job) return;
setShipping(true);
try {
- const updated = await patchJob(jobId, { leaseEpoch: job.leaseEpoch, stage: 'shipped' });
- setJob(updated);
+ await patchJob(jobId, { leaseEpoch: job.leaseEpoch, stage: 'shipped' });
+ await refresh(); // pull updated job + runs (PR may now be merged)
} catch {
/* show error in production */
} finally {
@@ -197,7 +197,11 @@ export default function FleetJobDetailPage() {
{job.stage !== 'shipped' && job.stage !== 'failed' && (
)}
{job.stage === 'building' && (
diff --git a/services/platform-service/src/modules/fleet/coordinator.ts b/services/platform-service/src/modules/fleet/coordinator.ts
index 6bc0d692..3d985711 100644
--- a/services/platform-service/src/modules/fleet/coordinator.ts
+++ b/services/platform-service/src/modules/fleet/coordinator.ts
@@ -18,6 +18,8 @@
*/
import { createHash } from 'node:crypto';
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
import { BadRequestError, ConflictError } from '../../lib/errors.js';
import * as repo from './repository.js';
import {
@@ -633,6 +635,27 @@ async function markLatestRunShipped(jobId: string): Promise {
+ if (process.env.FLEET_SHIP_MERGES_PR !== '1') return;
+ if (!latest?.prUrl || latest.prState === 'merged') return;
+ try {
+ await execFileAsync('gh', ['pr', 'merge', latest.prUrl, '--squash', '--delete-branch'], {
+ timeout: 60_000,
+ });
+ await repo.updateRun(latest.id, jobId, { prState: 'merged' });
+ } catch {
+ // best-effort — leave the PR open if the merge is blocked (checks, conflicts…)
+ }
+}
+
export async function patchJobFenced(
jobId: string,
productId: string,
@@ -670,6 +693,8 @@ export async function patchJobFenced(
try {
// Run-level result mirrors the terminal stage (ungated).
const latest = await markLatestRunShipped(jobId);
+ // Optionally merge the linked PR (flag-gated, best-effort).
+ await mergeRunPrOnShip(jobId, latest);
// Budgets (flag-gated): accrue the run's actual cost, idempotent per run.
if (isBudgetsEnabled()) {
await accrueSpend(productId, latest?.insights?.costUsd ?? 0, `${jobId}:${job.leaseEpoch}`);
@@ -1015,10 +1040,12 @@ export async function operatorAction(
data: { action, leaseEpoch: newEpoch, returnedTo: stage },
});
- // Operator `ship` is a terminal success — mirror it onto the run result.
+ // Operator `ship` is a terminal success — mirror it onto the run result and
+ // optionally merge the linked PR (flag-gated, best-effort).
if (stage === 'shipped') {
try {
- await markLatestRunShipped(jobId);
+ const latest = await markLatestRunShipped(jobId);
+ await mergeRunPrOnShip(jobId, latest);
} catch {
// best-effort — run bookkeeping never fails the operator action
}