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:
5
.changeset/fn-3249-assigned-executor-ownership.md
Normal file
5
.changeset/fn-3249-assigned-executor-ownership.md
Normal 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.
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" && (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user