feat(FN-3249): apply run status icon color style (+3 more)

Commits merged:
- fix(FN-3249): apply run status icon color style
- fix(FN-3249): address workflow revision dashboard accessibility feedback
- feat(FN-3249): complete Step 5 — document assigned execution ownership
- feat(FN-3249): complete Step 2 — durable assigned-agent execution ownership

Files changed:
.changeset/fn-3249-assigned-executor-ownership.md  |   5 +
 docs/agents.md                                     |  15 +++
 packages/core/src/__tests__/agent-store.test.ts    |  28 +++++
 packages/core/src/agent-store.ts                   |  34 ++++--
 .../dashboard/app/components/AgentDetailView.css   |   5 +
 .../dashboard/app/components/AgentDetailView.tsx   |   2 +-
 packages/dashboard/app/components/AgentsView.tsx   |   2 +
 .../runtimes/__tests__/in-process-runtime.test.ts  | 125 ++++++++++++++++++---
 packages/engine/src/runtimes/in-process-runtime.ts | 108 +++++++++++-------
 9 files changed, 260 insertions(+), 64 deletions(-)

Fusion-Task-Id: FN-3249
This commit is contained in:
Fusion
2026-05-04 22:36:04 -07:00
committed by gsxdsm
parent 583a8de019
commit 8565cdcff7
9 changed files with 260 additions and 64 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Use durable assigned agents as active task execution owners when `assignedAgentId` targets a non-ephemeral agent, instead of always creating transient `executor-FN-*` task-worker agents.

View File

@@ -82,6 +82,21 @@ These fields can only be set during update (not on create):
- `totalInputTokens` — Accumulated input token count - `totalInputTokens` — Accumulated input token count
- `totalOutputTokens` — Accumulated output token count - `totalOutputTokens` — Accumulated output token count
## Execution Ownership for Assigned Agents
When a task sets `assignedAgentId` to a **durable (non-ephemeral)** agent, that same agent is used as the active execution owner during runtime execution.
Behavior:
- Fusion links the durable agent's `taskId` to the running task for execution visibility
- No synthetic `executor-FN-*` task-worker agent is created for that run
- On completion/error, the durable agent's execution task link is cleared (the durable record is preserved)
Fallback behavior remains unchanged:
- Unassigned tasks still use runtime-managed `executor-FN-*` task-worker agents
- Missing assigned agents, or assigned agents that are ephemeral/runtime-managed, fall back to task-worker execution ownership
Execution-ownership sync intentionally avoids assignment-trigger side effects (`agent:assigned` wakeups) that are intended for control-plane delegation.
## Agents View (Dashboard) ## Agents View (Dashboard)
The agents surface provides: The agents surface provides:

View File

@@ -1639,6 +1639,34 @@ describe("AgentStore", () => {
}); });
}); });
describe("syncExecutionTaskLink", () => {
it("updates taskId without emitting assignment events", async () => {
const agent = await store.createAgent({ name: "Runtime Owner", role: "executor" });
const assignedHandler = vi.fn();
store.on("agent:assigned", assignedHandler);
const updated = await store.syncExecutionTaskLink(agent.id, "FN-3249");
expect(updated.taskId).toBe("FN-3249");
expect(assignedHandler).not.toHaveBeenCalled();
const fetched = await store.getAgent(agent.id);
expect(fetched?.taskId).toBe("FN-3249");
});
it("clears taskId without emitting assignment events", async () => {
const agent = await store.createAgent({ name: "Runtime Owner 2", role: "executor" });
await store.syncExecutionTaskLink(agent.id, "FN-1111");
const assignedHandler = vi.fn();
store.on("agent:assigned", assignedHandler);
const updated = await store.syncExecutionTaskLink(agent.id, undefined);
expect(updated.taskId).toBeUndefined();
expect(assignedHandler).not.toHaveBeenCalled();
});
});
describe("checkout leasing", () => { describe("checkout leasing", () => {
let taskStore: TaskStore; let taskStore: TaskStore;
let holderId: string; let holderId: string;

View File

@@ -1185,6 +1185,29 @@ export class AgentStore extends EventEmitter {
* @returns The updated agent * @returns The updated agent
*/ */
async assignTask(agentId: string, taskId: string | undefined, runContext?: RunMutationContext): Promise<Agent> { async assignTask(agentId: string, taskId: string | undefined, runContext?: RunMutationContext): Promise<Agent> {
const updated = await this.syncExecutionTaskLink(agentId, taskId);
// Emit agent:assigned only when assigning a task (not when clearing)
if (taskId !== undefined) {
this.emit("agent:assigned", updated, taskId);
}
// Log the assignment to the task when a non-empty taskId is provided
if (taskId && this.taskStore) {
await this.taskStore.logEntry(taskId, `Task assigned to agent ${agentId}`, undefined, runContext);
}
return updated;
}
/**
* Synchronize execution task ownership on an agent without firing
* assignment-side effects (`agent:assigned`, task assignment logs).
*
* Used by runtime execution bookkeeping so durable assigned agents can
* reflect active task ownership without triggering heartbeat assignment wakeups.
*/
async syncExecutionTaskLink(agentId: string, taskId: string | undefined): Promise<Agent> {
return this.withLock(agentId, async () => { return this.withLock(agentId, async () => {
const agent = await this.getAgent(agentId); const agent = await this.getAgent(agentId);
if (!agent) { if (!agent) {
@@ -1199,17 +1222,6 @@ export class AgentStore extends EventEmitter {
await this.writeAgent(updated); await this.writeAgent(updated);
this.emit("agent:updated", updated); this.emit("agent:updated", updated);
// Emit agent:assigned only when assigning a task (not when clearing)
if (taskId !== undefined) {
this.emit("agent:assigned", updated, taskId);
}
// Log the assignment to the task when a non-empty taskId is provided
if (taskId && this.taskStore) {
await this.taskStore.logEntry(taskId, `Task assigned to agent ${agentId}`, undefined, runContext);
}
return updated; return updated;
}); });
} }

View File

@@ -258,6 +258,11 @@
padding-bottom: var(--space-md); padding-bottom: var(--space-md);
} }
.dashboard-summary-health-reason {
margin-left: var(--space-xs);
font-size: calc(var(--space-sm) + var(--space-xs));
}
.dashboard-summary-card { .dashboard-summary-card {
background: var(--card); background: var(--card);
border: 1px solid var(--border); border: 1px solid var(--border);

View File

@@ -1393,7 +1393,7 @@ function RunsTab({
</button> </button>
)} )}
<span className={cn("run-status", run.status)}> <span className={cn("run-status", run.status)}>
<StatusIcon size={14} className={statusInfo.color} /> <StatusIcon size={14} style={{ color: statusInfo.color }} />
{run.status} {run.status}
</span> </span>
{run.heartbeatProcedureSource === "custom" && ( {run.heartbeatProcedureSource === "custom" && (

View File

@@ -787,6 +787,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
handleOpenNewAgent(); handleOpenNewAgent();
setIsControlsPanelOpen(false); setIsControlsPanelOpen(false);
}} }}
aria-label="New Agent"
title="New Agent"
> >
<Plus size={16} /> <Plus size={16} />
New Agent New Agent

View File

@@ -596,7 +596,35 @@ describe("InProcessRuntime", () => {
}); });
}, 30000); }, 30000);
it("creates runtime task-worker agents with disabled heartbeat metadata and running state", async () => { it("reuses assigned durable agent as execution owner without creating a task-worker", async () => {
await runtime.start();
const store = getAgentStore(runtime);
const durable = await store.createAgent({ name: "Durable Exec", role: "executor" });
const createAgentSpy = vi.spyOn(store, "createAgent");
const assignTaskSpy = vi.spyOn(store, "assignTask");
const syncLinkSpy = vi.spyOn(store, "syncExecutionTaskLink");
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
onStart?: (task: Task, worktreePath: string) => void;
};
executorOptions.onStart?.({ id: "FN-1661", assignedAgentId: durable.id } as Task, join(testDir, "worktree-FN-1661"));
await vi.waitFor(async () => {
const updated = await store.getAgent(durable.id);
expect(updated?.taskId).toBe("FN-1661");
expect(updated?.state).toBe("running");
});
expect(syncLinkSpy).toHaveBeenCalledWith(durable.id, "FN-1661");
expect(assignTaskSpy).not.toHaveBeenCalledWith(durable.id, "FN-1661");
expect(createAgentSpy).not.toHaveBeenCalledWith(expect.objectContaining({ name: "executor-FN-1661" }));
const agents = await store.listAgents({ includeEphemeral: true });
expect(agents.some((agent: Agent) => agent.name === "executor-FN-1661")).toBe(false);
}, 30000);
it("falls back to runtime task-worker agents for unassigned tasks", async () => {
await runtime.start(); await runtime.start();
const store = getAgentStore(runtime); const store = getAgentStore(runtime);
@@ -635,25 +663,29 @@ describe("InProcessRuntime", () => {
expect(assignTaskSpy.mock.invocationCallOrder[0]).toBeLessThan(updateStateSpy.mock.invocationCallOrder[0]); expect(assignTaskSpy.mock.invocationCallOrder[0]).toBeLessThan(updateStateSpy.mock.invocationCallOrder[0]);
}, 30000); }, 30000);
it("does not create duplicate task-worker agents when onStart fires twice for one task", async () => { it("falls back to runtime task-worker when assignedAgentId points to ephemeral agent", async () => {
await runtime.start(); await runtime.start();
const store = getAgentStore(runtime); const store = getAgentStore(runtime);
const ephemeral = await store.createAgent({
name: "Spawned Child",
role: "executor",
metadata: { agentKind: "task-worker", managedBy: "task-executor" },
runtimeConfig: { enabled: false },
});
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as { const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
onStart?: (task: Task, worktreePath: string) => void; onStart?: (task: Task, worktreePath: string) => void;
}; };
executorOptions.onStart?.({ id: "FN-1662", assignedAgentId: ephemeral.id } as Task, join(testDir, "worktree-FN-1662"));
executorOptions.onStart?.({ id: "FN-DUP-ONSTART" } as Task, join(testDir, "worktree-FN-DUP-ONSTART"));
executorOptions.onStart?.({ id: "FN-DUP-ONSTART" } as Task, join(testDir, "worktree-FN-DUP-ONSTART"));
await vi.waitFor(async () => { await vi.waitFor(async () => {
const agents = await store.listAgents({ includeEphemeral: true }); const agents = await store.listAgents({ includeEphemeral: true });
const matching = agents.filter((agent: Agent) => agent.name === "executor-FN-DUP-ONSTART"); expect(agents.some((agent: Agent) => agent.name === "executor-FN-1662")).toBe(true);
expect(matching).toHaveLength(1);
}); });
}, 30000); }, 30000);
it("does not wake executeHeartbeat for runtime task-worker assignment events", async () => { it("does not wake executeHeartbeat for runtime ownership sync of durable assigned agents", async () => {
await runtime.start(); await runtime.start();
const monitor = runtime.getHeartbeatMonitor(); const monitor = runtime.getHeartbeatMonitor();
@@ -664,22 +696,56 @@ describe("InProcessRuntime", () => {
.spyOn(heartbeatMonitor, "executeHeartbeat") .spyOn(heartbeatMonitor, "executeHeartbeat")
.mockResolvedValue(executeResult); .mockResolvedValue(executeResult);
const store = getAgentStore(runtime);
const durable = await store.createAgent({ name: "Owned Exec", role: "executor" });
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as { const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
onStart?: (task: Task, worktreePath: string) => void; onStart?: (task: Task, worktreePath: string) => void;
}; };
executorOptions.onStart?.({ id: "FN-2001" } as Task, join(testDir, "worktree-FN-2001")); executorOptions.onStart?.({ id: "FN-2001", assignedAgentId: durable.id } as Task, join(testDir, "worktree-FN-2001"));
const store = getAgentStore(runtime);
await vi.waitFor(async () => { await vi.waitFor(async () => {
const agents = await store.listAgents({ includeEphemeral: true }); const updated = await store.getAgent(durable.id);
expect(agents.some((agent: Agent) => agent.name === "executor-FN-2001")).toBe(true); expect(updated?.taskId).toBe("FN-2001");
}); });
await new Promise((resolve) => setTimeout(resolve, 25)); await new Promise((resolve) => setTimeout(resolve, 25));
expect(executeSpy).not.toHaveBeenCalled(); expect(executeSpy).not.toHaveBeenCalled();
}, 30000); }, 30000);
it("cleans up durable execution owner on completion without deleting agent", async () => {
vi.useFakeTimers();
try {
await runtime.start();
const store = getAgentStore(runtime);
const durable = await store.createAgent({ name: "Durable Cleanup", role: "executor" });
const deleteAgentSpy = vi.spyOn(store, "deleteAgent").mockResolvedValue(undefined);
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
onStart?: (task: Task, worktreePath: string) => void;
onComplete?: (task: Task) => void;
};
executorOptions.onStart?.({ id: "FN-DURABLE-1", assignedAgentId: durable.id } as Task, join(testDir, "worktree-FN-DURABLE-1"));
await vi.waitFor(async () => {
const updated = await store.getAgent(durable.id);
expect(updated?.taskId).toBe("FN-DURABLE-1");
});
executorOptions.onComplete?.({ id: "FN-DURABLE-1" } as Task);
await vi.advanceTimersByTimeAsync(5000);
const updated = await store.getAgent(durable.id);
expect(updated?.state).toBe("terminated");
expect(updated?.taskId).toBeUndefined();
expect(deleteAgentSpy).not.toHaveBeenCalledWith(durable.id);
} finally {
vi.useRealTimers();
}
}, 30000);
it("auto-deletes task-worker agent on task completion after 5 second delay", async () => { it("auto-deletes task-worker agent on task completion after 5 second delay", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -720,6 +786,39 @@ describe("InProcessRuntime", () => {
} }
}, 30000); }, 30000);
it("cleans up durable execution owner on error without deleting agent", async () => {
vi.useFakeTimers();
try {
await runtime.start();
const store = getAgentStore(runtime);
const durable = await store.createAgent({ name: "Durable Error", role: "executor" });
const deleteAgentSpy = vi.spyOn(store, "deleteAgent").mockResolvedValue(undefined);
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
onStart?: (task: Task, worktreePath: string) => void;
onError?: (task: Task, error: Error) => void;
};
executorOptions.onStart?.({ id: "FN-DURABLE-2", assignedAgentId: durable.id } as Task, join(testDir, "worktree-FN-DURABLE-2"));
await vi.waitFor(async () => {
const updated = await store.getAgent(durable.id);
expect(updated?.taskId).toBe("FN-DURABLE-2");
});
executorOptions.onError?.({ id: "FN-DURABLE-2" } as Task, new Error("boom"));
await vi.advanceTimersByTimeAsync(5000);
const updated = await store.getAgent(durable.id);
expect(updated?.state).toBe("terminated");
expect(updated?.taskId).toBeUndefined();
expect(deleteAgentSpy).not.toHaveBeenCalledWith(durable.id);
} finally {
vi.useRealTimers();
}
}, 30000);
it("auto-deletes task-worker agent on task error after 5 second delay", async () => { it("auto-deletes task-worker agent on task error after 5 second delay", async () => {
vi.useFakeTimers(); vi.useFakeTimers();

View File

@@ -92,8 +92,8 @@ export class InProcessRuntime
private agentStore?: AgentStore; private agentStore?: AgentStore;
private heartbeatMonitor?: HeartbeatMonitor; private heartbeatMonitor?: HeartbeatMonitor;
private triggerScheduler?: HeartbeatTriggerScheduler; private triggerScheduler?: HeartbeatTriggerScheduler;
/** Maps task IDs to agent IDs for lifecycle tracking */ /** Maps task IDs to execution owner metadata for lifecycle tracking */
private taskAgentMap = new Map<string, string>(); private taskAgentMap = new Map<string, { agentId: string; ephemeral: boolean }>();
private lastActivityAt: string = new Date().toISOString(); private lastActivityAt: string = new Date().toISOString();
private pluginRunner?: PluginRunner; private pluginRunner?: PluginRunner;
private pluginStore?: PluginStore; private pluginStore?: PluginStore;
@@ -373,53 +373,77 @@ export class InProcessRuntime
onStart: (task, worktreePath) => { onStart: (task, worktreePath) => {
this.recordActivity(); this.recordActivity();
runtimeLog.log(`Started executing task ${task.id} in ${worktreePath}`); runtimeLog.log(`Started executing task ${task.id} in ${worktreePath}`);
// Create a runtime-managed task worker agent for lifecycle tracking. if (!this.agentStore) return;
// These workers are not heartbeat-managed dashboard agents, so mark them
// explicitly and disable heartbeat triggers/timers.
if (this.agentStore) {
if (this.taskAgentMap.has(task.id)) {
runtimeLog.warn(`Skipping task-worker creation for ${task.id}: agent already exists (${this.taskAgentMap.get(task.id)})`);
return;
}
this.taskAgentMap.set(task.id, "creating"); void (async () => {
this.agentStore.createAgent({ try {
name: `executor-${task.id}`, const assignedAgentId = task.assignedAgentId;
role: "executor", if (assignedAgentId) {
metadata: { const assignedAgent = await this.agentStore!.getAgent(assignedAgentId);
agentKind: "task-worker", if (assignedAgent && !isEphemeralAgent(assignedAgent)) {
taskWorker: true, this.taskAgentMap.set(task.id, { agentId: assignedAgent.id, ephemeral: false });
managedBy: "task-executor", await this.agentStore!.syncExecutionTaskLink(assignedAgent.id, task.id);
}, const currentState = assignedAgent.state;
runtimeConfig: { if (currentState !== "running") {
enabled: false, if (currentState !== "active") {
}, await this.agentStore!.updateAgentState(assignedAgent.id, "active");
}).then(async (agent: { id: string }) => { }
this.taskAgentMap.set(task.id, agent.id); await this.agentStore!.updateAgentState(assignedAgent.id, "running");
}
return;
}
}
if (this.taskAgentMap.has(task.id)) {
runtimeLog.warn(`Skipping task-worker creation for ${task.id}: task already has execution owner`);
return;
}
// Create a runtime-managed task worker agent for lifecycle tracking.
// These workers are not heartbeat-managed dashboard agents, so mark them
// explicitly and disable heartbeat triggers/timers.
const agent = await this.agentStore!.createAgent({
name: `executor-${task.id}`,
role: "executor",
metadata: {
agentKind: "task-worker",
taskWorker: true,
managedBy: "task-executor",
},
runtimeConfig: {
enabled: false,
},
});
this.taskAgentMap.set(task.id, { agentId: agent.id, ephemeral: true });
await this.agentStore!.assignTask(agent.id, task.id); await this.agentStore!.assignTask(agent.id, task.id);
await this.agentStore!.updateAgentState(agent.id, "active"); await this.agentStore!.updateAgentState(agent.id, "active");
await this.agentStore!.updateAgentState(agent.id, "running"); await this.agentStore!.updateAgentState(agent.id, "running");
}).catch((err: unknown) => { } catch (err: unknown) {
this.taskAgentMap.delete(task.id); runtimeLog.warn(`Failed to initialize execution owner for task ${task.id}:`, err);
runtimeLog.warn(`Failed to create agent for task ${task.id}:`, err); }
}); })();
}
}, },
onComplete: (task) => { onComplete: (task) => {
this.recordActivity(); this.recordActivity();
runtimeLog.log(`Completed task ${task.id}`); runtimeLog.log(`Completed task ${task.id}`);
this.recordTaskCompletion(task.id, true); this.recordTaskCompletion(task.id, true);
// Update agent state to terminated (completed) // Update agent state to terminated (completed)
const agentId = this.taskAgentMap.get(task.id); const owner = this.taskAgentMap.get(task.id);
if (agentId && this.agentStore) { if (owner && this.agentStore) {
// Register pending deletion before flipping to terminated so const { agentId, ephemeral } = owner;
// agent:stateChanged listener doesn't schedule duplicate cleanup. if (ephemeral) {
this.pendingEphemeralDeletions.add(agentId); this.pendingEphemeralDeletions.add(agentId);
}
void this.agentStore.updateAgentState(agentId, "terminated").catch((err: unknown) => { void this.agentStore.updateAgentState(agentId, "terminated").catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
runtimeLog.warn(`Failed to update agent ${agentId} state to terminated (completion): ${msg}`); runtimeLog.warn(`Failed to update agent ${agentId} state to terminated (completion): ${msg}`);
}); });
void this.agentStore.syncExecutionTaskLink(agentId, undefined).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
runtimeLog.warn(`Failed to clear execution task link for agent ${agentId} on completion: ${msg}`);
});
this.taskAgentMap.delete(task.id); this.taskAgentMap.delete(task.id);
if (!ephemeral) return;
// Auto-delete the task-worker agent after a short delay so the UI // Auto-delete the task-worker agent after a short delay so the UI
// can observe the terminal state before the agent is removed. // can observe the terminal state before the agent is removed.
const timerId = setTimeout(async () => { const timerId = setTimeout(async () => {
@@ -459,16 +483,22 @@ export class InProcessRuntime
} }
// Update agent state to terminated (failed) // Update agent state to terminated (failed)
const agentId = this.taskAgentMap.get(task.id); const owner = this.taskAgentMap.get(task.id);
if (agentId && this.agentStore) { if (owner && this.agentStore) {
// Register pending deletion before flipping to terminated so const { agentId, ephemeral } = owner;
// agent:stateChanged listener doesn't schedule duplicate cleanup. if (ephemeral) {
this.pendingEphemeralDeletions.add(agentId); this.pendingEphemeralDeletions.add(agentId);
}
void this.agentStore.updateAgentState(agentId, "terminated").catch((err: unknown) => { void this.agentStore.updateAgentState(agentId, "terminated").catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
runtimeLog.warn(`Failed to update agent ${agentId} state to terminated (error): ${msg}`); runtimeLog.warn(`Failed to update agent ${agentId} state to terminated (error): ${msg}`);
}); });
void this.agentStore.syncExecutionTaskLink(agentId, undefined).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
runtimeLog.warn(`Failed to clear execution task link for agent ${agentId} on error: ${msg}`);
});
this.taskAgentMap.delete(task.id); this.taskAgentMap.delete(task.id);
if (!ephemeral) return;
// Auto-delete the task-worker agent after a short delay so the UI // Auto-delete the task-worker agent after a short delay so the UI
// can observe the terminal state before the agent is removed. // can observe the terminal state before the agent is removed.
const timerId = setTimeout(async () => { const timerId = setTimeout(async () => {