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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ─────────
|
||||
|
||||
Reference in New Issue
Block a user