perf(executor): recover approved steps on engine restart

When the engine restarts mid-step, an in-progress step may have already
passed plan + code review but not yet been flipped to done by the agent's
next task_update call. Previously, the next executor pass re-entered the
step and replayed both reviews — measured at 5-20 min of pure waste per
restart (observed in FN-2215 Step 1 and FN-2207 Step 6).

recoverApprovedStepsOnResume scans the task log for any in-progress step
whose most recent "code review Step N: APPROVE" entry is newer than its
most recent "Step N → pending" transition, and marks those steps done
before execute() runs. Safely skips steps that were reset after approval
(e.g. by a workflow revision) or only received REVISE verdicts.

Called from both the engine-restart path (resumeOrphaned) and the
unpause path, matching the two places the task log shows as vulnerable
to this race.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-21 09:02:42 -07:00
committed by gsxdsm
parent 32c551c5f4
commit 4e85969d56
33 changed files with 414 additions and 90 deletions

View File

@@ -88,7 +88,7 @@ Fusion also works as a standalone CLI outside of pi. See [STANDALONE.md](./STAND
## Full documentation
For architecture details, development setup, and contributor info, see the [project README](https://github.com/gsxdsm/fusion#readme).
For architecture details, development setup, and contributor info, see the [project README](https://github.com/Runfusion/Fusion#readme).
## License

View File

@@ -15,7 +15,7 @@
"skills": [
"./skill"
],
"image": "https://raw.githubusercontent.com/gsxdsm/fusion/main/demo/screenshot.png"
"image": "https://raw.githubusercontent.com/Runfusion/Fusion/main/demo/screenshot.png"
},
"publishConfig": {
"access": "public"
@@ -75,6 +75,6 @@
},
"repository": {
"type": "git",
"url": "https://github.com/gsxdsm/fusion"
"url": "https://github.com/Runfusion/Fusion"
}
}

View File

@@ -127,14 +127,15 @@ describe("build-exe-cross: --all builds all platforms", () => {
const result = spawnSync(bin, ["--help"], {
encoding: "utf-8",
timeout: 15_000,
// CI can occasionally be slow to launch freshly built native binaries.
timeout: 45_000,
});
if (hasKnownBunSqliteLimitation(result)) {
return;
}
expect(result.status).toBe(0);
expect(result.stdout).toContain("fn");
}, 20_000);
}, 60_000);
});
describe("build-exe-cross: default (no args) backward compatibility", () => {

View File

@@ -268,7 +268,7 @@ Usage:
Options:
--project, -P <name> Target a specific project (bypasses CWD detection)
--port, -p <port> Dashboard/serve port (default: 4040)
--host <host> Serve host (default: 0.0.0.0)
--host <host> Serve host (default: 127.0.0.1 — localhost only; pass 0.0.0.0 to expose)
--interactive Interactive mode (port selection for dashboard, issue selection for import)
--paused Start with engine paused (automation disabled)
--dev Start dashboard only (no AI engine)
@@ -504,7 +504,9 @@ async function main() {
const paused = args.includes("--paused");
const dev = args.includes("--dev");
const interactive = args.includes("--interactive");
await runDashboard(port, { paused, dev, interactive });
const dashHostIdx = args.indexOf("--host");
const host = dashHostIdx !== -1 && dashHostIdx + 1 < args.length ? args[dashHostIdx + 1] : undefined;
await runDashboard(port, { paused, dev, interactive, host });
break;
}

View File

@@ -670,13 +670,21 @@ describe("runDaemon", () => {
await triggerSignal("SIGINT");
});
it("prints banner with full token at startup", async () => {
it("prints banner with masked token at startup (full token never hits stdout)", async () => {
const providedToken = "fn_fulltoken12345678901234567890";
await runDaemon({ token: providedToken });
// Banner should contain the full token
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(providedToken));
// Banner should contain a MASKED form, not the raw token. The full token
// is persisted to ~/.fusion/settings.json (chmod 0600) and retrievable via
// `fn daemon --token-only` — printing it here would leak it to terminal
// scrollback and CI logs.
const allBannerArgs = logSpy.mock.calls.map((args) => String(args[0] ?? ""));
const banner = allBannerArgs.join("\n");
expect(banner).not.toContain(providedToken);
expect(banner).toContain("fn_ful");
expect(banner).toContain("7890");
expect(banner).toContain("fn daemon --token-only");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Fusion Daemon"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("bearer token required"));
@@ -701,7 +709,7 @@ describe("runDaemon", () => {
expect(mocks.listenCalls[0]).toMatchObject({
port: 0,
host: "0.0.0.0",
host: "127.0.0.1",
});
await triggerSignal("SIGINT");

View File

@@ -729,18 +729,18 @@ describe("runServe", () => {
expect(mocks.taskStores[0].close).toHaveBeenCalledTimes(1);
});
it("listens on 0.0.0.0 by default and respects a custom host", async () => {
it("listens on 127.0.0.1 by default and respects a custom host", async () => {
await runServe(3010, {});
expect(mocks.listenCalls[0]).toMatchObject({
port: 3010,
host: "0.0.0.0",
host: "127.0.0.1",
});
await triggerSignal("SIGINT");
await runServe(3020, { host: "127.0.0.1" });
await runServe(3020, { host: "0.0.0.0" });
expect(mocks.listenCalls[1]).toMatchObject({
port: 3020,
host: "127.0.0.1",
host: "0.0.0.0",
});
await triggerSignal("SIGINT");
});

View File

@@ -128,7 +128,8 @@ function maskToken(token: string): string {
export interface DaemonOptions {
/** Port to listen on (default: 0 for random port) */
port?: number;
/** Host to bind to (default: 0.0.0.0) */
/** Host to bind to (default: 127.0.0.1 — localhost only). Pass "0.0.0.0" to
* expose on all interfaces. */
host?: string;
/** Specific token to use (generated if not provided) */
token?: string;
@@ -207,7 +208,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
}
const selectedHost = opts.host ?? "0.0.0.0";
const selectedHost = opts.host ?? "127.0.0.1";
const cwd = await resolveRuntimeProjectPath();
// ── CentralCore: global coordination + ntfy project ID lookup ─────────
@@ -466,13 +467,16 @@ export async function runDaemon(opts: DaemonOptions = {}) {
console.warn(`[daemon] Failed to set local node online: ${message}`);
}
// Print startup banner with full token (shown once at startup)
// Print startup banner with a masked token. The full token is persisted in
// global settings (~/.fusion/settings.json, chmod 0600) and can be retrieved
// with `fn daemon --token-only` — printing it here would write the raw
// secret to terminal scrollback, CI logs, and screen-capture tools.
console.log();
console.log(` Fusion Daemon`);
console.log(` ────────────────────────`);
console.log(` → http://${selectedHost}:${actualPort}`);
console.log();
console.log(` Token: ${daemonToken}`);
console.log(` Token: ${maskToken(daemonToken)} (run "fn daemon --token-only" to retrieve)`);
console.log();
console.log(` Health: GET /api/health`);
console.log(` API: /api/* (bearer token required)`);

View File

@@ -1516,8 +1516,8 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
// mockListen should have been called with the requested port
expect(mockListen).toHaveBeenCalledWith(4040);
// mockListen should have been called with the requested port bound to localhost by default.
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
// Banner should show the requested port
expect(consoleSpy).toHaveBeenCalledWith(
@@ -1562,8 +1562,8 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
// Wait for async events to settle
await new Promise((r) => setTimeout(r, 100));
// Server should have retried with port 0
expect(mockServerListen).toHaveBeenCalledWith(0);
// Server should have retried with port 0, still bound to localhost.
expect(mockServerListen).toHaveBeenCalledWith(0, "127.0.0.1");
// Banner should show the fallback port, not the requested port
expect(consoleSpy).toHaveBeenCalledWith(
@@ -1888,9 +1888,9 @@ describe("runDashboard — --dev mode", () => {
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
// Server should have been created and listen called
// Server should have been created and listen called (localhost default)
expect(createServer).toHaveBeenCalled();
expect(mockListen).toHaveBeenCalledWith(4040);
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
// Banner should show the port
expect(consoleSpy).toHaveBeenCalledWith(

View File

@@ -193,7 +193,10 @@ async function resolveRuntimeProjectPath(): Promise<string> {
}
}
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean } = {}) {
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean; host?: string } = {}) {
// Default to localhost so the dashboard (and its shell-capable terminal API)
// is not exposed on the LAN. Pass --host 0.0.0.0 explicitly to opt-in.
const selectedHost = opts.host ?? "127.0.0.1";
ensureProcessDiagnostics();
// Handle interactive port selection
@@ -859,11 +862,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
});
}
const server = app.listen(selectedPort);
const server = app.listen(selectedPort, selectedHost);
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
server.listen(0);
server.listen(0, selectedHost);
} else {
console.error(`Failed to start server: ${err.message}`);
process.exit(1);

View File

@@ -218,7 +218,7 @@ export async function runServe(
}
}
const selectedHost = opts.host ?? "0.0.0.0";
const selectedHost = opts.host ?? "127.0.0.1";
const cwd = await resolveRuntimeProjectPath();
// ── CentralCore: global coordination + ntfy project ID lookup ─────────