Files
fusion/packages/engine/src/__tests__/cli-agent-executor.test.ts
gsxdsm c1c99a9de0 feat(engine): wire cli-agent executor seam and task session lifecycle (U7)
Add `cli-agent` as a task-execute executor kind. A workflow node with
`config.executor === "cli-agent"` drives an engine-owned CLI coding agent
through the execute step via the new cli-agent/task-session.ts orchestration:
spawn in the worktree, issue the hook token + write hook scripts, inject the
prompt after readiness, subscribe to the state machine, and resolve on a
positive completion signal (R20 gating). Config is snapshotted at launch; the
PTY is reaped (completed) at the in-review handoff.

Executor seam: runGraphCustomNode gains a cli-agent branch delegating to
runCliAgentNode; the hard-cancel/abort path (awaitAbortInFlightTaskWork +
abortAllInFlight) claims and SIGKILLs the CLI session as a first-class surface,
marking it killed (never resume-eligible). Re-entry kills any prior live
session and launches fresh; follow-up resumes the recorded native session id
when supported. A PTY-pool ceiling surfaces as a typed task value, not a stall.

Node-config typing extended minimally (WorkflowNodeExecutorKind /
WorkflowNodeExecutorConfig in @fusion/core).

Tests: cli-agent/__tests__/task-session.test.ts (12) and
__tests__/cli-agent-executor.test.ts (9) cover AE1/AE5, hard cancel, re-entry,
follow-up, config snapshot, ceiling, and the generic-tier confirm-advance path,
using scripted adapters + a mock PTY seam. Engine typecheck clean; full
src/cli-agent suite + new tests green (144 passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:01:12 -07:00

397 lines
16 KiB
TypeScript

import "./executor-test-helpers.js";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { join } from "node:path";
import { tmpdir } from "node:os";
// node:fs is mocked by executor-test-helpers; use node:fs/promises (unmocked) for
// real temp-dir + hook-script I/O.
import { mkdtemp, rm } from "node:fs/promises";
import { Database, CliSessionStore } from "@fusion/core";
import type { IPty } from "node-pty";
import { TaskExecutor, type CliAgentRuntime } from "../executor.js";
import { resetExecutorMocks } from "./executor-test-helpers.js";
import { CliSessionManager } from "../cli-agent/session-manager.js";
import { TelemetryHub } from "../cli-agent/telemetry-hub.js";
import { CliAdapterRegistry, type CliAgentAdapter } from "../cli-agent/adapter.js";
type Listener = (...args: any[]) => void;
// ── Mock PTY ────────────────────────────────────────────────────────────────
interface MockPty extends IPty {
written: string[];
killed: boolean;
killSignal: string | undefined;
emitData(data: string): void;
emitExit(exitCode: number, signal?: number): void;
}
interface MockState {
ptys: MockPty[];
}
function makeMockPtyModule(state: MockState): typeof import("node-pty") {
return {
spawn() {
let dataCb: ((d: string) => void) | undefined;
let exitCb: ((e: { exitCode: number; signal?: number }) => void) | undefined;
const mock: MockPty = {
pid: 3000 + state.ptys.length,
cols: 80,
rows: 24,
process: "mock",
handleFlowControl: false,
written: [],
killed: false,
killSignal: undefined,
onData: (cb: (d: string) => void) => {
dataCb = cb;
return { dispose() {} };
},
onExit: (cb: (e: { exitCode: number; signal?: number }) => void) => {
exitCb = cb;
return { dispose() {} };
},
on() {},
write(data: string) {
mock.written.push(data);
},
resize() {},
clear() {},
kill(signal?: string) {
mock.killed = true;
mock.killSignal = signal;
exitCb?.({ exitCode: 0, signal: signal === "SIGKILL" ? 9 : undefined });
},
pause() {},
resume() {},
emitData(d: string) {
dataCb?.(d);
},
emitExit(exitCode: number, signal?: number) {
exitCb?.({ exitCode, signal });
},
} as any;
state.ptys.push(mock);
return mock as unknown as IPty;
},
} as unknown as typeof import("node-pty");
}
function scriptedAdapter(): CliAgentAdapter {
return {
id: "scripted",
name: "Scripted",
capabilities: { nativeDone: true, nativeWaiting: true, transcriptSource: "hooks", supportsResume: true },
buildLaunch: () => ({ command: "scripted", args: [] }),
buildEnvAllowlist: () => ["PATH"],
createReadinessDetector: () => {
let ready = false;
return {
observe(chunk: string) {
if (chunk.includes("READY")) ready = true;
return ready;
},
};
},
formatInjection: (text) => ({ payload: text.endsWith("\r") ? text : `${text}\r` }),
};
}
// ── Store stub satisfying the runGraphCustomNode/cli-agent code paths ──────────
function createStore(task: any) {
const listeners = new Map<string, Set<Listener>>();
const logs: string[] = [];
return {
logs,
store: {
on: vi.fn((event: string, listener: Listener) => {
const set = listeners.get(event) ?? new Set<Listener>();
set.add(listener);
listeners.set(event, set);
}),
off: vi.fn(),
getTask: vi.fn().mockImplementation(async () => task),
logEntry: vi.fn().mockImplementation(async (_id: string, msg: string) => {
logs.push(msg);
}),
updateTask: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
listTasks: vi.fn().mockResolvedValue([]),
} as any,
};
}
describe("cli-agent executor seam (U7)", () => {
let tmpDir: string;
let fusionDir: string;
let db: Database;
let cliStore: CliSessionStore;
let registry: CliAdapterRegistry;
let manager: CliSessionManager;
let hub: TelemetryHub;
let state: MockState;
let worktree: string;
beforeEach(async () => {
resetExecutorMocks();
vi.clearAllMocks();
tmpDir = await mkdtemp(join(tmpdir(), "kb-cli-exec-"));
fusionDir = join(tmpDir, ".fusion");
worktree = join(tmpDir, "wt");
db = new Database(fusionDir, { inMemory: true });
db.init();
cliStore = new CliSessionStore(fusionDir, db);
registry = new CliAdapterRegistry();
registry.register(scriptedAdapter());
state = { ptys: [] };
manager = new CliSessionManager({ registry, store: cliStore, loadPty: async () => makeMockPtyModule(state) });
hub = new TelemetryHub({ store: cliStore });
});
afterEach(async () => {
manager.dispose();
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
function runtime(): CliAgentRuntime {
return {
manager,
hub,
registry,
store: cliStore,
projectId: "proj",
hookEndpointUrl: "http://127.0.0.1:4040/api/cli-agent/hooks",
hookDirRoot: tmpDir,
};
}
function makeExecutor(task: any) {
const { store, logs } = createStore(task);
const executor = new TaskExecutor(store, tmpDir, { cliAgentRuntime: runtime() });
return { executor, store, logs };
}
const cliNode = {
id: "execute",
kind: "prompt" as const,
config: { executor: "cli-agent", cliAdapterId: "scripted", prompt: "implement the feature" },
};
const taskDetail = () => ({
id: "FN-100",
column: "in-progress",
worktree,
prompt: "implement the feature",
steps: [],
currentStep: 0,
});
function lastPty() {
return state.ptys[state.ptys.length - 1];
}
// ── AE1 / F1 ────────────────────────────────────────────────────────────────
it("AE1: cli-agent node spawns in worktree, injects prompt after readiness, native done advances, PTY reaped", async () => {
const { executor } = makeExecutor(taskDetail());
const resultP = (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
// Wait for spawn.
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
// Readiness → injection.
lastPty().emitData("READY\r\n");
await vi.waitFor(() => expect(lastPty().written.some((w) => w.includes("implement the feature"))).toBe(true));
// Resolve the live session via the hub (the registered session id).
const sessions = cliStore.listByTask("FN-100");
expect(sessions).toHaveLength(1);
const sid = sessions[0].id;
// Injection drives ready→busy on the machine asynchronously; wait for busy.
await vi.waitFor(() => expect(hub.getStateMachine(sid)?.getState()).toBe("busy"));
hub.ingest(sid, { kind: "done" });
const result = await resultP;
expect(result.outcome).toBe("success");
expect(result.value).toBe("cli-agent-done");
// Reaped at handoff.
expect(lastPty().killed).toBe(true);
expect(manager.isLive(sid)).toBe(false);
expect(cliStore.getSession(sid)?.terminationReason).toBe("completed");
});
// ── AE5 ──────────────────────────────────────────────────────────────────────
it("AE5: user input mid-busy doesn't break tracking; subsequent done still advances", async () => {
const { executor } = makeExecutor(taskDetail());
const resultP = (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
lastPty().emitData("READY\r\n");
const sid = await vi.waitFor(() => {
const s = cliStore.listByTask("FN-100");
expect(s).toHaveLength(1);
return s[0].id;
});
// The injection drives the ready→busy machine transition asynchronously;
// wait until the machine has reached busy before exercising mid-busy input.
await vi.waitFor(() => expect(hub.getStateMachine(sid)?.getState()).toBe("busy"));
hub.ingest(sid, { kind: "sessionStart" });
hub.ingest(sid, { kind: "busy" });
// Mid-busy user keystrokes via the manager (deliberate control input).
manager.write(sid, "hint\r");
hub.ingest(sid, { kind: "toolActivity" });
expect(hub.getStateMachine(sid)?.getState()).toBe("busy");
hub.ingest(sid, { kind: "done" });
const result = await resultP;
expect(result.outcome).toBe("success");
});
// ── Hard cancel via the abort path ────────────────────────────────────────────
it("hard cancel: abort path SIGKILLs the cli session, marks killed (not resume-eligible), releases slot", async () => {
const { executor } = makeExecutor(taskDetail());
const resultP = (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
lastPty().emitData("READY\r\n");
const sid = await vi.waitFor(() => {
const s = cliStore.listByTask("FN-100");
expect(s).toHaveLength(1);
return s[0].id;
});
hub.ingest(sid, { kind: "sessionStart" });
hub.ingest(sid, { kind: "busy" });
// The cli session is registered as an active surface.
expect((executor as any).activeCliTaskSessions.has("FN-100")).toBe(true);
expect(manager.activeCount()).toBe(1);
// moveTask(in-progress→todo) hard cancel routes here.
await executor.awaitAbortInFlightTaskWork("FN-100", "parent moved from in-progress to todo", {
userCanceled: true,
});
const result = await resultP;
expect(result.outcome).toBe("failure");
expect(result.value).toBe("cli-agent-killed");
expect(lastPty().killed).toBe(true);
expect(lastPty().killSignal).toBe("SIGKILL");
expect(manager.activeCount()).toBe(0);
expect((executor as any).activeCliTaskSessions.has("FN-100")).toBe(false);
expect(cliStore.getSession(sid)?.terminationReason).toBe("killed");
});
// ── Re-entry launches fresh (prior live session killed) ──────────────────────
it("re-entry: a fresh run kills the prior live session and spawns a new PTY", async () => {
const { executor } = makeExecutor(taskDetail());
// First run, left live (no done).
void (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
lastPty().emitData("READY\r\n");
const firstId = await vi.waitFor(() => {
const s = cliStore.listByTask("FN-100");
expect(s.length).toBeGreaterThanOrEqual(1);
return s[0].id;
});
expect(manager.isLive(firstId)).toBe(true);
// Let the first run's async injection settle (it drives the machine to busy
// and would otherwise overwrite the killed reason mid-race).
await vi.waitFor(() => expect(hub.getStateMachine(firstId)?.getState()).toBe("busy"));
// Drop the first run's active handle to simulate a graph re-entry without abort.
(executor as any).activeCliTaskSessions.delete("FN-100");
// Second run (RETHINK re-entry) — kills the prior live session, spawns fresh.
const secondP = (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
await vi.waitFor(() => expect(state.ptys).toHaveLength(2));
expect(manager.isLive(firstId)).toBe(false);
expect(cliStore.getSession(firstId)?.terminationReason).toBe("killed");
lastPty().emitData("READY\r\n");
const second = cliStore.listByTask("FN-100").find((s) => s.id !== firstId)!;
await vi.waitFor(() => expect(hub.getStateMachine(second.id)?.getState()).toBe("busy"));
hub.ingest(second.id, { kind: "done" });
const result = await secondP;
expect(result.outcome).toBe("success");
});
// ── Ceiling produces a typed surfaced value, not a hang ──────────────────────
it("ceiling: spawn at the PTY pool ceiling produces a surfaced cli-agent-at-capacity value", async () => {
const limited = new CliSessionManager({
registry,
store: cliStore,
concurrencyCeiling: 1,
loadPty: async () => makeMockPtyModule(state),
});
try {
// Consume the only slot with a directly-spawned session.
await limited.spawn({ adapterId: "scripted", projectId: "proj", purpose: "execute", worktreePath: worktree });
const { store, logs } = createStore(taskDetail());
const executor = new TaskExecutor(store, tmpDir, {
cliAgentRuntime: { ...runtime(), manager: limited },
});
const result = await (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
expect(result.outcome).toBe("failure");
expect(result.value).toBe("cli-agent-at-capacity");
expect(logs.some((l) => l.includes("ceiling"))).toBe(true);
} finally {
limited.dispose();
}
});
// ── Missing config / runtime surface as clear errors ─────────────────────────
it("missing cliAdapterId surfaces a clear config error (not a stall)", async () => {
const { executor } = makeExecutor(taskDetail());
const node = { id: "x", kind: "prompt" as const, config: { executor: "cli-agent", prompt: "go" } };
const result = await (executor as any).runGraphCustomNode(node, taskDetail(), {});
expect(result.outcome).toBe("failure");
expect(result.value).toBe("cli-agent-adapter-missing");
});
it("absent runtime surfaces cli-agent-runtime-unavailable", async () => {
const { store } = createStore(taskDetail());
const executor = new TaskExecutor(store, tmpDir, {}); // no cliAgentRuntime
const result = await (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
expect(result.outcome).toBe("failure");
expect(result.value).toBe("cli-agent-runtime-unavailable");
});
it("no worktree surfaces no-worktree-for-write-node", async () => {
const noWt = { ...taskDetail(), worktree: undefined };
const { store } = createStore(noWt);
const executor = new TaskExecutor(store, tmpDir, { cliAgentRuntime: runtime() });
const result = await (executor as any).runGraphCustomNode(cliNode, noWt, {});
expect(result.outcome).toBe("failure");
expect(result.value).toBe("no-worktree-for-write-node");
});
// ── Node-config edit mid-run keeps the launch-time snapshot ───────────────────
it("node-config edit mid-run does not re-spawn or change the live session", async () => {
const { executor } = makeExecutor(taskDetail());
const node = {
id: "execute",
kind: "prompt" as const,
config: { executor: "cli-agent", cliAdapterId: "scripted", prompt: "v1 prompt" },
};
const resultP = (executor as any).runGraphCustomNode(node, taskDetail(), {});
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
lastPty().emitData("READY\r\n");
await vi.waitFor(() => expect(lastPty().written.some((w) => w.includes("v1 prompt"))).toBe(true));
// Edit the node config object mid-run.
node.config.prompt = "v2 prompt";
const sid = cliStore.listByTask("FN-100")[0].id;
await vi.waitFor(() => expect(hub.getStateMachine(sid)?.getState()).toBe("busy"));
hub.ingest(sid, { kind: "done" });
await resultP;
// Exactly one PTY, and it only ever saw the launch-time prompt (no re-spawn).
expect(state.ptys).toHaveLength(1);
expect(lastPty().written.some((w) => w.includes("v2 prompt"))).toBe(false);
});
});