feat(FN-2920): improve remote tunnel setup and heartbeat scheduling

- Add cloudflared install/detection support in remote settings API, UI, and route tests
- Surface Cloudflare tunnel prerequisites in Settings modal with remote access docs updates
- Harden heartbeat runtime scheduling by avoiding stale timeout state and simplifying runtime timeout handling
- Expand CLI/core/dashboard/engine coverage for task lifecycle, agent health, and runtime heartbeat behavior
- Add changesets for heartbeat scheduling fixes and PR approval setting updates

Fusion-Task-Id: FN-2920
This commit is contained in:
Fusion
2026-04-29 13:49:05 -07:00
committed by gsxdsm
parent b91533ce43
commit 17a072c924
25 changed files with 819 additions and 205 deletions

View File

@@ -12,7 +12,6 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AgentStore } from "../agent-store.js";
import { Database } from "../db.js";
import { TaskStore } from "../store.js";
import { rm } from "node:fs/promises";
import { join } from "node:path";
@@ -104,10 +103,7 @@ describe("AgentStore", () => {
}
});
it("normalizes legacy durable agents to heartbeat enabled once", async () => {
// Migration test: opens a raw Database on disk to seed a meta key,
// then re-opens the AgentStore to assert migration ran. Needs both
// the store and the raw DB to be disk-backed.
it("preserves disabled heartbeat config for durable agents across restart", async () => {
store.close();
store = new AgentStore({ rootDir });
await store.init();
@@ -124,20 +120,12 @@ describe("AgentStore", () => {
},
});
const db = new Database(rootDir);
db.init();
db.prepare(`
INSERT INTO __meta (key, value)
VALUES ('agentHeartbeatDefaultVersion', '0')
ON CONFLICT(key) DO UPDATE SET value = '0'
`).run();
store.close();
store = new AgentStore({ rootDir });
await store.init();
const migrated = await store.getAgent(agent.id);
expect((migrated?.runtimeConfig as Record<string, unknown> | undefined)?.enabled).toBe(true);
const persisted = await store.getAgent(agent.id);
expect((persisted?.runtimeConfig as Record<string, unknown> | undefined)?.enabled).toBe(false);
});
});

View File

@@ -213,7 +213,6 @@ export class AgentStore extends EventEmitter {
const _ = this.db;
await mkdir(this.agentsDir, { recursive: true });
await this.importLegacyFileDataOnce();
await this.normalizeHeartbeatDefaultsOnce();
}
/**
@@ -345,60 +344,6 @@ export class AgentStore extends EventEmitter {
this.db.bumpLastModified();
}
/**
* One-time normalization for durable agents created before the heartbeat
* toggle was exposed in the UI. Those agents could persist
* `runtimeConfig.enabled = false` even though users had no supported way to
* manage that flag, which caused timers to stay disabled after restart.
*
* We normalize only once per project. After this migration lands, explicit
* user choices are preserved because the version gate prevents reruns.
*/
private async normalizeHeartbeatDefaultsOnce(): Promise<void> {
const migrationKey = "agentHeartbeatDefaultVersion";
const migrationVersion = "1";
const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as
| { value: string }
| undefined;
if (row?.value === migrationVersion) {
return;
}
const agents = await this.listAgents({ includeEphemeral: true });
let changed = 0;
for (const agent of agents) {
if (isEphemeralAgent(agent)) {
continue;
}
const nextRuntimeConfig = {
...(resolveCreationRuntimeConfig(agent.runtimeConfig, agent.metadata) ?? {}),
enabled: true,
};
const currentRuntimeConfig = agent.runtimeConfig ?? undefined;
if (JSON.stringify(nextRuntimeConfig) === JSON.stringify(currentRuntimeConfig)) {
continue;
}
await this.writeAgent({
...agent,
runtimeConfig: nextRuntimeConfig,
});
changed++;
}
this.db.prepare(`
INSERT INTO __meta (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`).run(migrationKey, migrationVersion);
if (changed > 0) {
this.db.bumpLastModified();
}
}
/**
* Create a new agent with "idle" state.
*

View File

@@ -80,6 +80,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
overlapIgnorePaths: [],
autoMerge: true,
mergeStrategy: "direct",
requirePrApproval: false,
pushAfterMerge: false,
pushRemote: "origin",
unavailableNodePolicy: "block",

View File

@@ -1471,6 +1471,12 @@ export interface ProjectSettings {
* before merging through GitHub
* Default: "direct" for backward compatibility. */
mergeStrategy?: MergeStrategy;
/** When true, only auto-merge a pull request after it has at least one approving
* review (`reviewDecision === "APPROVED"`). Independent of GitHub's branch-protection
* `required` flag, so this works on free private repos where required reviewers can't
* be enforced server-side. Only applies when `mergeStrategy === "pull-request"`.
* Default: false. */
requirePrApproval?: boolean;
/** When true, automatically push to the configured remote after a successful direct merge.
* The push process includes pulling the latest from the remote (rebase) first.
* If conflicts arise during the pull, they are resolved using the AI conflict resolution pipeline.