Deliver durable, replay-safe cross-process task deletion observation. - Add PostgreSQL lifecycle consumer cursors, leases, acknowledgements, retention, and recovery. - Start named consumers in dashboard, serve, and engine runtime paths. - Preserve delete integration metadata while suppressing replayed GitHub and GitLab side effects. - Cover outbox identity, observed delivery, fencing, and reconciliation behavior. Files changed: ...fn-8685-cross-process-task-deleted-observers.md | 7 + .../fn-8685-task-deleted-outbox-consumers.md | 7 + docs/architecture.md | 8 +- ...tgres-cross-process-task-deleted-observation.md | 8 +- docs/storage.md | 10 +- packages/cli/src/commands/dashboard.ts | 9 +- packages/cli/src/commands/serve.ts | 9 +- packages/cli/src/project-context.ts | 9 +- .../task-deleted-outbox-consumer.pg.test.ts | 157 ++++++++ ...-deleted-observed-dispatch-side-effects.test.ts | 36 ++ .../task-lifecycle-consumer-identity.test.ts | 22 ++ packages/core/src/index.ts | 11 + .../0041_fn_8685_task_lifecycle_consumers.sql | 88 +++++ packages/core/src/postgres/schema-applier.ts | 16 +- packages/core/src/postgres/schema/project.ts | 45 +++ packages/core/src/postgres/startup-factory.ts | 4 + packages/core/src/store.ts | 54 ++- .../__tests__/lifecycle-outbox-writer.test.ts | 4 +- .../core/src/task-store/archive-lifecycle-2.ts | 1 + packages/core/src/task-store/lifecycle-ops.ts | 13 +- packages/core/src/task-store/lifecycle-outbox.ts | 2 + packages/core/src/task-store/project-store-ops.ts | 4 +- .../src/task-store/task-deleted-outbox-consumer.ts | 333 +++++++++++++++++ .../task-store/task-lifecycle-consumer-identity.ts | 32 ++ .../task-store/task-lifecycle-consumer-registry.ts | 396 +++++++++++++++++++++ .../task-store/task-lifecycle-event-retention.ts | 104 ++++++ packages/core/src/task-store/task-mutation-ops.ts | 1 + packages/dashboard/src/github-tracking-state.ts | 12 +- packages/dashboard/src/gitlab-delete-close.ts | 3 + packages/dashboard/src/gitlab-split-close.ts | 7 +- packages/dashboard/src/project-store-resolver.ts | 9 +- packages/engine/src/project-manager.ts | 4 +- packages/engine/src/project-runtime.ts | 2 +- packages/engine/src/runtimes/in-process-runtime.ts | 17 +- packages/engine/src/self-healing.ts | 27 ++ 35 files changed, 1439 insertions(+), 32 deletions(-) Fusion-Task-Id: FN-8685 Fusion-Task-Lineage: 63eca9ac-d2af-44b0-ba79-388a950148d3 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
104 lines
5.6 KiB
TypeScript
104 lines
5.6 KiB
TypeScript
import type { Task, TaskDeleteClosureContext, TaskStore } from "@fusion/core";
|
|
import { resolveGitLabClient, resolveGitLabTarget, safeLogGitLabEntry, type GitLabLifecycleTarget } from "./gitlab-lifecycle.js";
|
|
import { retryTransient, updateGitLabTargetState } from "./gitlab-tracking-state.js";
|
|
|
|
type TaskDeletedMeta = {
|
|
closureContext?: TaskDeleteClosureContext | { kind?: string; childTaskIds?: unknown };
|
|
observed?: boolean;
|
|
};
|
|
|
|
export type GitLabSplitNoteResult =
|
|
| { status: "posted"; target: GitLabLifecycleTarget }
|
|
| { status: "no-op"; reason: "no-closure-context" | "not-split" | "empty-child-ids" | "no-target" | "merge-request-target" | "already-closed" | "auth-unresolved" }
|
|
| { status: "failed"; reason: "comment-post-failed"; error: unknown };
|
|
|
|
/*
|
|
FNXC:GitLabSplitClose 2026-08-01-09:58:
|
|
Split-close gives the source issue a durable handoff: one note names the parent and normalized child task IDs immediately before one close. resolveGitLabTarget() makes a valid tracking item the owner; malformed tracking stays inert here so split-close cannot newly mutate a source item. Identity is by construction and one item cannot receive duplicate notes.
|
|
|
|
Empty or blank child IDs leave no self-explanatory handoff and are a no-op; duplicate IDs retain their first occurrence only. The typed posted | no-op | failed result deliberately gates the caller: only posted may close the returned target, so a note failure or benign non-owner state can never close an issue without its explanatory note.
|
|
*/
|
|
export function normalizeGitLabSplitChildTaskIds(childTaskIds: readonly string[]): string[] {
|
|
const seen = new Set<string>();
|
|
return childTaskIds.filter((childTaskId) => {
|
|
if (!childTaskId.trim() || seen.has(childTaskId)) return false;
|
|
seen.add(childTaskId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function buildGitLabSplitCloseNote(taskId: string, childTaskIds: readonly string[]): string | null {
|
|
const childIds = normalizeGitLabSplitChildTaskIds(childTaskIds);
|
|
if (childIds.length === 0) return null;
|
|
return `This issue was imported as Fusion task ${taskId}, which has been broken down into subtasks: ${childIds.join(", ")}. Closing this issue; work continues in those tasks.`;
|
|
}
|
|
|
|
export async function postGitLabSplitNoteBeforeClose(store: TaskStore, task: Task, meta?: TaskDeletedMeta): Promise<GitLabSplitNoteResult> {
|
|
const closureContext = meta?.closureContext;
|
|
if (!closureContext) return { status: "no-op", reason: "no-closure-context" };
|
|
if (closureContext.kind !== "split-into-subtasks") return { status: "no-op", reason: "not-split" };
|
|
|
|
const body = buildGitLabSplitCloseNote(task.id, Array.isArray(closureContext.childTaskIds) ? closureContext.childTaskIds : []);
|
|
if (!body) return { status: "no-op", reason: "empty-child-ids" };
|
|
|
|
const target = resolveGitLabTarget(task);
|
|
if (!target) return { status: "no-op", reason: "no-target" };
|
|
if (target.kind === "merge_request") {
|
|
await safeLogGitLabEntry(store, task.id, "Skipped GitLab split-close note", `${target.label} is a merge request and is not auto-closed on task split`);
|
|
return { status: "no-op", reason: "merge-request-target" };
|
|
}
|
|
|
|
const resolved = await resolveGitLabClient(store);
|
|
if (!resolved.ok) return { status: "no-op", reason: "auth-unresolved" };
|
|
|
|
try {
|
|
const existing = await resolved.client.getProjectIssue(target.project, target.iid);
|
|
if (existing.state === "closed") return { status: "no-op", reason: "already-closed" };
|
|
await retryTransient(() => resolved.client.commentOnProjectIssue(target.project, target.iid, body));
|
|
await safeLogGitLabEntry(store, task.id, "Posted GitLab split-close note", target.label);
|
|
return { status: "posted", target };
|
|
} catch (error) {
|
|
return { status: "failed", reason: "comment-post-failed", error };
|
|
}
|
|
}
|
|
|
|
export class GitLabSplitCloseService {
|
|
private readonly defaultStore: TaskStore;
|
|
private readonly listeners = new Map<TaskStore, { onTaskDeleted: (task: Task, meta?: TaskDeletedMeta) => void }>();
|
|
private started = false;
|
|
|
|
constructor(store: TaskStore) { this.defaultStore = store; }
|
|
|
|
start(): void { if (this.started) return; this.started = true; this.attach(this.defaultStore); }
|
|
stop(): void { if (!this.started) return; this.started = false; for (const store of [...this.listeners.keys()]) this.detach(store); }
|
|
|
|
attach(store: TaskStore): void {
|
|
if (this.listeners.has(store)) return;
|
|
const onTaskDeleted = (task: Task, meta?: TaskDeletedMeta): void => { void this.handleTaskDeleted(store, task, meta); };
|
|
this.listeners.set(store, { onTaskDeleted });
|
|
if (this.started) store.on("task:deleted", onTaskDeleted);
|
|
}
|
|
|
|
detach(store: TaskStore): void {
|
|
const handlers = this.listeners.get(store);
|
|
if (!handlers) return;
|
|
store.off("task:deleted", handlers.onTaskDeleted);
|
|
this.listeners.delete(store);
|
|
}
|
|
|
|
private async handleTaskDeleted(store: TaskStore, task: Task, meta?: TaskDeletedMeta): Promise<void> {
|
|
/* FNXC:CrossProcessDeleteObservation 2026-08-01-13:03: Split notes are writer-owned and must not repeat on at-least-once observed delivery. */
|
|
if (meta?.observed) return;
|
|
try {
|
|
const result = await postGitLabSplitNoteBeforeClose(store, task, meta);
|
|
if (result.status === "posted") {
|
|
await updateGitLabTargetState(store, task.id, result.target, "closed", "split-close");
|
|
} else if (result.status === "failed") {
|
|
await safeLogGitLabEntry(store, task.id, "Failed to post GitLab split-close note", result.error instanceof Error ? result.error.message : String(result.error));
|
|
}
|
|
} catch (error) {
|
|
await safeLogGitLabEntry(store, task.id, "Failed to process GitLab split-close", error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
}
|