feat(FN-1085): align agent routing and runtime contracts

- Harden core AgentStore lifecycle behavior and heartbeat runtime integration paths
- Align dashboard agent APIs, server routes, and agent UI flows with the updated contract
- Tighten CLI agent/message command routing and validate payload handling semantics
- Expand test coverage across core, dashboard, engine, and CLI for route, heartbeat, and instruction regressions
This commit is contained in:
gsxdsm
2026-04-08 00:44:33 -07:00
parent 92e6aa2b49
commit 07697b2f5b
19 changed files with 942 additions and 169 deletions

View File

@@ -1175,6 +1175,9 @@ function ConfigTab({
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
initial.heartbeatTimeoutMs = String(rc.heartbeatTimeoutMs);
}
if (rc.maxConcurrentRuns !== undefined && rc.maxConcurrentRuns !== null) {
initial.maxConcurrentRuns = String(rc.maxConcurrentRuns);
}
if (rc.messageResponseMode === "immediate" || rc.messageResponseMode === "on-heartbeat") {
initial.messageResponseMode = rc.messageResponseMode;
}
@@ -1202,7 +1205,7 @@ function ConfigTab({
}
// Check heartbeat values
const rc = agent.runtimeConfig ?? {};
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "messageResponseMode"] as const) {
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode"] as const) {
const current = heartbeatValues[key]?.trim() ?? "";
const persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
if (current !== persisted) return true;
@@ -1251,6 +1254,7 @@ function ConfigTab({
for (const [key, config] of Object.entries({
heartbeatIntervalMs: { label: "Heartbeat Interval", min: 1000 },
heartbeatTimeoutMs: { label: "Heartbeat Timeout", min: 5000 },
maxConcurrentRuns: { label: "Max Concurrent Runs", min: 1 },
})) {
const raw = heartbeatValues[key]?.trim();
if (!raw) continue;
@@ -1289,7 +1293,7 @@ function ConfigTab({
// Build the runtimeConfig payload — only include non-empty values
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs"] as const) {
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns"] as const) {
const raw = heartbeatValues[key]?.trim();
if (!raw) {
delete newRuntimeConfig[key];
@@ -1420,6 +1424,24 @@ function ConfigTab({
)}
</div>
<div className="config-field">
<label htmlFor="hb-maxConcurrentRuns">Max Concurrent Runs</label>
<input
id="hb-maxConcurrentRuns"
type="text"
inputMode="numeric"
className={cn("input", !!errors.maxConcurrentRuns && "input--error")}
placeholder="1"
value={heartbeatValues.maxConcurrentRuns ?? ""}
onChange={(e) => handleHeartbeatFieldChange("maxConcurrentRuns", e.target.value)}
/>
{errors.maxConcurrentRuns ? (
<span className="config-error">{errors.maxConcurrentRuns}</span>
) : (
<span className="config-hint">Maximum simultaneous heartbeat runs for this agent. Leave empty for system default (1).</span>
)}
</div>
<div className="config-field">
<label htmlFor="hb-messageResponseMode">Message Response Mode</label>
<select

View File

@@ -165,6 +165,25 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
void loadAgents();
}, [loadAgents]);
// Refresh agent list on SSE events (independent from useAgents hook state)
useEffect(() => {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const es = new EventSource(`/api/events${query}`);
const refresh = () => {
void loadAgents();
};
es.addEventListener("agent:created", refresh);
es.addEventListener("agent:updated", refresh);
es.addEventListener("agent:deleted", refresh);
es.addEventListener("agent:stateChanged", refresh);
return () => {
es.close();
};
}, [projectId, loadAgents]);
const handleStateChange = async (agentId: string, newState: AgentState) => {
try {
await updateAgentState(agentId, newState, projectId);
@@ -253,8 +272,11 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
}
const lastHeartbeat = new Date(agent.lastHeartbeatAt).getTime();
const elapsed = Date.now() - lastHeartbeat;
const timeoutMs = 60000; // 60 second timeout
if (elapsed > timeoutMs) {
const runtimeConfig = agent.runtimeConfig as Record<string, unknown> | undefined;
const configuredTimeout = typeof runtimeConfig?.heartbeatTimeoutMs === "number"
? runtimeConfig.heartbeatTimeoutMs
: 60000;
if (elapsed > configuredTimeout) {
return { label: "Unresponsive", icon: <Activity size={14} />, color: "var(--state-error-text)" };
}
return { label: "Healthy", icon: <Heart size={14} />, color: "var(--state-active-text)" };

View File

@@ -78,6 +78,9 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
const [title, setTitle] = useState("");
const [icon, setIcon] = useState("");
const [role, setRole] = useState<AgentCapability>("custom");
const [reportsTo, setReportsTo] = useState("");
const [instructionsPath, setInstructionsPath] = useState("");
const [instructionsText, setInstructionsText] = useState("");
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig>({
model: "",
thinkingLevel: "off",
@@ -175,6 +178,9 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
setTitle("");
setIcon("");
setRole("custom");
setReportsTo("");
setInstructionsPath("");
setInstructionsText("");
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 10 });
setSelectedPresetId(null);
setError(null);
@@ -196,6 +202,9 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
role,
...(title.trim() ? { title: title.trim() } : {}),
...(icon.trim() ? { icon: icon.trim() } : {}),
...(reportsTo.trim() ? { reportsTo: reportsTo.trim() } : {}),
...(instructionsPath.trim() ? { instructionsPath: instructionsPath.trim() } : {}),
...(instructionsText.trim() ? { instructionsText: instructionsText.trim() } : {}),
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
}, projectId);
handleClose();
@@ -303,6 +312,39 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
))}
</div>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-reports-to">Reports To <span className="agent-dialog-optional">(optional agent ID)</span></label>
<input
id="agent-reports-to"
type="text"
className="input"
placeholder="e.g. agent-1234abcd"
value={reportsTo}
onChange={e => setReportsTo(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-instructions-path">Instructions Path <span className="agent-dialog-optional">(optional)</span></label>
<input
id="agent-instructions-path"
type="text"
className="input"
placeholder="e.g. .fusion/agents/reviewer.md"
value={instructionsPath}
onChange={e => setInstructionsPath(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-instructions-text">Inline Instructions <span className="agent-dialog-optional">(optional)</span></label>
<textarea
id="agent-instructions-text"
className="input"
rows={4}
placeholder="Add custom behavior instructions..."
value={instructionsText}
onChange={e => setInstructionsText(e.target.value)}
/>
</div>
{/* AI-assisted generation */}
<div className="agent-dialog-ai-generate">
<button
@@ -394,6 +436,24 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
<span className="agent-dialog-summary-row-label">Role</span>
<span>{selectedRole?.icon} {selectedRole?.label}</span>
</div>
{reportsTo.trim() && (
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label">Reports To</span>
<span>{reportsTo.trim()}</span>
</div>
)}
{instructionsPath.trim() && (
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label">Instructions File</span>
<span>{instructionsPath.trim()}</span>
</div>
)}
{instructionsText.trim() && (
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label">Inline Instructions</span>
<span>{instructionsText.trim().length} chars</span>
</div>
)}
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label">Model</span>
<span>