FN-7138: show task column context in agent assignments

Agent Current Task output now includes linked task column context to distinguish active execution from parked or stale links.

- Add shared Current Task formatting for active, terminal, and unresolved task links.
- Show linked task columns in engine and CLI agent list/show surfaces.
- Cover parked, active, terminal, and missing task-link display cases with tests.
- Document the durable agent task-link invariant and release the CLI fix.

Files changed:
 .changeset/fn-7138-agent-current-task-context.md   |  7 ++++
 docs/agents.md                                     |  1 +
 docs/architecture.md                               |  2 +-
 packages/cli/src/__tests__/extension.test.ts       | 44 ++++++++++++++++++++++
 packages/cli/src/extension.ts                      | 35 +++++++++++++++--
 packages/core/src/agent-store.ts                   | 30 +++++++++++++++
 packages/core/src/index.ts                         |  2 +-
 .../src/__tests__/agent-tools-delegation.test.ts   | 44 +++++++++++++++++++++-
 packages/engine/src/agent-tools.ts                 | 15 ++++++--
 9 files changed, 169 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-7138
Fusion-Task-Lineage: 0ea97b4c-d4f8-4289-a5b6-e5c51b47b768
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 16:16:25 -07:00
parent 5066f90db2
commit 6f46fa17dd
9 changed files with 169 additions and 11 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show linked task columns in agent Current Task output.
category: fix
dev: Adds shared Current Task formatting for agent list/show tools across engine and CLI surfaces.

View File

@@ -35,6 +35,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
- Agent workflow-routing tools follow an intent boundary: agents may select or change a task workflow only when the user explicitly requested that workflow or when the agent created the task. Executors must not call `fn_workflow_select` to reroute the task they are executing unless the task instructions or a user steering comment explicitly asks for the workflow change.
- Executor, heartbeat, and dashboard chat sessions expose artifact registry tools so agents can publish and inspect multi-type deliverables without relying on the dashboard gallery. Planning sessions intentionally exclude artifact tools until they can thread the existing `MessageStore` dependency.
- Permanent/custom heartbeat agents receive the broad coordination and work-discovery tool surface instead of a narrowly curated subset: read-only task discovery (`fn_task_list`, `fn_task_show`, `fn_task_search`) for work discovery and duplicate avoidance, workflow discovery and authoring (`fn_workflow_list`, `fn_workflow_get`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`), governed research (`fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`), structured clarification (`fn_ask_question`), artifact, memory, messaging, goal, evaluation, identity, and delegation tools. Task-scoped heartbeat sessions also expose current-task workflow selection and promotion (`fn_workflow_select`, `fn_task_promote`); no-task heartbeats omit those because they have no ambient task. Executor-only worktree/workspace tools such as `fn_run_verification` and `fn_acquire_repo_worktree` remain out of the ambient heartbeat lane until that lane owns the required worktree/workspace context. The task read tools are store-backed, text-only, and action-gate-recognized as read-only; dangerous actions are controlled at invocation time by each agent's `AgentPermissionPolicy` through the action gate (allow / require approval / block), not by withholding governed tools from the session.
- `agent.taskId` is an active-execution linkage, not durable ownership. It may legitimately point at a `todo`/`triage` task only while the agent has live run or executor-active proof; task-move sync and self-healing clear stale parked, terminal, or unresolved links otherwise. `fn_list_agents` and `fn_agent_show` therefore include column context in the human-readable `Current Task` line, such as `(triage)`, `(in-progress)`, `(not active — done)`, or `(unresolved)`, so coordinators can distinguish transient planning ownership from drift.
### Artifact registry tools

View File

@@ -683,7 +683,7 @@ Runtime action-gate flow (v1):
- `recoverGhostReviewTasks()` is a fallback only for idle, non-terminal `in-review` states. Terminal/actionable states (notably `status: "failed"`) are preserved and **not** auto-kicked back to `todo`.
- `recoverPausedAbortFailures()` clears executor pause/resume abort parks only when the durable row is safe to recover. `todo`/`in-progress` rows are requeued for normal scheduling, while clean `in-review` rows (completed steps, not paused/user-paused/executing, auto-merge eligible, no confirmed or terminal merge evidence) have `status`/`error` cleared in place so review progression can continue. User hard-cancel, global/user pause, `autoMerge:false`, terminal merge, and live-execution guards remain operator-actionable. Successful recovery emits `task:auto-recover-paused-abort-park` with `preservedInReview` metadata.
- `reattach-orphaned-assigned-executions` is a forward-resume safety net for durable-agent assignments. During startup recovery and periodic maintenance, after orphaned-agent and stale-heartbeat-run repairs, self-healing finds `in-progress` tasks with an `assignedAgentId` whose agent has no active heartbeat run and no active executor session after the orphan grace window. It re-dispatches in place via `executor.resumeTaskForAgent(agentId)` (the same seam used by clean `HeartbeatMonitor.onRunCompleted` and guarded by executor double-execution checks), emits `task:reattach-orphaned-execution`, and never moves the task backward. This complements engine-start `executor.resumeOrphaned()` and leaves unassigned/role-based execution recovery to the existing startup/limbo/stuck-task paths.
- Durable `Agent.taskId` is a running assignment for parked `todo`/`triage` task rows only when the agent has live proof: a fresh active heartbeat run or an executor-active/tracked heartbeat signal. Scheduler overlap requeues, task move sync, self-healing, and Reports Health Check share this invariant: stale durable links are cleared or rendered as stale while `status: "queued"` and `overlapBlockedBy` remain on the task row so file-scope lease blocking is not weakened.
- Durable `Agent.taskId` is a running assignment for parked `todo`/`triage` task rows only when the agent has live proof: a fresh active heartbeat run or an executor-active/tracked heartbeat signal. Scheduler overlap requeues, task move sync, self-healing, and Reports Health Check share this invariant: stale durable links are cleared or rendered as stale while `status: "queued"` and `overlapBlockedBy` remain on the task row so file-scope lease blocking is not weakened. `fn_list_agents` and `fn_agent_show` render the linked task column next to `Current Task` (for example `Current Task: FN-1234 (triage)` or `Current Task: FN-1234 (not active — done)`) so parked-column planning ownership is not misread as in-progress execution drift.
- Mission validation has a dedicated stale-run reaper: startup recovery and Batch 2 maintenance call `reapStaleMissionValidatorRuns()` when wired by the runtime, using `VALIDATOR_RUN_STALE_MAX_AGE_MS` (currently 6 hours). The sweep terminates ownerless `mission_validator_runs.status='running'` rows as `error`, writes the reap reason into `summary`, leaves `lastValidatorRunId` pointing at the now-terminal run, and emits run-audit telemetry with `mutationType: "mission:validator-run-reaped"` plus `runId`/`featureId`/`missionId`/`triggerType`/`elapsedMs` metadata. Active mission features move to `loopState="needs_fix"` + `lastValidatorStatus="error"` unless their parent mission is already `complete`/`archived`.
#### Stuck-loop exhaustion terminal contract

View File

@@ -3904,6 +3904,34 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.details.agents.every((a: any) => a.id !== ephemeralId)).toBe(true);
});
it("shows current task column context for parked, active, terminal, and missing links", async () => {
const store = createStore();
await store.init();
const triageTask = await store.createTask({ description: "Planning link", column: "triage" });
const activeTask = await store.createTask({ description: "Active link", column: "in-progress" });
const doneTask = await store.createTask({ description: "Done link", column: "done" });
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const triageAgent = await agentStore.createAgent({ name: "triage-linked", role: "executor", metadata: {} });
const activeAgent = await agentStore.createAgent({ name: "active-linked", role: "executor", metadata: {} });
const doneAgent = await agentStore.createAgent({ name: "done-linked", role: "executor", metadata: {} });
const missingAgent = await agentStore.createAgent({ name: "missing-linked", role: "executor", metadata: {} });
await agentStore.syncExecutionTaskLink(triageAgent.id, triageTask.id);
await agentStore.syncExecutionTaskLink(activeAgent.id, activeTask.id);
await agentStore.syncExecutionTaskLink(doneAgent.id, doneTask.id);
await agentStore.syncExecutionTaskLink(missingAgent.id, "FN-404404");
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-current-task-context", {}, undefined, undefined, makeCtx(tmpDir));
const text = result.content[0].text;
expect(text).toContain(`Current Task: ${triageTask.id} (triage)`);
expect(text).toContain(`Current Task: ${activeTask.id} (in-progress)`);
expect(text).toContain(`Current Task: ${doneTask.id} (not active — done)`);
expect(text).toContain("Current Task: FN-404404 (unresolved)");
expect(text).not.toMatch(new RegExp(`Current Task: ${triageTask.id}(?! \\()`));
});
it("returns empty list message when no agents", async () => {
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-5", {}, undefined, undefined, makeCtx(tmpDir));
@@ -4219,6 +4247,22 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.details.agent.name).toBe("resolve-by-name");
});
it("shows current task column context for linked agents", async () => {
const store = createStore();
await store.init();
const triageTask = await store.createTask({ description: "Show linked task", column: "triage" });
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const agent = await agentStore.createAgent({ name: "show-linked", role: "executor", metadata: {} });
await agentStore.syncExecutionTaskLink(agent.id, triageTask.id);
const tool = api.tools.get("fn_agent_show")!;
const result = await tool.execute("as-current-task-context", { id: agent.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain(`Current Task: ${triageTask.id} (triage)`);
expect(result.content[0].text).not.toMatch(new RegExp(`Current Task: ${triageTask.id}(?! \\()`));
});
it("returns error for unknown agent", async () => {
const tool = api.tools.get("fn_agent_show")!;
const result = await tool.execute("as-3", { id: "no-such-agent" }, undefined, undefined, makeCtx(tmpDir));

View File

@@ -30,6 +30,7 @@ import {
resolveSecretAccessPolicy,
getProjectRootFromWorktree,
resolveTaskGithubTracking,
formatCurrentTaskLine,
type SecretScope,
} from "@fusion/core";
import {
@@ -4123,6 +4124,7 @@ export default function kbExtension(pi: ExtensionAPI) {
if (params.includeEphemeral !== undefined) filter.includeEphemeral = params.includeEphemeral;
const agents = await agentStore.listAgents(filter as Parameters<typeof agentStore.listAgents>[0]);
const store = await getStore(ctx.cwd);
if (agents.length === 0) {
return {
@@ -4131,7 +4133,7 @@ export default function kbExtension(pi: ExtensionAPI) {
};
}
const lines = agents.map((agent) => {
const lines = await Promise.all(agents.map(async (agent) => {
const parts: string[] = [
`ID: ${agent.id}`,
`Name: ${agent.name}`,
@@ -4145,10 +4147,22 @@ export default function kbExtension(pi: ExtensionAPI) {
const snippet = agent.instructionsText.slice(0, 100);
parts.push(`Custom Instructions: ${snippet}${agent.instructionsText.length > 100 ? "…" : ""}`);
}
if (agent.taskId) parts.push(`Current Task: ${agent.taskId}`);
if (agent.taskId) {
/*
FNXC:AgentTaskStateDrift 2026-06-27-16:05:
Show the linked task column in fn_list_agents so parked triage/todo ownership is not mistaken for an in-progress execution mismatch.
*/
let linkedTask: Pick<Task, "id" | "column"> | null = null;
try {
linkedTask = await store.getTask(agent.taskId);
} catch {
linkedTask = null;
}
parts.push(formatCurrentTaskLine(agent.taskId, linkedTask));
}
return parts.join("\n");
});
}));
return {
content: [{ type: "text" as const, text: `Available agents (${agents.length}):\n\n${lines.join("\n\n")}` }],
@@ -4285,6 +4299,7 @@ export default function kbExtension(pi: ExtensionAPI) {
// Get direct reports
const directReports = await agentStore.getAgentsByReportsTo(agent.id);
const store = await getStore(ctx.cwd);
const parts: string[] = [
`ID: ${agent.id}`,
@@ -4309,7 +4324,19 @@ export default function kbExtension(pi: ExtensionAPI) {
parts.push(`Direct Reports: ${directReports.map((r) => `${r.name} (${r.id})`).join(", ")}`);
}
if (agent.taskId) parts.push(`Current Task: ${agent.taskId}`);
if (agent.taskId) {
/*
FNXC:AgentTaskStateDrift 2026-06-27-16:05:
Show the linked task column in fn_agent_show so parked triage/todo ownership is not mistaken for an in-progress execution mismatch.
*/
let linkedTask: Pick<Task, "id" | "column"> | null = null;
try {
linkedTask = await store.getTask(agent.taskId);
} catch {
linkedTask = null;
}
parts.push(formatCurrentTaskLine(agent.taskId, linkedTask));
}
if (agent.instructionsText) {
const snippet = agent.instructionsText.slice(0, 100);

View File

@@ -169,6 +169,20 @@ interface AgentLock {
*/
export const DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS = 3_600_000;
export function formatCurrentTaskLine(taskId: string, linkedTask: Pick<Task, "column"> | null | undefined): string {
/*
FNXC:AgentTaskStateDrift 2026-06-27-16:05:
FN-7138 requires human-readable agent surfaces to include the linked task column so coordinators can distinguish legitimate transient triage/planning ownership from stale execution drift.
*/
if (!linkedTask) {
return `Current Task: ${taskId} (unresolved)`;
}
if (linkedTask.column === "done" || linkedTask.column === "archived") {
return `Current Task: ${taskId} (not active — ${linkedTask.column})`;
}
return `Current Task: ${taskId} (${linkedTask.column})`;
}
/**
* Compute the runtimeConfig to persist for a newly created agent.
*
@@ -1288,6 +1302,22 @@ export class AgentStore extends EventEmitter {
* Used by runtime execution bookkeeping so durable assigned agents can
* reflect active task ownership without triggering heartbeat assignment wakeups.
*/
async resolveCurrentTaskLink(taskId: string): Promise<Pick<Task, "id" | "column"> | null> {
/*
FNXC:AgentTaskStateDrift 2026-06-27-16:05:
AgentStore owns agent.taskId but may not have a TaskStore in every host. Expose a null-safe lookup for display-only column context without making task lookup a hard dependency.
*/
if (!this.taskStore) {
return null;
}
try {
const task = await this.taskStore.getTask(taskId);
return task ? { id: task.id, column: task.column } : null;
} catch {
return null;
}
}
async syncExecutionTaskLink(agentId: string, taskId: string | undefined): Promise<Agent> {
return this.withLock(agentId, async () => {
const agent = await this.getAgent(agentId);

View File

@@ -469,7 +469,7 @@ export {
validateColumnAgentBindings,
ColumnAgentBindingError,
} from "./column-agent-binding-validation.js";
export { AgentStore, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS } from "./agent-store.js";
export { AgentStore, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS, formatCurrentTaskLine } from "./agent-store.js";
export type { AgentStoreEvents } from "./agent-store.js";
export {
isImplementationTask,

View File

@@ -6,6 +6,7 @@ function createMockAgentStore(overrides: Partial<AgentStore> = {}): AgentStore {
return {
listAgents: vi.fn().mockResolvedValue([]),
getAgent: vi.fn().mockResolvedValue(null),
resolveCurrentTaskLink: vi.fn().mockResolvedValue(null),
...overrides,
} as unknown as AgentStore;
}
@@ -70,7 +71,48 @@ describe("createListAgentsTool", () => {
expect(text).toContain("Name: Bob");
expect(text).toContain("Role: reviewer");
expect(text).toContain("State: running");
expect(text).toContain("Current Task: FN-100");
expect(text).toContain("Current Task: FN-100 (unresolved)");
});
it("shows linked task columns for triage and in-progress task links", async () => {
const agents = [
createAgent({ id: "agent-triage", name: "Planner", taskId: "FN-200" }),
createAgent({ id: "agent-active", name: "Runner", taskId: "FN-201" }),
];
vi.mocked(agentStore.listAgents).mockResolvedValue(agents);
vi.mocked(agentStore.resolveCurrentTaskLink).mockImplementation(async (taskId: string) => {
if (taskId === "FN-200") return { id: taskId, column: "triage" as const };
if (taskId === "FN-201") return { id: taskId, column: "in-progress" as const };
return null;
});
const tool = createListAgentsTool(agentStore);
const result = await tool.execute("session-1", {}, undefined as any, undefined as any, undefined as any);
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Current Task: FN-200 (triage)");
expect(text).toContain("Current Task: FN-201 (in-progress)");
expect(text).not.toMatch(/Current Task: FN-200(?! \()/);
expect(text).not.toMatch(/Current Task: FN-201(?! \()/);
});
it("marks missing and terminal linked tasks without throwing", async () => {
const agents = [
createAgent({ id: "agent-missing", name: "Missing", taskId: "FN-300" }),
createAgent({ id: "agent-done", name: "Done", taskId: "FN-301" }),
];
vi.mocked(agentStore.listAgents).mockResolvedValue(agents);
vi.mocked(agentStore.resolveCurrentTaskLink).mockImplementation(async (taskId: string) => {
if (taskId === "FN-301") return { id: taskId, column: "done" as const };
return null;
});
const tool = createListAgentsTool(agentStore);
const result = await tool.execute("session-1", {}, undefined as any, undefined as any, undefined as any);
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Current Task: FN-300 (unresolved)");
expect(text).toContain("Current Task: FN-301 (not active — done)");
});
it("includes soul truncated to 200 chars when present", async () => {

View File

@@ -13,7 +13,7 @@ import { createHash } from "node:crypto";
import { join, relative, resolve } from "node:path";
import * as fusionCore from "@fusion/core";
import type { AgentState, AgentCapability, AgentUpdateInput, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine } from "@fusion/core";
import { promoteHeldTask } from "./hold-release.js";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
import { ResearchOrchestrator } from "./research-orchestrator.js";
@@ -2897,7 +2897,7 @@ export function createListAgentsTool(agentStore: AgentStore): ToolDefinition {
};
}
const lines = agents.map((agent) => {
const lines = await Promise.all(agents.map(async (agent) => {
const parts: string[] = [
`ID: ${agent.id}`,
`Name: ${agent.name}`,
@@ -2911,10 +2911,17 @@ export function createListAgentsTool(agentStore: AgentStore): ToolDefinition {
const snippet = agent.instructionsText.slice(0, 100);
parts.push(`Custom Instructions: ${snippet}${agent.instructionsText.length > 100 ? "…" : ""}`);
}
if (agent.taskId) parts.push(`Current Task: ${agent.taskId}`);
if (agent.taskId) {
/*
FNXC:AgentTaskStateDrift 2026-06-27-16:05:
Show the linked task column in fn_list_agents so parked triage/todo ownership is not mistaken for an in-progress execution mismatch.
*/
const linkedTask = await agentStore.resolveCurrentTaskLink(agent.taskId);
parts.push(formatCurrentTaskLine(agent.taskId, linkedTask));
}
return parts.join("\n");
});
}));
return {
content: [{ type: "text" as const, text: `Available agents:\n\n${lines.join("\n\n")}` }],