feat(FN-986): add agent stop/start CLI commands and extension tools

- Add `fn agent stop <id>` and `fn agent start <id>` CLI commands for pausing/resuming agents
- Add `kb_agent_stop` and `kb_agent_start` tools to the pi extension for in-session agent control
- Validate state transitions using AGENT_VALID_TRANSITIONS before applying changes
- Export AgentStore from @fusion/core public API
- Add comprehensive tests for agent CLI commands (210 lines)
- Add changeset for @gsxdsm/fusion patch release
This commit is contained in:
gsxdsm
2026-04-05 16:15:22 -07:00
parent 11dfcaf7e2
commit f6bd237f46
11 changed files with 498 additions and 0 deletions

View File

@@ -103,6 +103,13 @@ fn task import owner/repo # Import GitHub issues as tasks
fn task import owner/repo --limit 10 --labels "bug,enhancement"
```
### Agent control
```bash
fn agent stop <agent-id> # Stop (pause) a running agent
fn agent start <agent-id> # Start (resume) a stopped agent
```
### Typical workflow
```bash

View File

@@ -25,6 +25,7 @@ Mission → Milestone → Slice → Feature → Task
- **Task tools** — `kb_task_create`, `kb_task_update`, `kb_task_list`, `kb_task_show`, `kb_task_attach`, `kb_task_pause`, `kb_task_unpause`, `kb_task_retry`, `kb_task_duplicate`, `kb_task_refine`, `kb_task_archive`, `kb_task_unarchive`, `kb_task_delete`, `kb_task_plan`
- **GitHub tools** — `kb_task_import_github`, `kb_task_import_github_issue`, `kb_task_browse_github_issues`
- **Mission tools** — `kb_mission_create`, `kb_mission_list`, `kb_mission_show`, `kb_mission_delete`, `kb_milestone_add`, `kb_slice_add`, `kb_feature_add`, `kb_slice_activate`, `kb_feature_link_task`
- **Agent tools** — `kb_agent_stop`, `kb_agent_start`
- **Dashboard** — Use `/fn` command to start/stop the dashboard
</essential_principles>

View File

@@ -246,6 +246,22 @@ Link a feature to a kb task. Updates feature status to triaged.
| `featureId` | string | ✓ | Feature ID (e.g., F-001) |
| `taskId` | string | ✓ | Task ID (e.g., KB-001) |
### kb_agent_stop
Stop (pause) a running agent. Transitions the agent from running/active to paused state.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Agent ID to stop (e.g., agent-abc123) |
### kb_agent_start
Start (resume) a stopped agent. Transitions the agent from paused to active state.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Agent ID to start (e.g., agent-abc123) |
## Dashboard Command
### /fn

View File

@@ -35,6 +35,8 @@ Triage → Todo → In Progress → In Review → Done → Archived
| `kb_feature_add` | Add a feature to a slice |
| `kb_slice_activate` | Activate a pending slice |
| `kb_feature_link_task` | Link a feature to a task |
| `kb_agent_stop` | Stop (pause) a running agent |
| `kb_agent_start` | Start (resume) a stopped agent |
## CLI Commands (fn)

View File

@@ -101,6 +101,9 @@ describe("kb pi extension", () => {
"kb_feature_add",
"kb_slice_activate",
"kb_feature_link_task",
// Agent tools
"kb_agent_stop",
"kb_agent_start",
];
for (const name of expected) {

View File

@@ -48,6 +48,7 @@ const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = a
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
const { runInit } = await import("./commands/init.js");
const { runAgentStop, runAgentStart } = await import("./commands/agent.js");
const HELP = `
fn — AI-orchestrated task board
@@ -106,6 +107,8 @@ Usage:
fn git push Push current branch
fn git pull Pull current branch
fn git fetch [remote] Fetch from remote (default: origin)
fn agent stop <id> Stop a running agent (pause execution)
fn agent start <id> Start a stopped agent (resume execution)
fn backup --create Create a database backup immediately
fn backup --list List all database backups
fn backup --restore <file> Restore database from a backup file
@@ -728,6 +731,29 @@ async function main() {
break;
}
case "agent": {
const subcommand = args[1];
switch (subcommand) {
case "stop": {
const id = args[2];
if (!id) { console.error("Usage: fn agent stop <id>"); process.exit(1); }
await runAgentStop(id, projectName);
break;
}
case "start": {
const id = args[2];
if (!id) { console.error("Usage: fn agent start <id>"); process.exit(1); }
await runAgentStart(id, projectName);
break;
}
default:
console.error(`Unknown subcommand: agent ${subcommand || ""}`);
console.log("Try: fn agent stop <id> | fn agent start <id>");
process.exit(1);
}
break;
}
default:
console.error(`Unknown command: ${command}`);
console.log(HELP);

View File

@@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── Mock AgentStore ──────────────────────────────────────────────────
const mockGetAgent = vi.fn();
const mockUpdateAgentState = vi.fn();
const mockInit = vi.fn().mockResolvedValue(undefined);
// AgentStore mock — vi.fn() with mockImplementation works with `new` in vitest.
// We return a plain object from the constructor which becomes the instance.
vi.mock("@fusion/core", () => ({
AgentStore: vi.fn().mockImplementation(() => ({
init: mockInit,
getAgent: mockGetAgent,
updateAgentState: mockUpdateAgentState,
})),
AGENT_VALID_TRANSITIONS: {
idle: ["active"],
active: ["running", "paused", "terminated"],
running: ["active", "paused", "error", "terminated"],
paused: ["active", "terminated"],
error: ["active", "terminated"],
terminated: ["idle", "active", "running"],
},
}));
// ── Mock project-context ─────────────────────────────────────────────
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn().mockResolvedValue({
projectId: "test-project",
projectPath: "/tmp/test-project",
projectName: "test-project",
isRegistered: true,
store: {},
}),
}));
// ── Spies ────────────────────────────────────────────────────────────
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as any);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// ── Import after mocks ───────────────────────────────────────────────
import { runAgentStop, runAgentStart } from "../agent.js";
function makeAgent(state: string) {
return {
id: "agent-test123",
name: "test-agent",
role: "executor" as const,
state,
taskId: undefined,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
};
}
// ── Tests ────────────────────────────────────────────────────────────
describe("runAgentStop", () => {
beforeEach(() => {
mockGetAgent.mockResolvedValue(makeAgent("running"));
mockUpdateAgentState.mockResolvedValue(makeAgent("paused"));
mockInit.mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
it("should stop a running agent", async () => {
await runAgentStop("agent-test123");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "paused");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 stopped"));
});
it("should stop an active agent", async () => {
mockGetAgent.mockResolvedValue(makeAgent("active"));
await runAgentStop("agent-test123");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "paused");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 stopped"));
});
it("should report when agent is not found", async () => {
mockGetAgent.mockResolvedValue(null);
await expect(runAgentStop("agent-nonexistent")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("agent-nonexistent not found"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("should report when agent is already paused", async () => {
mockGetAgent.mockResolvedValue(makeAgent("paused"));
await runAgentStop("agent-test123");
// Should NOT call updateAgentState
expect(mockUpdateAgentState).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("already paused"));
});
it("should reject stopping an idle agent (invalid transition)", async () => {
mockGetAgent.mockResolvedValue(makeAgent("idle"));
await expect(runAgentStop("agent-test123")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("cannot transition to 'paused'"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("should reject stopping an error agent (invalid transition)", async () => {
mockGetAgent.mockResolvedValue(makeAgent("error"));
await expect(runAgentStop("agent-test123")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("cannot transition to 'paused'"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("should reject stopping a terminated agent (invalid transition)", async () => {
mockGetAgent.mockResolvedValue(makeAgent("terminated"));
await expect(runAgentStop("agent-test123")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("cannot transition to 'paused'"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe("runAgentStart", () => {
beforeEach(() => {
mockGetAgent.mockResolvedValue(makeAgent("paused"));
mockUpdateAgentState.mockResolvedValue(makeAgent("active"));
mockInit.mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
it("should start a paused agent", async () => {
await runAgentStart("agent-test123");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "active");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 started"));
});
it("should start a terminated agent", async () => {
mockGetAgent.mockResolvedValue(makeAgent("terminated"));
mockUpdateAgentState.mockResolvedValue(makeAgent("active"));
await runAgentStart("agent-test123");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "active");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 started"));
});
it("should start an idle agent", async () => {
mockGetAgent.mockResolvedValue(makeAgent("idle"));
mockUpdateAgentState.mockResolvedValue(makeAgent("active"));
await runAgentStart("agent-test123");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "active");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 started"));
});
it("should start an error agent", async () => {
mockGetAgent.mockResolvedValue(makeAgent("error"));
mockUpdateAgentState.mockResolvedValue(makeAgent("active"));
await runAgentStart("agent-test123");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "active");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 started"));
});
it("should report when agent is not found", async () => {
mockGetAgent.mockResolvedValue(null);
await expect(runAgentStart("agent-nonexistent")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("agent-nonexistent not found"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("should report when agent is already active", async () => {
mockGetAgent.mockResolvedValue(makeAgent("active"));
await runAgentStart("agent-test123");
expect(mockUpdateAgentState).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("already running"));
});
it("should report when agent is already running", async () => {
mockGetAgent.mockResolvedValue(makeAgent("running"));
await runAgentStart("agent-test123");
expect(mockUpdateAgentState).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("already running"));
});
});

View File

@@ -0,0 +1,101 @@
import { AgentStore, AGENT_VALID_TRANSITIONS } from "@fusion/core";
import type { AgentState } from "@fusion/core";
import { resolveProject } from "../project-context.js";
/**
* Get the project path for agent operations.
* Falls back to process.cwd() if no project is specified.
*/
async function getProjectPath(projectName?: string): Promise<string> {
if (projectName) {
const context = await resolveProject(projectName);
return context.projectPath;
}
try {
const context = await resolveProject(undefined);
return context.projectPath;
} catch {
return process.cwd();
}
}
/**
* Create an initialized AgentStore for the given project.
*/
async function createAgentStore(projectName?: string): Promise<AgentStore> {
const projectPath = await getProjectPath(projectName);
const agentStore = new AgentStore({ rootDir: projectPath + "/.fusion" });
await agentStore.init();
return agentStore;
}
/**
* Stop (pause) a running agent.
* Transitions state from running/active to paused.
*/
export async function runAgentStop(id: string, projectName?: string): Promise<void> {
const agentStore = await createAgentStore(projectName);
const agent = await agentStore.getAgent(id);
if (!agent) {
console.error(`Agent ${id} not found`);
process.exit(1);
}
// Already paused — nothing to do
if (agent.state === "paused") {
console.log();
console.log(` Agent ${id} is already paused`);
console.log();
return;
}
// Validate transition locally
const validTargets = AGENT_VALID_TRANSITIONS[agent.state as AgentState];
if (!validTargets || !validTargets.includes("paused")) {
console.error(`Cannot stop agent ${id} — current state '${agent.state}' cannot transition to 'paused'`);
process.exit(1);
}
await agentStore.updateAgentState(id, "paused");
console.log();
console.log(` ✓ Agent ${id} stopped`);
console.log();
}
/**
* Start (resume) a stopped/paused agent.
* Transitions state from paused to active.
*/
export async function runAgentStart(id: string, projectName?: string): Promise<void> {
const agentStore = await createAgentStore(projectName);
const agent = await agentStore.getAgent(id);
if (!agent) {
console.error(`Agent ${id} not found`);
process.exit(1);
}
// Already active/running — nothing to do
if (agent.state === "active" || agent.state === "running") {
console.log();
console.log(` Agent ${id} is already running (${agent.state})`);
console.log();
return;
}
// Validate transition locally
const validTargets = AGENT_VALID_TRANSITIONS[agent.state as AgentState];
if (!validTargets || !validTargets.includes("active")) {
console.error(`Cannot start agent ${id} — current state '${agent.state}' cannot transition to 'active'`);
process.exit(1);
}
await agentStore.updateAgentState(id, "active");
console.log();
console.log(` ✓ Agent ${id} started`);
console.log();
}

View File

@@ -1519,6 +1519,132 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
// ── kb_agent_stop ─────────────────────────────────────────────────
pi.registerTool({
name: "kb_agent_stop",
label: "KB: Stop Agent",
description:
"Stop a running agent — pauses its execution. " +
"Transitions the agent from running/active to paused state.",
promptSnippet: "Stop (pause) a running Fusion agent",
promptGuidelines: [
"Use to pause an agent that is currently running or active",
"Stopped agents can be resumed with kb_agent_start",
"Agents in 'idle', 'error', or 'terminated' state cannot be stopped",
],
parameters: Type.Object({
id: Type.String({ description: "Agent ID to stop (e.g., agent-abc123)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore, AGENT_VALID_TRANSITIONS } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: ctx.cwd + "/.fusion" });
await agentStore.init();
const agent = await agentStore.getAgent(params.id);
if (!agent) {
return {
content: [{ type: "text", text: `Agent ${params.id} not found` }],
isError: true,
details: { error: "Agent not found" },
};
}
if (agent.state === "paused") {
return {
content: [{ type: "text", text: `Agent ${params.id} is already paused` }],
details: { agentId: params.id, state: agent.state },
};
}
const validTargets = AGENT_VALID_TRANSITIONS[agent.state];
if (!validTargets.includes("paused")) {
return {
content: [
{
type: "text",
text: `Cannot stop agent ${params.id} — current state '${agent.state}' cannot transition to 'paused'. Valid transitions: ${validTargets.join(", ")}`,
},
],
isError: true,
details: { agentId: params.id, currentState: agent.state, validTargets },
};
}
await agentStore.updateAgentState(params.id, "paused");
return {
content: [{ type: "text", text: `Stopped ${params.id}` }],
details: { agentId: params.id, previousState: agent.state, newState: "paused" },
};
},
});
// ── kb_agent_start ────────────────────────────────────────────────
pi.registerTool({
name: "kb_agent_start",
label: "KB: Start Agent",
description:
"Start a stopped agent — resumes its execution. " +
"Transitions the agent from paused to active state.",
promptSnippet: "Start (resume) a stopped Fusion agent",
promptGuidelines: [
"Use to resume an agent that has been paused",
"Only agents in 'paused' state can be started",
"Agents in 'idle' or 'error' state cannot be started — use reset instead",
],
parameters: Type.Object({
id: Type.String({ description: "Agent ID to start (e.g., agent-abc123)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore, AGENT_VALID_TRANSITIONS } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: ctx.cwd + "/.fusion" });
await agentStore.init();
const agent = await agentStore.getAgent(params.id);
if (!agent) {
return {
content: [{ type: "text", text: `Agent ${params.id} not found` }],
isError: true,
details: { error: "Agent not found" },
};
}
if (agent.state === "active" || agent.state === "running") {
return {
content: [{ type: "text", text: `Agent ${params.id} is already running (${agent.state})` }],
details: { agentId: params.id, state: agent.state },
};
}
const validTargets = AGENT_VALID_TRANSITIONS[agent.state];
if (!validTargets.includes("active")) {
return {
content: [
{
type: "text",
text: `Cannot start agent ${params.id} — current state '${agent.state}' cannot transition to 'active'. Valid transitions: ${validTargets.join(", ")}`,
},
],
isError: true,
details: { agentId: params.id, currentState: agent.state, validTargets },
};
}
await agentStore.updateAgentState(params.id, "active");
return {
content: [{ type: "text", text: `Started ${params.id}` }],
details: { agentId: params.id, previousState: agent.state, newState: "active" },
};
},
});
// ── /fn command — start the dashboard + engine ───────────────────
let dashboardProcess: ChildProcess | null = null;