FN-5703: forward task:deleted events through in-process runtime

Ensure engine-side task deletes propagate through all runtime transports so GitHub tracking cleanup runs consistently.

- extend IPC protocol and child-process runtime/worker plumbing to carry `task:deleted` events
- forward `task:deleted` from in-process runtime through project runtime and project manager dispatch
- add regression coverage for in-process runtime forwarding and GitHub tracking delete handling

Files changed:
 .../src/__tests__/github-tracking-delete.test.ts   | 29 ++++++++++++++++++
 .../src/__tests__/in-process-runtime.test.ts       | 34 +++++++++++++++++++++-
 packages/engine/src/ipc/ipc-protocol.ts            | 14 ++++++++-
 packages/engine/src/project-manager.ts             |  7 +++++
 packages/engine/src/project-runtime.ts             |  4 ++-
 .../engine/src/runtimes/child-process-runtime.ts   |  6 ++++
 .../engine/src/runtimes/child-process-worker.ts    | 14 +++++++--
 packages/engine/src/runtimes/in-process-runtime.ts | 10 ++++++-
 8 files changed, 111 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-5703

Fusion-Task-Lineage: 1406838b-5abf-4b86-8161-c2dd59f88a9b
This commit is contained in:
gsxdsm
2026-05-29 18:38:43 -07:00
parent dac9b96262
commit a9d2064f66
8 changed files with 111 additions and 7 deletions

View File

@@ -133,6 +133,35 @@ describe("github tracking delete flow", () => {
expect(mockSetIssueState).toHaveBeenCalledWith("octocat", "hello-world", 7, "closed", "not_planned"); expect(mockSetIssueState).toHaveBeenCalledWith("octocat", "hello-world", 7, "closed", "not_planned");
}); });
it("uses task:deleted githubIssueAction=auto metadata to close linked issue", async () => {
const handleTaskDeletedSpy = vi.spyOn(stateService as any, "handleTaskDeleted");
const task = await store.createTask({
description: "delete tracked task with default auto metadata",
githubTracking: { enabled: true },
});
await store.linkGithubIssue(task.id, {
owner: "octocat",
repo: "hello-world",
number: 70,
url: "https://github.com/octocat/hello-world/issues/70",
createdAt: new Date().toISOString(),
});
const closeAction = waitForGithubIssueAction(
store,
(payload) => payload.taskId === task.id && payload.action === "close" && payload.outcome === "success",
{ timeoutMessage: `Timed out waiting for auto close action for deleted task ${task.id}` },
);
await store.deleteTask(task.id, { githubIssueAction: "auto" });
await closeAction;
expect(handleTaskDeletedSpy).toHaveBeenCalled();
expect(handleTaskDeletedSpy).toHaveBeenCalledWith(store, expect.objectContaining({ id: task.id }), { githubIssueAction: "auto" });
expect(mockSetIssueState).toHaveBeenCalledWith("octocat", "hello-world", 70, "closed", "not_planned");
});
it("does not call GitHub when deleting a task with tracking disabled", async () => { it("does not call GitHub when deleting a task with tracking disabled", async () => {
const task = await store.createTask({ const task = await store.createTask({
description: "delete untracked task", description: "delete untracked task",

View File

@@ -1,6 +1,8 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { EventEmitter } from "node:events";
import { InProcessRuntime } from "../runtimes/in-process-runtime.js";
describe("InProcessRuntime onStart duplicate guard", () => { describe("InProcessRuntime onStart duplicate guard", () => {
it("contains a taskAgentMap guard before creating task-worker agents", () => { it("contains a taskAgentMap guard before creating task-worker agents", () => {
@@ -21,4 +23,34 @@ describe("InProcessRuntime onStart duplicate guard", () => {
const source = readFileSync(join(process.cwd(), "src/runtimes/in-process-runtime.ts"), "utf-8"); const source = readFileSync(join(process.cwd(), "src/runtimes/in-process-runtime.ts"), "utf-8");
expect(source).toContain("activeMissionAutopilot.recoverMissions(activeMissionStore)"); expect(source).toContain("activeMissionAutopilot.recoverMissions(activeMissionStore)");
}); });
it("forwards task:deleted events with and without githubIssueAction metadata", () => {
const runtime = new InProcessRuntime(
{
projectId: "proj-test",
workingDirectory: process.cwd(),
isolationMode: "in-process",
maxConcurrent: 1,
maxWorktrees: 1,
},
{
getGlobalConcurrencyState: vi.fn(),
recordTaskCompletion: vi.fn(),
} as any,
);
const taskStore = new EventEmitter();
const emitSpy = vi.spyOn(runtime, "emit");
(runtime as any).taskStore = taskStore;
(runtime as any).setupEventForwarding();
const task = { id: "FN-1", title: "task" };
const meta = { githubIssueAction: "auto" };
taskStore.emit("task:deleted", task, meta);
taskStore.emit("task:deleted", task);
expect(emitSpy).toHaveBeenCalledWith("task:deleted", task, meta);
expect(emitSpy).toHaveBeenCalledWith("task:deleted", task, undefined);
});
}); });

View File

@@ -11,7 +11,7 @@
*/ */
import type { RuntimeStatus, ProjectRuntimeConfig } from "../project-runtime.js"; import type { RuntimeStatus, ProjectRuntimeConfig } from "../project-runtime.js";
import type { Task } from "@fusion/core"; import type { GithubIssueAction, Task } from "@fusion/core";
// ── Base Message Types ──────────────────────────────────────────────────── // ── Base Message Types ────────────────────────────────────────────────────
@@ -121,6 +121,8 @@ export const TASK_CREATED = "TASK_CREATED" as const;
export const TASK_MOVED = "TASK_MOVED" as const; export const TASK_MOVED = "TASK_MOVED" as const;
/** Event type: Task updated */ /** Event type: Task updated */
export const TASK_UPDATED = "TASK_UPDATED" as const; export const TASK_UPDATED = "TASK_UPDATED" as const;
/** Event type: Task deleted */
export const TASK_DELETED = "TASK_DELETED" as const;
/** Event type: Runtime error */ /** Event type: Runtime error */
export const ERROR_EVENT = "ERROR_EVENT" as const; export const ERROR_EVENT = "ERROR_EVENT" as const;
/** Event type: Health status changed */ /** Event type: Health status changed */
@@ -133,6 +135,7 @@ export type IpcEventType =
| typeof TASK_CREATED | typeof TASK_CREATED
| typeof TASK_MOVED | typeof TASK_MOVED
| typeof TASK_UPDATED | typeof TASK_UPDATED
| typeof TASK_DELETED
| typeof ERROR_EVENT | typeof ERROR_EVENT
| typeof HEALTH_CHANGED; | typeof HEALTH_CHANGED;
@@ -159,6 +162,14 @@ export interface TaskUpdatedPayload {
task: Task; task: Task;
} }
/**
* Payload for TASK_DELETED event.
*/
export interface TaskDeletedPayload {
task: Task;
meta?: { githubIssueAction?: GithubIssueAction };
}
/** /**
* Payload for ERROR event. * Payload for ERROR event.
*/ */
@@ -209,6 +220,7 @@ export function isIpcEvent(message: IpcMessage): boolean {
TASK_CREATED, TASK_CREATED,
TASK_MOVED, TASK_MOVED,
TASK_UPDATED, TASK_UPDATED,
TASK_DELETED,
ERROR_EVENT, ERROR_EVENT,
HEALTH_CHANGED, HEALTH_CHANGED,
]; ];

View File

@@ -18,6 +18,8 @@ import { projectManagerLog } from "./logger.js";
export interface ProjectManagerEvents { export interface ProjectManagerEvents {
/** Emitted when a task is created in any project */ /** Emitted when a task is created in any project */
"task:created": [data: { projectId: string; projectName: string; task: Task }]; "task:created": [data: { projectId: string; projectName: string; task: Task }];
/** Emitted when a task is deleted in any project */
"task:deleted": [data: { projectId: string; projectName: string; task: Task; meta?: { githubIssueAction?: import("@fusion/core").GithubIssueAction } }];
/** Emitted when a task is moved in any project */ /** Emitted when a task is moved in any project */
"task:moved": [ "task:moved": [
data: { data: {
@@ -397,6 +399,11 @@ export class ProjectManager extends EventEmitter<ProjectManagerEvents> {
this.emit("task:updated", { projectId, projectName, task }); this.emit("task:updated", { projectId, projectName, task });
}); });
// Forward task:deleted
runtime.on("task:deleted", (task: Task, meta?: { githubIssueAction?: import("@fusion/core").GithubIssueAction }) => {
this.emit("task:deleted", { projectId, projectName, task, meta });
});
// Forward errors // Forward errors
runtime.on("error", (error: Error) => { runtime.on("error", (error: Error) => {
this.emit("error", { projectId, projectName, error }); this.emit("error", { projectId, projectName, error });

View File

@@ -1,5 +1,5 @@
import type { EventEmitter } from "node:events"; import type { EventEmitter } from "node:events";
import type { TaskStore, Task, IsolationMode, ProjectSettings } from "@fusion/core"; import type { TaskStore, Task, IsolationMode, ProjectSettings, GithubIssueAction } from "@fusion/core";
import type { Scheduler } from "./scheduler.js"; import type { Scheduler } from "./scheduler.js";
/** /**
@@ -67,6 +67,8 @@ export interface ProjectRuntimeEvents {
"task:moved": [data: { task: Task; from: string; to: string }]; "task:moved": [data: { task: Task; from: string; to: string }];
/** Emitted when a task is updated */ /** Emitted when a task is updated */
"task:updated": [task: Task]; "task:updated": [task: Task];
/** Emitted when a task is deleted */
"task:deleted": [task: Task, meta?: { githubIssueAction?: GithubIssueAction }];
/** Emitted when a cross-node assignment event is observed */ /** Emitted when a cross-node assignment event is observed */
"task:assigned": [data: { taskId: string; agentId: string; assignedAt: string; source?: string }]; "task:assigned": [data: { taskId: string; agentId: string; assignedAt: string; source?: string }];
/** Emitted when an error occurs in the runtime */ /** Emitted when an error occurs in the runtime */

View File

@@ -22,11 +22,13 @@ import {
TASK_CREATED, TASK_CREATED,
TASK_MOVED, TASK_MOVED,
TASK_UPDATED, TASK_UPDATED,
TASK_DELETED,
ERROR_EVENT, ERROR_EVENT,
HEALTH_CHANGED, HEALTH_CHANGED,
type TaskCreatedPayload, type TaskCreatedPayload,
type TaskMovedPayload, type TaskMovedPayload,
type TaskUpdatedPayload, type TaskUpdatedPayload,
type TaskDeletedPayload,
type ErrorEventPayload, type ErrorEventPayload,
type HealthChangedPayload, type HealthChangedPayload,
} from "../ipc/ipc-protocol.js"; } from "../ipc/ipc-protocol.js";
@@ -296,6 +298,10 @@ export class ChildProcessRuntime
this.emit("task:updated", payload.task); this.emit("task:updated", payload.task);
}); });
this.ipcHost.on(TASK_DELETED, (payload: TaskDeletedPayload) => {
this.emit("task:deleted", payload.task, payload.meta);
});
// Forward error events // Forward error events
this.ipcHost.on(ERROR_EVENT, (payload: ErrorEventPayload) => { this.ipcHost.on(ERROR_EVENT, (payload: ErrorEventPayload) => {
const error = new Error(payload.message); const error = new Error(payload.message);

View File

@@ -18,6 +18,10 @@ import {
STOP_RUNTIME, STOP_RUNTIME,
GET_STATUS, GET_STATUS,
GET_METRICS, GET_METRICS,
TASK_CREATED,
TASK_MOVED,
TASK_UPDATED,
TASK_DELETED,
ERROR_EVENT, ERROR_EVENT,
type StartRuntimePayload, type StartRuntimePayload,
} from "../ipc/ipc-protocol.js"; } from "../ipc/ipc-protocol.js";
@@ -76,15 +80,19 @@ ipcWorker.onCommand(START_RUNTIME, async (payload: unknown) => {
// Forward runtime events to host // Forward runtime events to host
runtime.on("task:created", (task) => { runtime.on("task:created", (task) => {
ipcWorker.sendEvent("TASK_CREATED", { task }); ipcWorker.sendEvent(TASK_CREATED, { task });
}); });
runtime.on("task:moved", (data) => { runtime.on("task:moved", (data) => {
ipcWorker.sendEvent("TASK_MOVED", data); ipcWorker.sendEvent(TASK_MOVED, data);
}); });
runtime.on("task:updated", (task) => { runtime.on("task:updated", (task) => {
ipcWorker.sendEvent("TASK_UPDATED", { task }); ipcWorker.sendEvent(TASK_UPDATED, { task });
});
runtime.on("task:deleted", (task, meta) => {
ipcWorker.sendEvent(TASK_DELETED, { task, meta });
}); });
runtime.on("error", (error) => { runtime.on("error", (error) => {

View File

@@ -10,6 +10,7 @@ import type {
PluginLoader, PluginLoader,
MessageStore, MessageStore,
RoutineStore, RoutineStore,
GithubIssueAction,
} from "@fusion/core"; } from "@fusion/core";
import { ChatStore, createCentralDatabase, isEphemeralAgent } from "@fusion/core"; import { ChatStore, createCentralDatabase, isEphemeralAgent } from "@fusion/core";
import { Scheduler } from "../scheduler.js"; import { Scheduler } from "../scheduler.js";
@@ -1284,7 +1285,8 @@ export class InProcessRuntime
} }
/** /**
* Set up event forwarding from TaskStore to runtime listeners. * Set up event forwarding from TaskStore to runtime listeners
* for task:created, task:moved, task:updated, and task:deleted.
*/ */
private setupEventForwarding(): void { private setupEventForwarding(): void {
// Forward task:created events // Forward task:created events
@@ -1305,6 +1307,12 @@ export class InProcessRuntime
this.emit("task:updated", task); this.emit("task:updated", task);
}); });
// Forward task:deleted events
this.taskStore.on("task:deleted", (task: Task, meta?: { githubIssueAction?: GithubIssueAction }) => {
this.recordActivity();
this.emit("task:deleted", task, meta);
});
runtimeLog.log("Event forwarding setup complete"); runtimeLog.log("Event forwarding setup complete");
} }