feat(FN-3202): handle manual PR linking and feedback follow-ups

- Add scheduler logic to create dependency-linked follow-up tasks when actionable PR feedback remains after a PR is merged or closed
- Update engine runtime/project wiring to support manual PR create flows and branch publish behavior for fusion/<task-id>
- Add dashboard route coverage for manual PR creation/linking behavior and corresponding engine/runtime tests
- Document manual PR branch conventions and follow-up behavior in task management and dashboard docs

Fusion-Task-Id: FN-3202
This commit is contained in:
Fusion
2026-05-02 10:59:51 -07:00
committed by gsxdsm
parent bed7f3d325
commit 4eacff7a9e
9 changed files with 174 additions and 14 deletions

View File

@@ -24,6 +24,8 @@ const mocks = vi.hoisted(() => ({
notifierNotifyGridlock: vi.fn(),
notificationServiceStart: vi.fn(async () => undefined),
notificationServiceStop: vi.fn(),
runtimeConfigurePrMonitoring: vi.fn(),
prHandlerCreateFollowUpTask: vi.fn(async () => undefined),
}));
vi.mock("@fusion/core", async (importOriginal) => {
@@ -71,6 +73,7 @@ vi.mock("../pr-monitor.js", () => ({
vi.mock("../pr-comment-handler.js", () => ({
PrCommentHandler: vi.fn().mockImplementation(() => ({
handleNewComments: vi.fn(),
createFollowUpTask: mocks.prHandlerCreateFollowUpTask,
})),
}));
@@ -101,6 +104,7 @@ vi.mock("../runtimes/in-process-runtime.js", () => ({
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
configurePrMonitoring: mocks.runtimeConfigurePrMonitoring,
})),
}));
@@ -284,6 +288,36 @@ describe("ProjectEngine notification ownership wiring", () => {
});
});
describe("ProjectEngine PR monitoring wiring", () => {
it("wires runtime scheduler PR monitoring with closed-PR follow-up handler", async () => {
const { store } = createMockStore(baseSettings);
mocks.currentStore = store;
const engine = createEngine();
await engine.start();
expect(mocks.runtimeConfigurePrMonitoring).toHaveBeenCalled();
const configArg = mocks.runtimeConfigurePrMonitoring.mock.calls.at(-1)?.[0] as {
onClosedPrFeedback?: (taskId: string, prInfo: Record<string, unknown>, comments: unknown[]) => Promise<void> | void;
};
expect(typeof configArg.onClosedPrFeedback).toBe("function");
await configArg.onClosedPrFeedback?.(
"FN-3202",
{ number: 12, status: "merged", url: "https://example/pr/12" } as never,
[{ id: 1, body: "please fix", user: { login: "reviewer" } }] as never,
);
expect(mocks.prHandlerCreateFollowUpTask).toHaveBeenCalledWith(
"FN-3202",
expect.objectContaining({ number: 12 }),
expect.any(Array),
);
await engine.stop();
});
});
describe("ProjectEngine auto-summarize wiring", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -273,6 +273,11 @@ export class ProjectEngine {
this.prMonitor.onNewComments((taskId, prInfo, comments) =>
this.prCommentHandler!.handleNewComments(taskId, prInfo, comments),
);
this.runtime.configurePrMonitoring({
prMonitor: this.prMonitor,
onClosedPrFeedback: (taskId, prInfo, comments) =>
this.prCommentHandler!.createFollowUpTask(taskId, prInfo, comments),
});
// 3. Initialize notification services (unless caller manages them externally)
if (!this.options.skipNotifier) {

View File

@@ -18,6 +18,7 @@ const {
mockResumeOrphaned,
mockTaskStoreSettings,
mockMessageStoreSetHook,
mockSchedulerConfigurePrMonitoring,
} = vi.hoisted(() => ({
mockSelfHealingStart: vi.fn(),
mockSelfHealingStop: vi.fn(),
@@ -28,6 +29,7 @@ const {
mockResumeOrphaned: vi.fn().mockResolvedValue(undefined),
mockTaskStoreSettings: {} as Record<string, unknown>,
mockMessageStoreSetHook: vi.fn(),
mockSchedulerConfigurePrMonitoring: vi.fn(),
}));
// Mock the TaskStore class
@@ -108,6 +110,7 @@ vi.mock("../../scheduler.js", async () => {
self.start = vi.fn();
self.stop = vi.fn();
self.reconcileAllMissionFeatures = vi.fn().mockResolvedValue(0);
self.configurePrMonitoring = mockSchedulerConfigurePrMonitoring;
return self;
}),
};
@@ -473,6 +476,19 @@ describe("InProcessRuntime", () => {
it("should return undefined TriggerScheduler before start", () => {
expect(runtime.getTriggerScheduler()).toBeUndefined();
});
it("configures scheduler PR monitoring after start", async () => {
await runtime.start();
runtime.configurePrMonitoring({
prMonitor: {} as never,
onClosedPrFeedback: vi.fn(),
});
expect(mockSchedulerConfigurePrMonitoring).toHaveBeenCalledTimes(1);
expect(mockSchedulerConfigurePrMonitoring).toHaveBeenCalledWith(expect.objectContaining({
prMonitor: expect.any(Object),
}));
});
});
describe("trigger scheduler wiring", () => {

View File

@@ -13,6 +13,8 @@ import type {
} from "@fusion/core";
import { isEphemeralAgent } from "@fusion/core";
import { Scheduler } from "../scheduler.js";
import type { PrMonitor, PrComment } from "../pr-monitor.js";
import type { PrInfo } from "@fusion/core";
import { TaskExecutor, type TaskExecutorOptions } from "../executor.js";
import { WorktreePool, isGitRepository } from "../worktree-pool.js";
import { AgentSemaphore } from "../concurrency.js";
@@ -1004,6 +1006,17 @@ export class InProcessRuntime
return this.scheduler;
}
configurePrMonitoring(options: {
prMonitor: PrMonitor;
onClosedPrFeedback?: (taskId: string, prInfo: PrInfo, comments: PrComment[]) => void | Promise<void>;
}): void {
if (!this.scheduler) {
throw new Error("Scheduler not initialized. Call start() first.");
}
this.scheduler.configurePrMonitoring(options);
}
/**
* Get current runtime metrics.
*/

View File

@@ -424,6 +424,32 @@ export class Scheduler {
return this.options.missionAutopilot;
}
configurePrMonitoring(options: {
prMonitor?: PrMonitor;
onClosedPrFeedback?: SchedulerOptions["onClosedPrFeedback"];
}): void {
this.options.prMonitor = options.prMonitor;
this.options.onClosedPrFeedback = options.onClosedPrFeedback;
if (!options.prMonitor) {
return;
}
void this.store.listTasks({ slim: true, includeArchived: false })
.then((tasks) => {
const repo = getCurrentRepo(this.store.getRootDir());
if (!repo) return;
for (const task of tasks) {
if (task.column !== "in-review" || !task.prInfo) continue;
options.prMonitor!.startMonitoring(task.id, repo.owner, repo.repo, task.prInfo);
}
})
.catch((err) => {
schedulerLog.error("Failed to hydrate PR monitoring from existing in-review tasks:", err);
});
}
/**
* Resolve the base branch for a task being started.
*