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:
@@ -26,11 +26,12 @@ interface MockTask {
|
||||
column: string;
|
||||
}
|
||||
|
||||
function makeStore(task: MockTask) {
|
||||
function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
||||
const emitter = new EventEmitter();
|
||||
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
||||
return Object.assign(emitter, {
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }),
|
||||
updateTask: vi.fn(async (id: string, patch: Record<string, unknown>) => {
|
||||
updates.push({ id, patch });
|
||||
}),
|
||||
@@ -187,4 +188,150 @@ describe("processPullRequestMergeTask", () => {
|
||||
|
||||
expect(github.createPr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("requirePrApproval", () => {
|
||||
function makeReadyMergeStatus(reviewDecision: string | null) {
|
||||
const prInfo = {
|
||||
number: 100,
|
||||
url: "https://github.com/x/y/pull/100",
|
||||
status: "open" as const,
|
||||
headBranch: "fusion/fn-9100",
|
||||
baseBranch: "main",
|
||||
};
|
||||
// Simulate the "free private repo" case: GitHub reports no required
|
||||
// checks and no blocking review state, so isPrMergeReady returns
|
||||
// mergeReady: true. Without the gate this would auto-merge.
|
||||
return {
|
||||
prInfo,
|
||||
reviewDecision,
|
||||
checks: [],
|
||||
mergeReady: true,
|
||||
blockingReasons: [],
|
||||
};
|
||||
}
|
||||
|
||||
it("holds the merge when requirePrApproval is true and reviewDecision is not APPROVED", async () => {
|
||||
const task: MockTask = {
|
||||
id: "FN-9100",
|
||||
title: "test",
|
||||
description: "desc",
|
||||
column: "in-review",
|
||||
prInfo: {
|
||||
number: 100,
|
||||
url: "https://github.com/x/y/pull/100",
|
||||
status: "open",
|
||||
headBranch: "fusion/fn-9100",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
const store = makeStore(task, { requirePrApproval: true });
|
||||
|
||||
const github = {
|
||||
findPrForBranch: vi.fn(),
|
||||
createPr: vi.fn(),
|
||||
getPrMergeStatus: vi.fn(async () => makeReadyMergeStatus(null)),
|
||||
mergePr: vi.fn(),
|
||||
};
|
||||
|
||||
const result = await processPullRequestMergeTask(
|
||||
store as never,
|
||||
"/repo",
|
||||
task.id,
|
||||
github as never,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
expect(result).toBe("waiting");
|
||||
expect(github.mergePr).not.toHaveBeenCalled();
|
||||
const lastUpdate = (store as { _updates: Array<{ patch: Record<string, unknown> }> })._updates.at(-1);
|
||||
expect(lastUpdate?.patch).toEqual({ status: "awaiting-pr-checks" });
|
||||
});
|
||||
|
||||
it("merges when requirePrApproval is true and reviewDecision is APPROVED", async () => {
|
||||
const task: MockTask = {
|
||||
id: "FN-9101",
|
||||
title: "test",
|
||||
description: "desc",
|
||||
column: "in-review",
|
||||
prInfo: {
|
||||
number: 100,
|
||||
url: "https://github.com/x/y/pull/100",
|
||||
status: "open",
|
||||
headBranch: "fusion/fn-9101",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
const store = makeStore(task, { requirePrApproval: true });
|
||||
|
||||
const merged = {
|
||||
number: 100,
|
||||
url: "https://github.com/x/y/pull/100",
|
||||
status: "merged" as const,
|
||||
headBranch: "fusion/fn-9101",
|
||||
baseBranch: "main",
|
||||
};
|
||||
const github = {
|
||||
findPrForBranch: vi.fn(),
|
||||
createPr: vi.fn(),
|
||||
getPrMergeStatus: vi.fn(async () => makeReadyMergeStatus("APPROVED")),
|
||||
mergePr: vi.fn(async () => merged),
|
||||
};
|
||||
|
||||
const result = await processPullRequestMergeTask(
|
||||
store as never,
|
||||
"/repo",
|
||||
task.id,
|
||||
github as never,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
expect(result).toBe("merged");
|
||||
expect(github.mergePr).toHaveBeenCalledWith({ number: 100, method: "squash" });
|
||||
});
|
||||
|
||||
it("preserves existing behavior when requirePrApproval is false", async () => {
|
||||
const task: MockTask = {
|
||||
id: "FN-9102",
|
||||
title: "test",
|
||||
description: "desc",
|
||||
column: "in-review",
|
||||
prInfo: {
|
||||
number: 100,
|
||||
url: "https://github.com/x/y/pull/100",
|
||||
status: "open",
|
||||
headBranch: "fusion/fn-9102",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
const store = makeStore(task, { requirePrApproval: false });
|
||||
|
||||
const merged = {
|
||||
number: 100,
|
||||
url: "https://github.com/x/y/pull/100",
|
||||
status: "merged" as const,
|
||||
headBranch: "fusion/fn-9102",
|
||||
baseBranch: "main",
|
||||
};
|
||||
const github = {
|
||||
findPrForBranch: vi.fn(),
|
||||
createPr: vi.fn(),
|
||||
// reviewDecision: null but mergeReady: true — without the gate,
|
||||
// this should still merge (the buggy default that #21's reviewer
|
||||
// flagged as too aggressive on free private repos).
|
||||
getPrMergeStatus: vi.fn(async () => makeReadyMergeStatus(null)),
|
||||
mergePr: vi.fn(async () => merged),
|
||||
};
|
||||
|
||||
const result = await processPullRequestMergeTask(
|
||||
store as never,
|
||||
"/repo",
|
||||
task.id,
|
||||
github as never,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
expect(result).toBe("merged");
|
||||
expect(github.mergePr).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,6 +230,18 @@ export async function processPullRequestMergeTask(
|
||||
return "merged";
|
||||
}
|
||||
|
||||
// Optional approval gate. GitHub's `required: true` flag for checks only
|
||||
// flows from branch protection (Pro feature on private repos), so on free
|
||||
// private repos every fresh PR is "merge ready" and would auto-squash
|
||||
// immediately. `requirePrApproval` lets users keep PR mode as "open the
|
||||
// PR, wait for me to approve and merge it" by holding the merge until
|
||||
// reviewDecision === "APPROVED".
|
||||
const settings = await store.getSettings();
|
||||
if (settings.requirePrApproval && mergeStatus.reviewDecision !== "APPROVED") {
|
||||
await store.updateTask(task.id, { status: "awaiting-pr-checks" });
|
||||
return "waiting";
|
||||
}
|
||||
|
||||
if (!mergeStatus.mergeReady) {
|
||||
if (mergeStatus.prInfo.status === "open") {
|
||||
await store.updateTask(task.id, { status: "awaiting-pr-checks" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -80,6 +80,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
overlapIgnorePaths: [],
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
requirePrApproval: false,
|
||||
pushAfterMerge: false,
|
||||
pushRemote: "origin",
|
||||
unavailableNodePolicy: "block",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -462,6 +462,7 @@ export interface RemoteStatus {
|
||||
url: string | null;
|
||||
lastError: string | null;
|
||||
lastErrorCode?: string | null;
|
||||
cloudflaredAvailable?: boolean | null;
|
||||
restore?: {
|
||||
outcome: "applied" | "skipped" | "failed";
|
||||
reason: string;
|
||||
@@ -489,6 +490,12 @@ export function fetchRemoteStatus(projectId?: string): Promise<RemoteStatus> {
|
||||
return api<RemoteStatus>(withProjectId("/remote/status", projectId));
|
||||
}
|
||||
|
||||
export function installCloudflared(projectId?: string): Promise<{ success: boolean; command: string; error?: string }> {
|
||||
return api(withProjectId("/remote/install-cloudflared", projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function activateRemoteProvider(provider: "tailscale" | "cloudflare", projectId?: string): Promise<{ activeProvider: "tailscale" | "cloudflare" }> {
|
||||
return api<{ activeProvider: "tailscale" | "cloudflare" }>(withProjectId("/remote/provider/activate", projectId), {
|
||||
method: "POST",
|
||||
|
||||
@@ -615,6 +615,44 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.remote-cli-detection {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
margin: 0 var(--space-xl) var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.remote-cli-detection--available {
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.remote-cli-detection--missing {
|
||||
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.remote-cli-detection-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.remote-cli-detection .btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.remote-cli-install-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.remote-cli-manual {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.remote-advanced-details {
|
||||
margin: var(--space-lg) var(--space-xl) 0;
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
@@ -791,6 +829,10 @@
|
||||
.remote-tunnel-actions .btn {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.remote-cli-detection {
|
||||
margin: 0 var(--space-lg) var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
/* === Notifications Settings === */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEvent } from "react";
|
||||
import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2 } from "lucide-react";
|
||||
import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2, CheckCircle, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
THINKING_LEVELS,
|
||||
getErrorMessage,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
resolveTitleSummarizerSettingsModel,
|
||||
} from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
|
||||
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
@@ -424,6 +424,8 @@ export function SettingsModal({
|
||||
// Remote access state
|
||||
const [remoteStatus, setRemoteStatus] = useState<RemoteStatus | null>(null);
|
||||
const [remoteBusyAction, setRemoteBusyAction] = useState<string | null>(null);
|
||||
const [cloudflaredInstalling, setCloudflaredInstalling] = useState(false);
|
||||
const [cloudflaredInstallError, setCloudflaredInstallError] = useState<string | null>(null);
|
||||
const [remoteAuthLinkTokenType, setRemoteAuthLinkTokenType] = useState<"persistent" | "short-lived">("persistent");
|
||||
const [remoteUrlPreview, setRemoteUrlPreview] = useState<{ url: string; expiresAt: string | null; tokenType: "persistent" | "short-lived" } | null>(null);
|
||||
const [remoteQrSvg, setRemoteQrSvg] = useState<string | null>(null);
|
||||
@@ -1538,6 +1540,35 @@ export function SettingsModal({
|
||||
}
|
||||
}, [addToast, loadRemoteData]);
|
||||
|
||||
const cloudflaredManualInstallCommand = useCallback(() => {
|
||||
if (typeof navigator !== "undefined" && navigator.userAgent.includes("Windows")) {
|
||||
return "winget install Cloudflare.cloudflared";
|
||||
}
|
||||
if (typeof navigator !== "undefined" && /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform)) {
|
||||
return "brew install cloudflared";
|
||||
}
|
||||
return "curl -L --output /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 && chmod +x /usr/local/bin/cloudflared";
|
||||
}, []);
|
||||
|
||||
const handleInstallCloudflared = useCallback(async () => {
|
||||
setCloudflaredInstalling(true);
|
||||
setCloudflaredInstallError(null);
|
||||
try {
|
||||
const result = await installCloudflared(projectId);
|
||||
if (!result.success) {
|
||||
setCloudflaredInstallError(result.error ?? "Installation failed");
|
||||
return;
|
||||
}
|
||||
const status = await fetchRemoteStatus(projectId);
|
||||
setRemoteStatus(status);
|
||||
addToast("cloudflared installed successfully", "success");
|
||||
} catch (err) {
|
||||
setCloudflaredInstallError(err instanceof Error ? err.message : "Installation failed");
|
||||
} finally {
|
||||
setCloudflaredInstalling(false);
|
||||
}
|
||||
}, [addToast, projectId]);
|
||||
|
||||
/** Render a scope indicator banner for the current section with theme-aware Lucide icons */
|
||||
const renderScopeBanner = () => {
|
||||
if (activeSectionScope === "global") {
|
||||
@@ -2972,6 +3003,27 @@ export function SettingsModal({
|
||||
</small>
|
||||
</details>
|
||||
</div>
|
||||
{form.mergeStrategy === "pull-request" && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="requirePrApproval" className="checkbox-label">
|
||||
<input
|
||||
id="requirePrApproval"
|
||||
type="checkbox"
|
||||
checked={form.requirePrApproval ?? false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, requirePrApproval: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Wait for an approving review before merging the PR
|
||||
</label>
|
||||
<details className="settings-option-details">
|
||||
<summary>More details</summary>
|
||||
<small>
|
||||
When enabled, Fusion holds the PR in In Review until at least one approving GitHub review has been submitted. Useful on free private repos where GitHub's required-reviewer enforcement isn't available — without this, a fresh PR with no required checks is treated as immediately mergeable.
|
||||
</small>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label htmlFor="includeTaskIdInCommit" className="checkbox-label">
|
||||
<input
|
||||
@@ -4050,6 +4102,32 @@ export function SettingsModal({
|
||||
{!activeProvider && <small>Select a provider above to configure remote access.</small>}
|
||||
</div>
|
||||
|
||||
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === true && (
|
||||
<div className="remote-cli-detection remote-cli-detection--available" role="status">
|
||||
<CheckCircle aria-hidden="true" />
|
||||
<span>cloudflared is installed</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false && (
|
||||
<div className="remote-cli-detection remote-cli-detection--missing" role="status">
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<div className="remote-cli-detection-content">
|
||||
<span>cloudflared is not installed</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={cloudflaredInstalling || remoteBusyAction !== null}
|
||||
onClick={() => void handleInstallCloudflared()}
|
||||
>
|
||||
{cloudflaredInstalling ? "Installing…" : "Install cloudflared"}
|
||||
</button>
|
||||
{cloudflaredInstallError && <small className="remote-cli-install-error">{cloudflaredInstallError}</small>}
|
||||
<small className="remote-cli-manual">Manual install: <code>{cloudflaredManualInstallCommand()}</code></small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeProvider && (
|
||||
<div className="form-group remote-provider-settings">
|
||||
{activeProvider === "tailscale" ? (
|
||||
@@ -4108,32 +4186,37 @@ export function SettingsModal({
|
||||
{remoteBusyAction === "stop" ? "Stopping…" : "Stop Tunnel"}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("start", async () => {
|
||||
const formState = form as Record<string, unknown>;
|
||||
const savePayload: Partial<RemoteSettings> = {
|
||||
remoteActiveProvider: activeProvider,
|
||||
remoteTailscaleEnabled: activeProvider === "tailscale",
|
||||
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
|
||||
// Server overrides this with req.socket.localPort
|
||||
// when starting the tunnel; the value sent here is
|
||||
// only a fallback if that override doesn't fire.
|
||||
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
|
||||
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
|
||||
remoteCloudflareEnabled: activeProvider === "cloudflare",
|
||||
remoteCloudflareQuickTunnel: Boolean(formState.remoteCloudflareQuickTunnel ?? true),
|
||||
remoteCloudflareTunnelName: String(formState.remoteCloudflareTunnelName ?? ""),
|
||||
remoteCloudflareTunnelToken: (formState.remoteCloudflareTunnelToken as string | null) || null,
|
||||
remoteCloudflareIngressUrl: String(formState.remoteCloudflareIngressUrl ?? ""),
|
||||
remoteShortLivedEnabled: Boolean(formState.remoteShortLivedEnabled),
|
||||
remoteShortLivedTtlMs: Number(formState.remoteShortLivedTtlMs ?? 900000),
|
||||
remoteRememberLastRunning: Boolean(formState.remoteRememberLastRunning),
|
||||
};
|
||||
await updateRemoteSettings(savePayload, projectId);
|
||||
await startRemoteTunnel(projectId);
|
||||
addToast("Remote tunnel started", "success");
|
||||
})}>
|
||||
{remoteBusyAction === "start" ? "Starting…" : "Start Tunnel"}
|
||||
</button>
|
||||
<>
|
||||
<button type="button" className="btn btn-primary" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("start", async () => {
|
||||
const formState = form as Record<string, unknown>;
|
||||
const savePayload: Partial<RemoteSettings> = {
|
||||
remoteActiveProvider: activeProvider,
|
||||
remoteTailscaleEnabled: activeProvider === "tailscale",
|
||||
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
|
||||
// Server overrides this with req.socket.localPort
|
||||
// when starting the tunnel; the value sent here is
|
||||
// only a fallback if that override doesn't fire.
|
||||
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
|
||||
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
|
||||
remoteCloudflareEnabled: activeProvider === "cloudflare",
|
||||
remoteCloudflareQuickTunnel: Boolean(formState.remoteCloudflareQuickTunnel ?? true),
|
||||
remoteCloudflareTunnelName: String(formState.remoteCloudflareTunnelName ?? ""),
|
||||
remoteCloudflareTunnelToken: (formState.remoteCloudflareTunnelToken as string | null) || null,
|
||||
remoteCloudflareIngressUrl: String(formState.remoteCloudflareIngressUrl ?? ""),
|
||||
remoteShortLivedEnabled: Boolean(formState.remoteShortLivedEnabled),
|
||||
remoteShortLivedTtlMs: Number(formState.remoteShortLivedTtlMs ?? 900000),
|
||||
remoteRememberLastRunning: Boolean(formState.remoteRememberLastRunning),
|
||||
};
|
||||
await updateRemoteSettings(savePayload, projectId);
|
||||
await startRemoteTunnel(projectId);
|
||||
addToast("Remote tunnel started", "success");
|
||||
})}>
|
||||
{remoteBusyAction === "start" ? "Starting…" : "Start Tunnel"}
|
||||
</button>
|
||||
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false ? (
|
||||
<small className="field-error">cloudflared must be installed to start the tunnel</small>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const mockCheckForUpdates = vi.fn();
|
||||
const mockFetchRemoteSettings = vi.fn();
|
||||
const mockUpdateRemoteSettings = vi.fn();
|
||||
const mockFetchRemoteStatus = vi.fn();
|
||||
const mockInstallCloudflared = vi.fn();
|
||||
const mockStartRemoteTunnel = vi.fn();
|
||||
const mockStopRemoteTunnel = vi.fn();
|
||||
const mockRegenerateRemotePersistentToken = vi.fn();
|
||||
@@ -73,6 +74,7 @@ vi.mock("../../api", () => ({
|
||||
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
|
||||
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
|
||||
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
|
||||
installCloudflared: (...args: unknown[]) => mockInstallCloudflared(...args),
|
||||
startRemoteTunnel: (...args: unknown[]) => mockStartRemoteTunnel(...args),
|
||||
stopRemoteTunnel: (...args: unknown[]) => mockStopRemoteTunnel(...args),
|
||||
regenerateRemotePersistentToken: (...args: unknown[]) => mockRegenerateRemotePersistentToken(...args),
|
||||
@@ -282,6 +284,7 @@ describe("SettingsModal", () => {
|
||||
},
|
||||
});
|
||||
mockFetchRemoteStatus.mockResolvedValue({ provider: null, state: "stopped", url: null, lastError: null });
|
||||
mockInstallCloudflared.mockResolvedValue({ success: true, command: "brew install cloudflared" });
|
||||
mockStartRemoteTunnel.mockResolvedValue({ state: "starting", provider: "tailscale" });
|
||||
mockStopRemoteTunnel.mockResolvedValue({ state: "stopped", provider: null });
|
||||
mockRegenerateRemotePersistentToken.mockResolvedValue({ token: "token", maskedToken: "****" });
|
||||
@@ -1695,6 +1698,69 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows cloudflared available indicator when Cloudflare is selected and cloudflared is installed", async () => {
|
||||
mockFetchRemoteStatus.mockResolvedValue({ provider: "cloudflare", state: "stopped", url: null, lastError: null, cloudflaredAvailable: true });
|
||||
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openRemoteSection();
|
||||
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
||||
|
||||
expect(await screen.findByText("cloudflared is installed")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Install cloudflared" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows install button when Cloudflare is selected and cloudflared is not available", async () => {
|
||||
mockFetchRemoteStatus.mockResolvedValue({ provider: "cloudflare", state: "stopped", url: null, lastError: null, cloudflaredAvailable: false });
|
||||
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openRemoteSection();
|
||||
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
||||
|
||||
expect(await screen.findByText("cloudflared is not installed")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Install cloudflared" })).toBeInTheDocument();
|
||||
expect(screen.getByText("cloudflared must be installed to start the tunnel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("install button triggers install and refreshes status", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockFetchRemoteStatus
|
||||
.mockResolvedValueOnce({ provider: "cloudflare", state: "stopped", url: null, lastError: null, cloudflaredAvailable: false })
|
||||
.mockResolvedValueOnce({ provider: "cloudflare", state: "stopped", url: null, lastError: null, cloudflaredAvailable: true });
|
||||
mockInstallCloudflared.mockResolvedValueOnce({ success: true, command: "brew install cloudflared" });
|
||||
|
||||
renderModal({ addToast });
|
||||
await waitForSettingsModalReady();
|
||||
await openRemoteSection();
|
||||
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
||||
|
||||
const installButton = await screen.findByRole("button", { name: "Install cloudflared" });
|
||||
await userEvent.click(installButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallCloudflared).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("cloudflared installed successfully", "success");
|
||||
});
|
||||
expect(await screen.findByText("cloudflared is installed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("install button shows error on failure", async () => {
|
||||
mockFetchRemoteStatus.mockResolvedValue({ provider: "cloudflare", state: "stopped", url: null, lastError: null, cloudflaredAvailable: false });
|
||||
mockInstallCloudflared.mockResolvedValueOnce({ success: false, command: "brew install cloudflared", error: "Command failed" });
|
||||
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openRemoteSection();
|
||||
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Install cloudflared" }));
|
||||
|
||||
expect(await screen.findByText("Command failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows lifecycle state changes for start and stop actions, including error state", async () => {
|
||||
mockFetchRemoteStatus
|
||||
.mockResolvedValueOnce({ provider: null, state: "stopped", url: null, lastError: null })
|
||||
|
||||
@@ -132,10 +132,9 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Heartbeat scheduling is driven by agent.state on the server; there is no
|
||||
// separate "disabled" UI concept anymore. Non-task-worker agents with a
|
||||
// legacy `runtimeConfig.enabled === false` on disk are rendered by state
|
||||
// just like any other agent.
|
||||
// Heartbeat disabled is a real durable-agent state in the UI. Task workers
|
||||
// still follow execution-state health because their runtimeConfig.enabled
|
||||
// flag only opts them out of scheduler timers.
|
||||
|
||||
describe("task worker health classification", () => {
|
||||
it('returns "Running" for metadata-marked task workers with disabled heartbeat', () => {
|
||||
@@ -173,7 +172,7 @@ describe("getAgentHealthStatus", () => {
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
it('ignores legacy runtimeConfig.enabled=false on non-task-worker agents', () => {
|
||||
it('returns "Heartbeat Disabled" for non-task-worker agents with heartbeat disabled', () => {
|
||||
const agent = makeAgent({
|
||||
name: "Reviewer",
|
||||
role: "reviewer",
|
||||
@@ -181,8 +180,21 @@ describe("getAgentHealthStatus", () => {
|
||||
runtimeConfig: { enabled: false },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
// No persisted heartbeat, no lastHeartbeatAt → Starting... not Disabled.
|
||||
expect(status.label).toBe("Starting...");
|
||||
expect(status.label).toBe("Heartbeat Disabled");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(status.color).toBe("var(--state-paused-text)");
|
||||
});
|
||||
|
||||
it('returns "Heartbeat Disabled" even when a disabled durable agent has a recent heartbeat', () => {
|
||||
const agent = makeAgent({
|
||||
name: "Reviewer",
|
||||
role: "reviewer",
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1_000).toISOString(),
|
||||
runtimeConfig: { enabled: false, heartbeatIntervalMs: 60_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Heartbeat Disabled");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -436,9 +448,9 @@ describe("getAgentHealthStatus", () => {
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("ignores runtimeConfig.enabled and uses interval-based staleness", () => {
|
||||
// 6 minute interval → 24 minute threshold. 25 minutes elapsed is stale regardless of any
|
||||
// legacy enabled flag or per-run timeout.
|
||||
it("uses interval-based staleness for enabled durable agents", () => {
|
||||
// 6 minute interval → 24 minute threshold. 25 minutes elapsed is stale
|
||||
// regardless of any per-run timeout.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 25 * 60 * 1000).toISOString(), // 25 minutes ago
|
||||
@@ -454,8 +466,7 @@ describe("getAgentHealthStatus", () => {
|
||||
{ agent: makeAgent({ state: "paused" }), expectedIconType: "Pause" },
|
||||
{ agent: makeAgent({ state: "running" }), expectedIconType: "Activity" },
|
||||
{ agent: makeAgent({ state: "idle" }), expectedIconType: "Bot" },
|
||||
// state=active + no lastHeartbeatAt → "Starting..." → Bot icon
|
||||
{ agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }), expectedIconType: "Bot" },
|
||||
{ agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }), expectedIconType: "Pause" },
|
||||
{
|
||||
agent: makeAgent({
|
||||
name: "executor-FN-1661",
|
||||
|
||||
@@ -3,11 +3,10 @@ import { Bot, Heart, Activity, Pause, Square } from "lucide-react";
|
||||
import type { Agent } from "../api";
|
||||
import { resolveHeartbeatIntervalMs } from "./heartbeatIntervals";
|
||||
|
||||
// Heartbeat scheduling is driven by `agent.state` on the server — active and
|
||||
// running tick, everything else does not. There is no separate "heartbeat
|
||||
// enabled" flag surfaced in the UI, so this file derives freshness straight
|
||||
// from state + lastHeartbeatAt and ignores any legacy `runtimeConfig.enabled`
|
||||
// value that may still be persisted on older agent records.
|
||||
// Heartbeat scheduling depends on both state and `runtimeConfig.enabled`.
|
||||
// Durable agents with heartbeat disabled should render distinctly from healthy
|
||||
// or merely-starting agents, while task-worker agents still follow their
|
||||
// execution lifecycle regardless of the scheduler toggle.
|
||||
|
||||
/**
|
||||
* Grace multiplier applied to an agent's configured interval before flagging
|
||||
@@ -86,6 +85,7 @@ function isTaskWorkerAgent(agent: AgentHealthInput): boolean {
|
||||
* - "Error" — agent.state === "error" (uses lastError if available)
|
||||
* - "Paused" — agent.state === "paused" (uses pauseReason if available)
|
||||
* - "Running" — agent.state === "running", or a detected task worker in "active"
|
||||
* - "Heartbeat Disabled" — durable agent with `runtimeConfig.enabled === false`
|
||||
* - "Starting..." — state === "active" && no lastHeartbeatAt
|
||||
* - "Idle" — state !== "active" && no lastHeartbeatAt
|
||||
* - "Healthy" — heartbeat is fresh within 2× the configured interval
|
||||
@@ -97,6 +97,7 @@ function isTaskWorkerAgent(agent: AgentHealthInput): boolean {
|
||||
export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus {
|
||||
const { state, lastHeartbeatAt, lastError, pauseReason, runtimeConfig } = agent;
|
||||
const isTaskWorker = isTaskWorkerAgent(agent);
|
||||
const isHeartbeatEnabled = isTaskWorker || runtimeConfig?.enabled !== false;
|
||||
|
||||
// Terminal states - these always take precedence
|
||||
if (state === "terminated") {
|
||||
@@ -136,6 +137,15 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
};
|
||||
}
|
||||
|
||||
if (!isHeartbeatEnabled) {
|
||||
return {
|
||||
label: "Heartbeat Disabled",
|
||||
icon: <Pause size={14} />,
|
||||
color: "var(--state-paused-text)",
|
||||
stateDerived: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No heartbeat data yet
|
||||
if (!lastHeartbeatAt) {
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
|
||||
const { mockExecFile } = vi.hoisted(() => ({
|
||||
mockExecFile: vi.fn(),
|
||||
}));
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
execFile: mockExecFile,
|
||||
};
|
||||
});
|
||||
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
|
||||
@@ -84,6 +96,16 @@ async function REQUEST(app: express.Express, method: string, path: string, body?
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecFile.mockReset();
|
||||
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
|
||||
: maybeCallback;
|
||||
callback?.(null, command === "where" || command === "which" ? "/usr/local/bin/cloudflared" : "", "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote access provider/lifecycle contracts", () => {
|
||||
it("switches active provider and rejects invalid provider values", async () => {
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -180,4 +202,87 @@ describe("remote access provider/lifecycle contracts", () => {
|
||||
details: { code: "REMOTE_TUNNEL_PREREQUISITE_MISSING" },
|
||||
});
|
||||
});
|
||||
|
||||
it("includes cloudflaredAvailable in remote status for cloudflare provider", async () => {
|
||||
const { app } = createApp();
|
||||
|
||||
const status = await REQUEST(app, "GET", "/api/remote/status");
|
||||
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body).toEqual(expect.objectContaining({
|
||||
provider: "cloudflare",
|
||||
cloudflaredAvailable: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns cloudflaredAvailable false when cloudflared check fails", async () => {
|
||||
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null) => void
|
||||
: maybeCallback;
|
||||
if (command === "which" || command === "where") {
|
||||
callback?.(new Error("missing"));
|
||||
return;
|
||||
}
|
||||
callback?.(null);
|
||||
});
|
||||
|
||||
const { app } = createApp();
|
||||
const status = await REQUEST(app, "GET", "/api/remote/status");
|
||||
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body).toEqual(expect.objectContaining({ cloudflaredAvailable: false }));
|
||||
});
|
||||
|
||||
it("returns cloudflaredAvailable null for non-cloudflare provider", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
remoteAccess: buildRemoteAccessSettings({ activeProvider: "tailscale" }),
|
||||
}),
|
||||
});
|
||||
const { app } = createApp({ store });
|
||||
|
||||
const status = await REQUEST(app, "GET", "/api/remote/status");
|
||||
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body).toEqual(expect.objectContaining({
|
||||
provider: "tailscale",
|
||||
cloudflaredAvailable: null,
|
||||
}));
|
||||
});
|
||||
|
||||
it("installs cloudflared via endpoint and returns install command metadata", async () => {
|
||||
const { app } = createApp();
|
||||
|
||||
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
command: expect.any(String),
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns install failure details when cloudflared installation command fails", async () => {
|
||||
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
|
||||
: maybeCallback;
|
||||
if (command === "sh" || command === "cmd") {
|
||||
callback?.(new Error("Command failed"), "", "Command failed");
|
||||
return;
|
||||
}
|
||||
callback?.(null, "/usr/local/bin/cloudflared", "");
|
||||
});
|
||||
|
||||
const { app } = createApp();
|
||||
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual(expect.objectContaining({
|
||||
success: false,
|
||||
command: expect.any(String),
|
||||
error: expect.stringContaining("Command failed"),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -474,6 +474,28 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("resuming to active does not auto-trigger heartbeat when disabled", async () => {
|
||||
mockGetAgent.mockResolvedValue({
|
||||
id: "agent-001",
|
||||
state: "paused",
|
||||
runtimeConfig: { enabled: false },
|
||||
});
|
||||
mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "active" });
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/state",
|
||||
JSON.stringify({ state: "active" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ id: "agent-001", state: "active" });
|
||||
await Promise.resolve();
|
||||
expect(mockExecuteHeartbeat).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/runs", () => {
|
||||
|
||||
@@ -380,8 +380,8 @@ describe("createServer health and headless mode", () => {
|
||||
|
||||
const dashStatusBody = dashStatus.body as Record<string, unknown>;
|
||||
const headlessStatusBody = headlessStatus.body as Record<string, unknown>;
|
||||
expect(Object.keys(dashStatusBody).sort()).toEqual(["lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
|
||||
expect(Object.keys(headlessStatusBody).sort()).toEqual(["lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
|
||||
expect(Object.keys(dashStatusBody).sort()).toEqual(["cloudflaredAvailable", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
|
||||
expect(Object.keys(headlessStatusBody).sort()).toEqual(["cloudflaredAvailable", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
|
||||
expect(headlessRoot.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -432,7 +432,8 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
|
||||
}
|
||||
}
|
||||
|
||||
if (nextState === "active" && projectHeartbeatMonitor) {
|
||||
const isHeartbeatEnabled = currentAgent.runtimeConfig?.enabled !== false;
|
||||
if (nextState === "active" && isHeartbeatEnabled && projectHeartbeatMonitor) {
|
||||
await projectHeartbeatMonitor.executeHeartbeat({
|
||||
agentId,
|
||||
source: "on_demand",
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
} from "@fusion/core";
|
||||
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
|
||||
import QRCode from "qrcode";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { ApiError, badRequest } from "../api-error.js";
|
||||
import { generateRemoteToken, issueRemoteAuthToken, maskRemoteToken } from "../remote-auth.js";
|
||||
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
|
||||
@@ -57,6 +59,44 @@ interface SettingsMemoryRouteDeps {
|
||||
export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: SettingsMemoryRouteDeps): void {
|
||||
const { router, options, store, runtimeLogger, getProjectContext, rethrowAsApiError } = ctx;
|
||||
const { githubToken, validateModelPresets, sanitizeOverlapIgnorePaths, discoverDashboardPiExtensions } = deps;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function isCloudflaredAvailable(): Promise<boolean> {
|
||||
const command = process.platform === "win32" ? "where" : "which";
|
||||
try {
|
||||
await execFileAsync(command, ["cloudflared"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCloudflaredInstallCommand(): string {
|
||||
if (process.platform === "darwin") {
|
||||
return "brew install cloudflared";
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return "winget install Cloudflare.cloudflared";
|
||||
}
|
||||
return "curl -L --output /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 && chmod +x /usr/local/bin/cloudflared";
|
||||
}
|
||||
|
||||
async function installCloudflared(): Promise<{ success: boolean; command: string; error?: string }> {
|
||||
const command = resolveCloudflaredInstallCommand();
|
||||
const shell = process.platform === "win32" ? "cmd" : "sh";
|
||||
const shellArgs = process.platform === "win32" ? ["/c", command] : ["-c", command];
|
||||
|
||||
try {
|
||||
await execFileAsync(shell, shellArgs, { timeout: 120_000 });
|
||||
return { success: true, command };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
command,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRemoteBaseUrl(
|
||||
remoteAccess: NonNullable<Awaited<ReturnType<typeof store.getSettings>>["remoteAccess"]>,
|
||||
@@ -422,12 +462,19 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
const tunnelStatus = manager?.getStatus();
|
||||
const restore = engine?.getRemoteTunnelRestoreDiagnostics();
|
||||
|
||||
const activeProvider = tunnelStatus?.provider ?? settings.remoteAccess?.activeProvider ?? null;
|
||||
let cloudflaredAvailable: boolean | null = null;
|
||||
if (activeProvider === "cloudflare") {
|
||||
cloudflaredAvailable = await isCloudflaredAvailable();
|
||||
}
|
||||
|
||||
res.json({
|
||||
provider: tunnelStatus?.provider ?? settings.remoteAccess?.activeProvider ?? null,
|
||||
provider: activeProvider,
|
||||
state: tunnelStatus?.state ?? "stopped",
|
||||
url: tunnelStatus?.url ?? null,
|
||||
lastError: tunnelStatus?.lastError?.message ?? null,
|
||||
lastErrorCode: tunnelStatus?.lastError?.code ?? null,
|
||||
cloudflaredAvailable,
|
||||
restore: restore ?? {
|
||||
outcome: "skipped",
|
||||
reason: "not_attempted",
|
||||
@@ -441,6 +488,16 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/remote/install-cloudflared", async (_req, res) => {
|
||||
try {
|
||||
const result = await installCloudflared();
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to install cloudflared");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/remote/provider/activate", async (req, res) => {
|
||||
try {
|
||||
const provider = req.body?.provider;
|
||||
|
||||
@@ -4217,13 +4217,9 @@ describe("HeartbeatTriggerScheduler", () => {
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
});
|
||||
|
||||
it("registers regardless of the legacy enabled flag (state is the source of truth)", () => {
|
||||
// runtimeConfig.enabled is no longer honored by the scheduler — pause
|
||||
// and resume happen through agent.state, and the agent:updated listener
|
||||
// drives register/unregister. Callers that still pass `enabled: false`
|
||||
// should not silently lose the timer.
|
||||
it("does not register when heartbeat is explicitly disabled", () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000, enabled: false });
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
|
||||
});
|
||||
|
||||
it("applies default 3600-second interval when intervalMs is undefined", async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* - onTerminated: Called when an unresponsive agent is terminated
|
||||
*/
|
||||
|
||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, BlockedStateSnapshot, RunMutationContext, Settings } from "@fusion/core";
|
||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, BlockedStateSnapshot, RunMutationContext, Settings, AgentConfigRevision } from "@fusion/core";
|
||||
import { buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity } from "@fusion/core";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
@@ -1750,11 +1750,14 @@ function isHeartbeatManaged(agent: Agent): boolean {
|
||||
/**
|
||||
* HeartbeatTriggerScheduler manages timer-based heartbeat triggers for agents.
|
||||
*
|
||||
* State is the source of truth: state ∈ {active, running} on a non-ephemeral
|
||||
* agent arms the timer; any other state or any ephemeral agent doesn't. The
|
||||
* `runtimeConfig.enabled` flag is no longer consulted here — pause/resume
|
||||
* happens through `agent.state`, and the `agent:updated` listener arms or
|
||||
* clears the timer on transitions.
|
||||
* Timers are armed only for durable agents where all of the following hold:
|
||||
* - `runtimeConfig.enabled !== false`
|
||||
* - `state ∈ {active, running, idle}`
|
||||
*
|
||||
* Any other state, or any ephemeral/task-worker agent, clears the timer.
|
||||
* State changes and heartbeat config updates are observed via AgentStore
|
||||
* lifecycle events, while callers can still explicitly register existing
|
||||
* agents during startup bootstrap.
|
||||
*
|
||||
* Other config knobs still apply:
|
||||
* - `heartbeatIntervalMs`: Timer interval (default 1h)
|
||||
@@ -1768,7 +1771,9 @@ export class HeartbeatTriggerScheduler {
|
||||
private registrationEpochs: Map<string, number> = new Map();
|
||||
private running = false;
|
||||
private assignedListener: ((agent: import("@fusion/core").Agent, taskId: string) => void) | null = null;
|
||||
private createdListener: ((agent: import("@fusion/core").Agent) => void) | null = null;
|
||||
private updatedListener: ((agent: import("@fusion/core").Agent) => void) | null = null;
|
||||
private configRevisionListener: ((agentId: string, revision: AgentConfigRevision) => void) | null = null;
|
||||
private deletedListener: ((agentId: string) => void) | null = null;
|
||||
|
||||
constructor(store: AgentStore, callback: TriggerCallback, taskStore?: TaskStore) {
|
||||
@@ -1779,7 +1784,7 @@ export class HeartbeatTriggerScheduler {
|
||||
|
||||
/**
|
||||
* Start the scheduler. Enables assignment watching.
|
||||
* Individual agents must be registered separately via registerAgent().
|
||||
* Existing agents still need one startup bootstrap pass via registerAgent().
|
||||
*/
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
@@ -1826,9 +1831,10 @@ export class HeartbeatTriggerScheduler {
|
||||
* @param config - Per-agent heartbeat config
|
||||
*/
|
||||
registerAgent(agentId: string, config: AgentHeartbeatConfig): void {
|
||||
// State drives whether an agent ticks; this method no longer honors
|
||||
// `config.enabled` as a registration gate. Callers filter based on
|
||||
// state + ephemeral classification before calling through.
|
||||
if (config.enabled === false) {
|
||||
this.unregisterAgent(agentId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply default interval if not explicitly configured
|
||||
// This ensures agents with heartbeat monitoring enabled but no explicit interval
|
||||
@@ -2042,46 +2048,112 @@ export class HeartbeatTriggerScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
private isTimerEligibleAgent(agent: Agent): boolean {
|
||||
return isHeartbeatManaged(agent)
|
||||
&& agent.runtimeConfig?.enabled !== false
|
||||
&& isTickableState(agent.state);
|
||||
}
|
||||
|
||||
private getAgentTimerConfig(agent: Agent): AgentHeartbeatConfig {
|
||||
const rc = (agent.runtimeConfig ?? {}) as {
|
||||
enabled?: boolean;
|
||||
heartbeatIntervalMs?: number;
|
||||
maxConcurrentRuns?: number;
|
||||
};
|
||||
return {
|
||||
enabled: rc.enabled,
|
||||
heartbeatIntervalMs: rc.heartbeatIntervalMs,
|
||||
maxConcurrentRuns: rc.maxConcurrentRuns,
|
||||
};
|
||||
}
|
||||
|
||||
private syncTimerForAgent(agent: Agent, reason: string): void {
|
||||
if (!this.isTimerEligibleAgent(agent)) {
|
||||
this.unregisterAgent(agent.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.timers.has(agent.id)) {
|
||||
// Already ticking — non-config updates should not reset the interval.
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerAgent(agent.id, this.getAgentTimerConfig(agent));
|
||||
heartbeatLog.log(`Timer armed for ${agent.id} (${reason})`);
|
||||
}
|
||||
|
||||
private async syncTimerForAgentFromStore(agentId: string, reason: string): Promise<void> {
|
||||
const agent = await this.store.getAgent(agentId);
|
||||
if (!agent) {
|
||||
this.unregisterAgent(agentId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isTimerEligibleAgent(agent)) {
|
||||
this.unregisterAgent(agentId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerAgent(agent.id, this.getAgentTimerConfig(agent));
|
||||
heartbeatLog.log(`Timer refreshed for ${agent.id} (${reason})`);
|
||||
}
|
||||
|
||||
private didHeartbeatScheduleChange(revision: AgentConfigRevision): boolean {
|
||||
const before = (revision.before.runtimeConfig ?? {}) as Record<string, unknown>;
|
||||
const after = (revision.after.runtimeConfig ?? {}) as Record<string, unknown>;
|
||||
|
||||
const pickScheduleFields = (runtimeConfig: Record<string, unknown>) => ({
|
||||
enabled: runtimeConfig.enabled,
|
||||
heartbeatIntervalMs: runtimeConfig.heartbeatIntervalMs,
|
||||
maxConcurrentRuns: runtimeConfig.maxConcurrentRuns,
|
||||
});
|
||||
|
||||
return JSON.stringify(pickScheduleFields(before)) !== JSON.stringify(pickScheduleFields(after));
|
||||
}
|
||||
|
||||
private watchAgentLifecycle(): void {
|
||||
if (this.updatedListener || this.deletedListener) return;
|
||||
if (this.createdListener || this.updatedListener || this.configRevisionListener || this.deletedListener) return;
|
||||
|
||||
this.createdListener = (agent) => {
|
||||
this.syncTimerForAgent(agent, `created:${agent.state}`);
|
||||
};
|
||||
|
||||
// State-driven registration: when an agent transitions into a tickable
|
||||
// state (active/running) arm the timer; transitioning out clears it.
|
||||
// state arm the timer; transitioning out clears it. Existing timers are
|
||||
// left alone here so unrelated agent updates do not reset the interval.
|
||||
this.updatedListener = (agent) => {
|
||||
if (!isHeartbeatManaged(agent) || !isTickableState(agent.state)) {
|
||||
this.unregisterAgent(agent.id);
|
||||
this.syncTimerForAgent(agent, `state:${agent.state}`);
|
||||
};
|
||||
this.configRevisionListener = (agentId, revision) => {
|
||||
if (!this.didHeartbeatScheduleChange(revision)) {
|
||||
return;
|
||||
}
|
||||
if (this.timers.has(agent.id)) {
|
||||
// Already ticking — re-registering would reset the interval mid-cycle
|
||||
// on every unrelated agent update.
|
||||
return;
|
||||
}
|
||||
const rc = (agent.runtimeConfig ?? {}) as {
|
||||
heartbeatIntervalMs?: number;
|
||||
maxConcurrentRuns?: number;
|
||||
};
|
||||
this.registerAgent(agent.id, {
|
||||
heartbeatIntervalMs: rc.heartbeatIntervalMs,
|
||||
maxConcurrentRuns: rc.maxConcurrentRuns,
|
||||
});
|
||||
heartbeatLog.log(
|
||||
`State-driven registration: ${agent.id} is ${agent.state} — timer armed`,
|
||||
);
|
||||
|
||||
void this.syncTimerForAgentFromStore(agentId, "runtime-config-updated");
|
||||
};
|
||||
this.deletedListener = (agentId) => {
|
||||
this.unregisterAgent(agentId);
|
||||
};
|
||||
|
||||
this.store.on("agent:created", this.createdListener);
|
||||
this.store.on("agent:updated", this.updatedListener);
|
||||
this.store.on("agent:configRevision", this.configRevisionListener);
|
||||
this.store.on("agent:deleted", this.deletedListener);
|
||||
}
|
||||
|
||||
private unwatchAgentLifecycle(): void {
|
||||
if (this.createdListener) {
|
||||
this.store.off("agent:created", this.createdListener);
|
||||
this.createdListener = null;
|
||||
}
|
||||
if (this.updatedListener) {
|
||||
this.store.off("agent:updated", this.updatedListener);
|
||||
this.updatedListener = null;
|
||||
}
|
||||
if (this.configRevisionListener) {
|
||||
this.store.off("agent:configRevision", this.configRevisionListener);
|
||||
this.configRevisionListener = null;
|
||||
}
|
||||
if (this.deletedListener) {
|
||||
this.store.off("agent:deleted", this.deletedListener);
|
||||
this.deletedListener = null;
|
||||
|
||||
@@ -862,24 +862,46 @@ describe("InProcessRuntime", () => {
|
||||
expect(scheduler!.getRegisteredAgents()).not.toContain(agent.id);
|
||||
});
|
||||
|
||||
it("re-registers an existing agent when agent:updated event is emitted", async () => {
|
||||
// Create a new agent
|
||||
it("does not reset an armed timer on unrelated agent updates", async () => {
|
||||
const store = getAgentStore(runtime);
|
||||
const monitor = runtime.getHeartbeatMonitor();
|
||||
expect(monitor).toBeDefined();
|
||||
|
||||
const executeHeartbeatSpy = vi
|
||||
.spyOn(monitor!, "executeHeartbeat")
|
||||
.mockResolvedValue({ id: "run-update-timer-stability" } as any);
|
||||
|
||||
const agent = await store.createAgent({
|
||||
name: "test-agent-update",
|
||||
role: "executor",
|
||||
runtimeConfig: {
|
||||
enabled: true,
|
||||
heartbeatIntervalMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const scheduler = runtime.getTriggerScheduler();
|
||||
expect(scheduler!.getRegisteredAgents()).toContain(agent.id);
|
||||
|
||||
// Update the agent
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
await store.updateAgent(agent.id, {
|
||||
name: "test-agent-update-renamed",
|
||||
});
|
||||
|
||||
// Verify the agent is still registered (re-registration succeeded)
|
||||
expect(scheduler!.getRegisteredAgents()).toContain(agent.id);
|
||||
await vi.advanceTimersByTimeAsync(599);
|
||||
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await vi.waitFor(() => {
|
||||
expect(executeHeartbeatSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(executeHeartbeatSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: agent.id,
|
||||
source: "timer",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("unregisters an agent when enabled is set to false in update", async () => {
|
||||
@@ -907,6 +929,41 @@ describe("InProcessRuntime", () => {
|
||||
expect(scheduler!.getRegisteredAgents()).not.toContain(agent.id);
|
||||
});
|
||||
|
||||
it("re-arms the timer when heartbeat interval changes", async () => {
|
||||
const store = getAgentStore(runtime);
|
||||
const monitor = runtime.getHeartbeatMonitor();
|
||||
expect(monitor).toBeDefined();
|
||||
|
||||
const executeHeartbeatSpy = vi
|
||||
.spyOn(monitor!, "executeHeartbeat")
|
||||
.mockResolvedValue({ id: "run-interval-change" } as any);
|
||||
|
||||
const agent = await store.createAgent({
|
||||
name: "interval-change-agent",
|
||||
role: "executor",
|
||||
runtimeConfig: {
|
||||
enabled: true,
|
||||
heartbeatIntervalMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
await store.updateAgent(agent.id, {
|
||||
runtimeConfig: {
|
||||
enabled: true,
|
||||
heartbeatIntervalMs: 2000,
|
||||
},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1599);
|
||||
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(401);
|
||||
await vi.waitFor(() => {
|
||||
expect(executeHeartbeatSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears timers on pause and re-arms from resume without stale pre-pause firing", async () => {
|
||||
const store = getAgentStore(runtime);
|
||||
const monitor = runtime.getHeartbeatMonitor();
|
||||
|
||||
@@ -98,8 +98,6 @@ export class InProcessRuntime
|
||||
private triageProcessor?: TriageProcessor;
|
||||
private messageStore?: MessageStore;
|
||||
private concurrencyChangedListener?: (state: { globalMaxConcurrent: number }) => void;
|
||||
private agentCreatedListener?: (agent: import("@fusion/core").Agent) => void;
|
||||
private agentUpdatedListener?: (agent: import("@fusion/core").Agent, previousState?: import("@fusion/core").AgentState) => void;
|
||||
/** Set of agent IDs with scheduled ephemeral cleanup (prevents duplicate deletion) */
|
||||
private pendingEphemeralDeletions = new Set<string>();
|
||||
/** Map of agent IDs to their cleanup timer IDs */
|
||||
@@ -506,9 +504,8 @@ export class InProcessRuntime
|
||||
);
|
||||
this.triggerScheduler.start();
|
||||
|
||||
// Dynamic registration follows per-agent heartbeat enablement and tickable state.
|
||||
// Non-ephemeral agents are managed unless runtimeConfig.enabled is explicitly false.
|
||||
// Paused/error/terminated states are never timer-armed.
|
||||
// Startup bootstrap for already-persisted agents. Ongoing lifecycle
|
||||
// updates are handled inside HeartbeatTriggerScheduler itself.
|
||||
const isHeartbeatEnabledAgent = (agent: import("@fusion/core").Agent) =>
|
||||
!isEphemeralAgent(agent) && agent.runtimeConfig?.enabled !== false;
|
||||
const isTickableHeartbeatState = (state: import("@fusion/core").AgentState) =>
|
||||
@@ -516,34 +513,6 @@ export class InProcessRuntime
|
||||
const isTimerManagedAgent = (agent: import("@fusion/core").Agent) =>
|
||||
isHeartbeatEnabledAgent(agent) && isTickableHeartbeatState(agent.state);
|
||||
|
||||
this.agentCreatedListener = (agent) => {
|
||||
if (!this.triggerScheduler) return;
|
||||
if (!isTimerManagedAgent(agent)) return;
|
||||
const rc = agent.runtimeConfig;
|
||||
this.triggerScheduler.registerAgent(agent.id, {
|
||||
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
|
||||
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
|
||||
});
|
||||
runtimeLog.log(`Registered new agent ${agent.id} for heartbeat triggers`);
|
||||
};
|
||||
this.agentStore.on("agent:created", this.agentCreatedListener);
|
||||
|
||||
this.agentUpdatedListener = (agent) => {
|
||||
if (!this.triggerScheduler) return;
|
||||
if (!isTimerManagedAgent(agent)) {
|
||||
this.triggerScheduler.unregisterAgent(agent.id);
|
||||
runtimeLog.log(`Unregistered agent ${agent.id} from heartbeat triggers`);
|
||||
return;
|
||||
}
|
||||
const rc = agent.runtimeConfig;
|
||||
this.triggerScheduler.registerAgent(agent.id, {
|
||||
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
|
||||
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
|
||||
});
|
||||
runtimeLog.log(`Re-registered agent ${agent.id} for heartbeat triggers`);
|
||||
};
|
||||
this.agentStore.on("agent:updated", this.agentUpdatedListener);
|
||||
|
||||
// Listen for agent state transitions to clean up terminated ephemeral agents.
|
||||
// This catches cases where ephemeral agents (task-workers, spawned children) are
|
||||
// terminated by HeartbeatMonitor or other pathways outside of onComplete/onError callbacks.
|
||||
@@ -595,6 +564,7 @@ export class InProcessRuntime
|
||||
if (!isTimerManagedAgent(agent)) continue;
|
||||
const rc = agent.runtimeConfig;
|
||||
this.triggerScheduler.registerAgent(agent.id, {
|
||||
enabled: rc?.enabled as boolean | undefined,
|
||||
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
|
||||
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
|
||||
});
|
||||
@@ -792,16 +762,6 @@ export class InProcessRuntime
|
||||
|
||||
// 3. Remove agent event listeners (before stopping trigger scheduler)
|
||||
// Guard on this.agentStore being defined - it may not exist if AgentStore init failed
|
||||
if (this.agentCreatedListener && this.agentStore) {
|
||||
this.agentStore.off("agent:created", this.agentCreatedListener);
|
||||
this.agentCreatedListener = undefined;
|
||||
runtimeLog.log("AgentStore agent:created listener removed");
|
||||
}
|
||||
if (this.agentUpdatedListener && this.agentStore) {
|
||||
this.agentStore.off("agent:updated", this.agentUpdatedListener);
|
||||
this.agentUpdatedListener = undefined;
|
||||
runtimeLog.log("AgentStore agent:updated listener removed");
|
||||
}
|
||||
if (this.ephemeralTerminationListener && this.agentStore) {
|
||||
this.agentStore.off("agent:stateChanged", this.ephemeralTerminationListener);
|
||||
this.ephemeralTerminationListener = undefined;
|
||||
|
||||
Reference in New Issue
Block a user