feat(FN-3882): document clearStaleBlockedBy() in SelfHealingManager section

Added a single line documenting `clearStaleBlockedBy()` in the SelfHealingManager section of the architecture docs.

Fusion-Task-Id: FN-3882
This commit is contained in:
Fusion
2026-05-09 14:46:23 -07:00
committed by gsxdsm
parent 15e43360ef
commit 76e6eedec0
14 changed files with 163 additions and 99 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Stop overwriting canonical merge commit SHAs on already-done tasks during self-healing reconciliation. Confirmed `mergeDetails.commitSha` is now preserved as authoritative; rediscovery for unconfirmed done tasks prefers the earliest owned commit so the original merge commit wins over later follow-up commits sharing the same `Fusion-Task-Id` trailer.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Add global and project settings for GitHub issue tracking: global default tracking repo, project-level default tracking repo, per-project tracking toggle for new tasks, GitHub auth mode (`gh-cli` | `token`), and optional stored personal access token. This is foundational settings work for FN-3868 → FN-3876; behavior wiring ships in downstream subtasks.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Triage: progressively compact large optional sections (subtask guidance, attachments, existing spec, user comments) of the spec prompt when the model's context window overflows, in addition to the existing project-memory compaction. Fixes failures on small-context models such as local vLLM Qwen3-30B (issue Runfusion/Fusion#62, FN-3877).

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Add a compatibility self-heal for legacy task databases that report `schemaVersion >= 20` but are missing checkout lease columns (`checkedOutBy`, `checkedOutAt`, `checkoutNodeId`, `checkoutRunId`, `checkoutLeaseRenewedAt`, `checkoutLeaseEpoch`).
On initialization, missing lease columns are now added idempotently before version-guarded migrations, matching the earlier `nodeId` mitigation pattern and preventing `no such column: checkoutNodeId` crashes in task listing paths.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Add a multi-agent report review panel flow to the bundled reports plugin, including parallel reviewer orchestration, structured feedback parsing with retry, deterministic aggregation, and documented timeout/failure semantics.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Scheduler: exclude paused in-review tasks from `activeScopes`. Paused failed-merge tasks no longer block dispatch of overlapping todo tasks via `blockedBy` re-stamping. (FN-3867)

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Add `recoverAlreadyMergedReviewTasks()` self-healing sweep to recover phantom-merge-guard false positives. Detects tasks whose content already landed on the integration branch (via Fusion-Task-Id trailer, branch ancestry, or git patch-id walk) and reconciles them to `done` with proper merge metadata.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Restore canonical mergeDetails.commitSha for tasks FN-3794, FN-3814, FN-3829 whose attribution had been overwritten by self-healing reconciliation prior to the FN-3862 fix. Adds an idempotent restoration script (`scripts/restore-merge-sha-fn-3878.mjs`) for operators to re-verify or repair similar drift.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Wire chat rooms UI to backend. Creating a room now persists via /api/chat/rooms, the sidebar lists real rooms, room threads load history and stream new messages over chat:room:* SSE events, and the FN-3807 "Coming soon" placeholder is gone.

View File

@@ -158,15 +158,24 @@ describe("CLI bundle output", () => {
expect(existsSync(join(stagedRoot, "src", "process-manager.ts"))).toBe(true);
});
it("dist/plugins/fusion-plugin-dependency-graph/ is staged with a valid manifest", () => {
it("dist/plugins/fusion-plugin-dependency-graph/ is staged as bundled runtime output", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-dependency-graph");
const manifestPath = join(stagedRoot, "manifest.json");
const packageJsonPath = join(stagedRoot, "package.json");
expect(existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
expect(manifest.id).toBe("fusion-plugin-dependency-graph");
expect(typeof manifest.name).toBe("string");
expect(manifest.name?.length).toBeGreaterThan(0);
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
expect(existsSync(join(stagedRoot, "src"))).toBe(false);
const stagedPkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
exports?: { "."?: { import?: string } };
};
expect(stagedPkg.exports?.["."]?.import).toBe("./bundled.js");
});
it("dist/plugins/fusion-plugin-whatsapp-chat/ is staged with a valid manifest", () => {

View File

@@ -98,17 +98,20 @@ describe("CLI package.json publishing config", () => {
);
function extractStringArray(name: string): string[] {
const m = tsupRaw.match(new RegExp(`${name}:\\s*\\[([\\s\\S]*?)\\]`, "m"));
if (!m) return [];
return [...m[1].matchAll(/["']([^"']+)["']/g)].map((mm) => mm[1]);
const matches = [...tsupRaw.matchAll(new RegExp(`${name}:\\s*\\[([\\s\\S]*?)\\]`, "gm"))];
const values = matches.flatMap((m) =>
[...m[1].matchAll(/["']([^"']+)["']/g)].map((mm) => mm[1]),
);
return [...new Set(values)];
}
function extractRegexes(name: string): RegExp[] {
const m = tsupRaw.match(new RegExp(`${name}:\\s*\\[([\\s\\S]*?)\\]`, "m"));
if (!m) return [];
const matches = [...tsupRaw.matchAll(new RegExp(`${name}:\\s*\\[([\\s\\S]*?)\\]`, "gm"))];
// Match `/PATTERN/flags` where PATTERN may contain escaped slashes (`\/`).
return [...m[1].matchAll(/\/((?:\\\/|[^/\n])+)\/[gimsuy]*/g)].map(
(mm) => new RegExp(mm[1].replace(/\\\//g, "/")),
return matches.flatMap((m) =>
[...m[1].matchAll(/\/((?:\\\/|[^/\n])+)\/[gimsuy]*/g)].map(
(mm) => new RegExp(mm[1].replace(/\\\//g, "/")),
),
);
}
@@ -125,6 +128,8 @@ describe("CLI package.json publishing config", () => {
"cpu-features": "transitive dep of dockerode (via ssh2)",
"@homebridge/node-pty-prebuilt-multiarch":
"aliased as node-pty in dependencies; the alias entry satisfies the import",
"@fusion/core": "plugin-entry bundling external only; not a runtime dep of the CLI bin",
"@fusion/engine": "plugin-entry bundling external only; not a runtime dep of the CLI bin",
};
it("parses externals from tsup.config.ts", () => {

View File

@@ -195,9 +195,9 @@ beforeEach(() => {
});
describe("resolvePluginEntryPath", () => {
it("prefers src/index.ts over bundled.js when both exist in workspace contexts", () => {
it("prefers bundled.js when both bundled and source entries exist", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/bundled.js"));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/bundled.js");
});
it("prefers bundled.js when source entry is unavailable", () => {
@@ -205,14 +205,14 @@ describe("resolvePluginEntryPath", () => {
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/bundled.js");
});
it("prefers src/index.ts over dist/index.js in workspace contexts", () => {
it("prefers dist/index.js when bundled.js is unavailable", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/dist/index.js");
});
it("falls back to dist/index.js when source entry is unavailable", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/dist/index.js"));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/dist/index.js");
it("falls back to src/index.ts for workspace-dev plugins without build outputs", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts"));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
});
});
@@ -412,10 +412,11 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
);
});
it("registers Hermes from source entry when both src and dist entries exist", async () => {
it("registers Hermes from bundled.js when bundled, src, and dist entries all exist", async () => {
const manifest = makeManifest({ id: HERMES_PLUGIN_ID, name: "Hermes Runtime" });
mockExistsSync.mockImplementation((p: string) => {
if (p.endsWith("manifest.json") && p.includes(HERMES_PLUGIN_ID)) return true;
if (p.endsWith("/bundled.js") && p.includes(HERMES_PLUGIN_ID)) return true;
if (p.endsWith("/src/index.ts") && p.includes(HERMES_PLUGIN_ID)) return true;
if (p.endsWith("/dist/index.js") && p.includes(HERMES_PLUGIN_ID)) return true;
return false;
@@ -434,6 +435,6 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
expect(result).toBe("installed");
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
expect(registerCall.path).toContain(`${HERMES_PLUGIN_ID}/src/index.ts`);
expect(registerCall.path).toContain(`${HERMES_PLUGIN_ID}/bundled.js`);
});
});

View File

@@ -64,16 +64,16 @@ function resolveBundledPluginDir(pluginId: string): string | null {
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
* does not allow directory imports, so we must register the explicit file the
* loader will dynamic-import. Preference order:
* 1. ./src/index.ts (workspace/dev source of truth)
* 2. ./bundled.js (esbuild-bundled, ships in npm tarball)
* 3. ./dist/index.js
* 1. ./bundled.js (esbuild-bundled, shipped in npm tarball)
* 2. ./dist/index.js (legacy prebuilt fallback)
* 3. ./src/index.ts (workspace/dev fallback when no bundle exists)
* 4. fall back to the directory itself
*/
export function resolvePluginEntryPath(pluginDir: string): string {
const candidates = [
join(pluginDir, "src", "index.ts"),
join(pluginDir, "bundled.js"),
join(pluginDir, "dist", "index.js"),
join(pluginDir, "src", "index.ts"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {

View File

@@ -54,6 +54,76 @@ const dashboardClientStub = `<!doctype html>
</html>
`;
type BundlePluginEntryOptions = {
pluginId: string;
srcDir: string;
destDir: string;
withMcpAsset?: boolean;
};
async function bundlePluginEntry({ pluginId, srcDir, destDir, withMcpAsset = false }: BundlePluginEntryOptions) {
if (existsSync(destDir)) {
rmSync(destDir, { recursive: true, force: true });
}
if (!existsSync(srcDir)) {
console.warn(
`WARNING: Plugin source not found at ${srcDir}; ${pluginId} will be unavailable in the published package.`,
);
return;
}
mkdirSync(destDir, { recursive: true });
cpSync(join(srcDir, "manifest.json"), join(destDir, "manifest.json"));
const srcPkg = JSON.parse(readFileSync(join(srcDir, "package.json"), "utf-8"));
const destPkg = {
name: srcPkg.name,
version: srcPkg.version,
type: "module",
exports: { ".": { import: "./bundled.js" } },
private: true,
};
writeFileSync(join(destDir, "package.json"), JSON.stringify(destPkg, null, 2));
const srcEntry = join(srcDir, "src", "index.ts");
const builtEntry = join(srcDir, "dist", "index.js");
const entry = existsSync(srcEntry) ? srcEntry : builtEntry;
if (!existsSync(entry)) {
throw new Error(`No entry found for ${pluginId} (looked for src/index.ts and dist/index.js)`);
}
await esbuildBuild({
entryPoints: [entry],
bundle: true,
format: "esm",
platform: "node",
target: "node22",
outfile: join(destDir, "bundled.js"),
external: ["@fusion/core", "@fusion/engine"],
alias: {
"@fusion/plugin-sdk": join(__dirname, "..", "plugin-sdk", "src", "index.ts"),
},
logLevel: "warning",
});
if (withMcpAsset) {
const mcpServerAsset = join(srcDir, "src", "mcp-schema-server.cjs");
if (!existsSync(mcpServerAsset)) {
throw new Error(
`[tsup] Missing required bridge asset for ${pluginId} at ${mcpServerAsset}; expected committed source file mcp-schema-server.cjs.`,
);
}
cpSync(mcpServerAsset, join(destDir, "mcp-schema-server.cjs"));
}
const bundledOutput = join(destDir, "bundled.js");
if (!existsSync(bundledOutput)) {
throw new Error(`[tsup] Missing bundled output for ${pluginId}: expected ${bundledOutput}`);
}
console.log(`Bundled plugin ${pluginId} to dist/plugins/${pluginId}/bundled.js`);
}
export default defineConfig({
entry: ["src/bin.ts", "src/extension.ts"],
format: ["esm"],
@@ -137,20 +207,11 @@ export default defineConfig({
);
}
if (existsSync(dependencyGraphPluginDest)) {
rmSync(dependencyGraphPluginDest, { recursive: true, force: true });
}
if (existsSync(dependencyGraphPluginSrc)) {
mkdirSync(dependencyGraphPluginDest, { recursive: true });
cpSync(join(dependencyGraphPluginSrc, "manifest.json"), join(dependencyGraphPluginDest, "manifest.json"));
cpSync(join(dependencyGraphPluginSrc, "package.json"), join(dependencyGraphPluginDest, "package.json"));
cpSync(join(dependencyGraphPluginSrc, "src"), join(dependencyGraphPluginDest, "src"), { recursive: true });
console.log("Copied dependency graph plugin to dist/plugins/fusion-plugin-dependency-graph/");
} else {
console.warn(
`WARNING: Dependency graph plugin source not found at ${dependencyGraphPluginSrc}; bundled auto-install will be unavailable.`,
);
}
await bundlePluginEntry({
pluginId: "fusion-plugin-dependency-graph",
srcDir: dependencyGraphPluginSrc,
destDir: dependencyGraphPluginDest,
});
if (existsSync(whatsappChatPluginDest)) {
rmSync(whatsappChatPluginDest, { recursive: true, force: true });
@@ -200,71 +261,12 @@ export default defineConfig({
// Bundle each runtime plugin into a self-contained ESM file so npm/npx
// installs can load them without the workspace `@fusion/plugin-sdk`.
for (const pluginId of RUNTIME_PLUGIN_IDS) {
const pluginSrcDir = join(__dirname, "..", "..", "plugins", pluginId);
const pluginDestDir = join(__dirname, "dist", "plugins", pluginId);
if (existsSync(pluginDestDir)) {
rmSync(pluginDestDir, { recursive: true, force: true });
}
if (!existsSync(pluginSrcDir)) {
console.warn(
`WARNING: Runtime plugin source not found at ${pluginSrcDir}; ${pluginId} will be unavailable in the published package.`,
);
continue;
}
mkdirSync(pluginDestDir, { recursive: true });
cpSync(join(pluginSrcDir, "manifest.json"), join(pluginDestDir, "manifest.json"));
// Stripped package.json: no dependencies (workspace SDK is inlined into
// bundled.js), exports point at the bundle. Keeps the published tarball
// self-contained without leaking workspace-only metadata.
const srcPkg = JSON.parse(readFileSync(join(pluginSrcDir, "package.json"), "utf-8"));
const destPkg = {
name: srcPkg.name,
version: srcPkg.version,
type: "module",
exports: { ".": { import: "./bundled.js" } },
private: true,
};
writeFileSync(join(pluginDestDir, "package.json"), JSON.stringify(destPkg, null, 2));
// Pick the best available entry: built dist/index.js if present, else
// raw src/index.ts (esbuild can transpile TS).
const builtEntry = join(pluginSrcDir, "dist", "index.js");
const srcEntry = join(pluginSrcDir, "src", "index.ts");
const entry = existsSync(builtEntry) ? builtEntry : srcEntry;
if (!existsSync(entry)) {
console.warn(`WARNING: No entry found for ${pluginId} (looked for dist/index.js and src/index.ts)`);
continue;
}
await esbuildBuild({
entryPoints: [entry],
bundle: true,
format: "esm",
platform: "node",
target: "node22",
outfile: join(pluginDestDir, "bundled.js"),
// @fusion/core and @fusion/engine are loaded by the host process at
// runtime; the SDK is inlined.
external: ["@fusion/core", "@fusion/engine"],
logLevel: "warning",
await bundlePluginEntry({
pluginId,
srcDir: join(__dirname, "..", "..", "plugins", pluginId),
destDir: join(__dirname, "dist", "plugins", pluginId),
withMcpAsset: RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER.has(pluginId),
});
if (RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER.has(pluginId)) {
const mcpServerAsset = join(pluginSrcDir, "src", "mcp-schema-server.cjs");
if (!existsSync(mcpServerAsset)) {
throw new Error(
`[tsup] Missing required bridge asset for ${pluginId} at ${mcpServerAsset}; expected committed source file mcp-schema-server.cjs.`,
);
}
cpSync(mcpServerAsset, join(pluginDestDir, "mcp-schema-server.cjs"));
}
console.log(`Bundled runtime plugin ${pluginId} to dist/plugins/${pluginId}/bundled.js`);
}
if (existsSync(dashboardClientDest)) {