Files
fusion/packages/engine/src/task-agent-sync.ts
gsxdsm fe536b2af8 FN-6954: reconcile stale parked task assignments
Reconcile agent/task drift when durable agents remain linked to queued tasks without live execution proof.

- clear stale Agent.taskId links for parked todo/triage tasks while preserving task leases and queue state
- report stale parked assignments as active/no-live-run in Reports Health Check before reconciliation completes
- add scheduler and self-healing coverage for queued lease drift, overlap starvation, and audit events
- document the reconciliation behavior and add a published package patch changeset

Files changed:
 .changeset/fn-6954-agent-task-state-drift.md       |   5 +
 docs/architecture.md                               |   2 +
 .../src/__tests__/heartbeat-executor.test.ts       |  68 ++++++++++++
 .../__tests__/scheduler-overlap-starvation.test.ts |  68 +++++++++++-
 .../self-healing-agent-link-drift.test.ts          |  97 ++++++++++++++++-
 .../engine/src/__tests__/task-agent-sync.test.ts   |  33 +++++-
 packages/engine/src/agent-heartbeat.ts             | 121 +++++++++++++++++++--
 packages/engine/src/run-audit.ts                   |   5 +
 packages/engine/src/runtimes/in-process-runtime.ts |   1 +
 packages/engine/src/scheduler.ts                   |  24 +++-
 packages/engine/src/self-healing.ts                | 102 ++++++++++++++---
 packages/engine/src/task-agent-sync.ts             |  65 ++++++++++-
 12 files changed, 555 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-6954
Fusion-Task-Lineage: 24b8a2eb-5a33-4539-ab64-ae2bbfc2d195
2026-06-23 10:30:50 -07:00

105 lines
3.8 KiB
TypeScript

import type { Agent, AgentHeartbeatRun, AgentStore, Task, TaskStore } from "@fusion/core";
export const PARKED_AGENT_LINK_FRESH_RUN_MS = 5 * 60_000;
export interface AgentTaskLinkExecutionProof {
hasFreshRun: boolean;
hasActiveExecution: boolean;
shouldPreserveParkedLink: boolean;
runAgeMs: number;
}
export function hasFreshActiveHeartbeatRun(
activeRun: AgentHeartbeatRun | null | undefined,
now = Date.now(),
freshRunMs = PARKED_AGENT_LINK_FRESH_RUN_MS,
): { hasFreshRun: boolean; runAgeMs: number } {
const runStartedAt = activeRun?.startedAt;
const runAgeMs = runStartedAt ? now - Date.parse(runStartedAt) : Number.POSITIVE_INFINITY;
return {
hasFreshRun: Boolean(activeRun) && Number.isFinite(runAgeMs) && runAgeMs <= freshRunMs,
runAgeMs,
};
}
export function isParkedTaskColumn(task: Pick<Task, "column"> | null | undefined): boolean {
return task?.column === "todo" || task?.column === "triage";
}
export function evaluateParkedAgentTaskLink(options: {
agent: Pick<Agent, "id" | "taskId">;
linkedTask: Pick<Task, "column"> | null | undefined;
activeRun?: AgentHeartbeatRun | null;
hasActiveAgentExecution?: (agentId: string) => boolean;
now?: number;
}): AgentTaskLinkExecutionProof {
const { hasFreshRun, runAgeMs } = hasFreshActiveHeartbeatRun(options.activeRun, options.now);
const hasActiveExecution = options.hasActiveAgentExecution?.(options.agent.id) === true;
/*
FNXC:AgentTaskStateDrift 2026-06-23-08:33:
Agent.taskId is a running assignment for parked todo/triage tasks only when the agent has live execution proof: a fresh active heartbeat run or an executor-active signal. File-scope overlapBlockedBy keeps the task queued but never proves the blocked task itself is executing.
*/
return {
hasFreshRun,
hasActiveExecution,
shouldPreserveParkedLink: isParkedTaskColumn(options.linkedTask) && (hasFreshRun || hasActiveExecution),
runAgeMs,
};
}
type LoggerLike = { log: (msg: string) => void; warn: (msg: string) => void };
export interface AttachAgentLinkSyncOptions {
store: TaskStore;
agentStore: AgentStore;
hasActiveAgentExecution?: (agentId: string) => boolean;
logger?: LoggerLike;
}
const CLEAR_COLUMNS = new Set(["done", "archived", "todo", "triage"]);
export function attachAgentLinkSync(opts: AttachAgentLinkSyncOptions): () => void {
const logger: LoggerLike = opts.logger ?? console;
const handler = async ({ task, from, to }: { task: { id: string }; from: string; to: string }) => {
if (!CLEAR_COLUMNS.has(to)) {
return;
}
try {
const agents = await opts.agentStore.listAgents({ includeEphemeral: false });
const linkedAgents = agents.filter((agent) => agent.taskId === task.id);
for (const agent of linkedAgents) {
if (to === "todo" || to === "triage") {
const activeRun = await opts.agentStore.getActiveHeartbeatRun?.(agent.id);
const proof = evaluateParkedAgentTaskLink({
agent,
linkedTask: { column: to } as Pick<Task, "column">,
activeRun,
hasActiveAgentExecution: opts.hasActiveAgentExecution,
});
if (proof.shouldPreserveParkedLink) {
continue;
}
}
if (agent.state === "running") {
await opts.agentStore.updateAgentState(agent.id, "active");
}
await opts.agentStore.syncExecutionTaskLink(agent.id, undefined);
logger.log(`taskAgentLinkSync: cleared agent ${agent.id} taskId from ${task.id} after move ${from} → ${to}`);
}
} catch (error) {
logger.warn(
`taskAgentLinkSync: failed to sync agents for task ${task.id} after move ${from} → ${to}: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
opts.store.on("task:moved", handler);
return () => {
opts.store.off("task:moved", handler);
};
}