diff --git a/.changeset/fn-7138-agent-current-task-context.md b/.changeset/fn-7138-agent-current-task-context.md new file mode 100644 index 0000000000..313f72104b --- /dev/null +++ b/.changeset/fn-7138-agent-current-task-context.md @@ -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. diff --git a/docs/agents.md b/docs/agents.md index b736fc62a3..c40ae0de58 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -35,6 +35,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - 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 diff --git a/docs/architecture.md b/docs/architecture.md index d123fe6108..a48b2161b2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 7b9ffb6bef..2ba1aea82a 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -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)); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 9a15481f5d..9ea0f1178d 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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[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 | 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 | 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); diff --git a/packages/core/src/agent-store.ts b/packages/core/src/agent-store.ts index c3798acf85..d19042324d 100644 --- a/packages/core/src/agent-store.ts +++ b/packages/core/src/agent-store.ts @@ -169,6 +169,20 @@ interface AgentLock { */ export const DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS = 3_600_000; +export function formatCurrentTaskLine(taskId: string, linkedTask: Pick | 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 | 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 { return this.withLock(agentId, async () => { const agent = await this.getAgent(agentId); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 47539548ce..892800f88a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/engine/src/__tests__/agent-tools-delegation.test.ts b/packages/engine/src/__tests__/agent-tools-delegation.test.ts index a01c52ecd4..1408deeee0 100644 --- a/packages/engine/src/__tests__/agent-tools-delegation.test.ts +++ b/packages/engine/src/__tests__/agent-tools-delegation.test.ts @@ -6,6 +6,7 @@ function createMockAgentStore(overrides: Partial = {}): 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 () => { diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index e8cb70cf3a..95ff91030e 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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")}` }],