Files
fusion/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts
gsxdsm f5be21178f feat(core,engine,compound-engineering): make CE sub-agent sessions load their skills
Closes the U2/U5 skill-discovery carry-forward so the plugin's interactive ce-*
sessions actually load the stage's bundled skill in a live agent (not just in
scripted-fake tests).

Root cause: createFnAgent built its DefaultResourceLoader without forwarding any
skill-discovery path, and the interactive seam options couldn't carry one. The
loader's skillsOverride only *filters* skills already discovered from cwd's
standard roots, so the plugin-local .fusion-ce-skills/<id>/SKILL.md was never
discoverable.

Fix (end-to-end):
- AgentOptions.additionalSkillPaths forwarded into DefaultResourceLoader
- CreateInteractiveAiSessionOptions gains requestedSkillNames + additionalSkillPaths
- the interactive engine adapter forwards them to createFnAgent (skills +
  additionalSkillPaths)
- the orchestrator runs the session with cwd on the real project root and hands
  it [stage.skillId] + the install root

Proven: a real DefaultResourceLoader with additionalSkillPaths discovers ce-plan
and filters out ce-work; the orchestrator passes the right id/path/cwd. Plugin 96,
engine 136, core 99 tests green.
2026-06-02 20:55:01 -07:00

600 lines
25 KiB
TypeScript

import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import type {
CreateInteractiveAiSessionFactory,
InteractiveAiSession,
InteractiveAiSessionEvent,
PlanningQuestion,
PluginContext,
} from "@fusion/core";
import { resolveDefaultInstallTargetRoot } from "../skill-installation.js";
import { getCePipelineStore, type CePipelineStore } from "../sync/pipeline-store.js";
import { createCeTaskWithLink } from "../sync/ce-task.js";
import { getDefaultModelId, getDefaultProvider, getEnabledStages } from "../settings.js";
import type { CeSession, CeSessionStore } from "./session-store.js";
import { getCeSessionStore } from "./session-store.js";
import { getStage, type CeStageDefinition } from "./stage-registry.js";
/**
* The stage id whose `complete` payload carries a derived task list to land on
* the board (U7). Its skill is `ce-work` (see the stage registry).
*/
export const WORK_STAGE_ID = "work";
/**
* CE provenance identity constants. Defined in `../sync/ce-task.ts` (so the
* reconciler can reuse them without importing this module) and re-exported here
* for existing consumers (index.ts, tests) that import them from the orchestrator.
*/
export { CE_PLUGIN_ID, CE_WORK_SOURCE_TYPE } from "../sync/ce-task.js";
/**
* COMPLETION-PAYLOAD → TASKS CONTRACT (U7).
*
* The `work` stage's `complete` event `data` MAY carry a `tasks` array describing
* the board tasks to create. Each entry needs at least a `description` (the only
* required TaskCreateInput field); `title` and `column` are optional.
*
* { artifact?: string, tasks?: Array<{ title?: string, description: string, column?: Column }> }
*
* A missing/empty `tasks` array is a clean no-op (no board tasks, no link rows).
* Entries with a blank description are skipped (createTask would reject them).
*/
export interface CeDerivedTaskSpec {
title?: string;
description: string;
column?: string;
}
/** Default per-turn timeout. A turn that exceeds this is treated as a stall. */
const DEFAULT_TURN_TIMEOUT_MS = 120000;
/**
* Observable event names emitted via `ctx.emitEvent`. The no-silent-loss
* invariant requires that interrupt/error ALWAYS emit one of these AND persist
* progress first.
*/
export const CE_EVENTS = {
turn: "compound-engineering:session-turn",
question: "compound-engineering:session-question",
completed: "compound-engineering:session-completed",
error: "compound-engineering:session-error",
interrupted: "compound-engineering:session-interrupted",
} as const;
export class CeTurnTimeoutError extends Error {
constructor(ms: number) {
super(`CE session turn timed out after ${ms}ms`);
this.name = "CeTurnTimeoutError";
}
}
function timeoutAfter(ms: number): Promise<never> {
return new Promise((_, reject) => {
globalThis.setTimeout(() => reject(new CeTurnTimeoutError(ms)), ms).unref?.();
});
}
export interface OrchestratorDeps {
ctx: PluginContext;
/**
* Interactive-session factory. Defaults to `ctx.createInteractiveAiSession`
* (route contexts only); injectable for deterministic, scripted-fake tests.
*/
createInteractiveAiSession?: CreateInteractiveAiSessionFactory;
/** Project root used for the session cwd and artifact writes. */
projectRoot?: string;
/** Override the per-turn timeout (ms). */
turnTimeoutMs?: number;
}
/**
* Skill discovery wiring (closes the U2 → U5 carry-forward).
*
* U2 proved a `PluginSkillContribution` is NOT auto-ingested by the engine
* skill-resolver; a physical install onto a discoverable path is required, and
* the plugin installs its bundled `ce-*` skills to a plugin-local directory
* (`resolveDefaultInstallTargetRoot()`). The U4 seam now carries
* `requestedSkillNames` + `additionalSkillPaths`, which the engine adapter
* forwards into `createFnAgent` (`skills` + the loader's `additionalSkillPaths`).
* So the orchestrator hands the live session BOTH the stage's skill id and the
* install directory to discover it from — the session runs with `cwd` at the
* real project root (where it reads context and writes artifacts), not at the
* skills directory.
*/
export function resolveStageSkillPaths(): string[] {
// The install target root holds `<skillId>/SKILL.md` for each installed skill;
// passing it as an additional skill-discovery path makes the stage's skill
// loadable while the session cwd stays on the project.
return [resolveDefaultInstallTargetRoot()];
}
/**
* Build the system prompt: instruct the agent to (a) apply the named ce-* skill
* and (b) emit the JSON question/complete protocol the U4 seam parses.
*/
export function buildStageSystemPrompt(stage: CeStageDefinition): string {
return [
`You are running the Compound Engineering "${stage.stageId}" stage.`,
`Apply the bundled skill "${stage.skillId}" (it has been loaded into this session).`,
"",
"Drive the stage as an interactive question/answer flow. On every turn respond with ONLY a JSON object:",
' - To ask the user something: {"type":"question","data":{"id":"<unique>","type":"single_select|multi_select|text|confirm","question":"...","options":[{"id":"..","label":".."}]}}',
' - When the stage is finished: {"type":"complete","data":{"artifact":"<full markdown document>", ...}}',
"No markdown fences, no prose outside the JSON object.",
].join("\n");
}
export interface StartStageOptions {
/** Opening user message (the stage prompt / topic). */
openingMessage: string;
projectId?: string | null;
}
/** Result of a single orchestrator step (start / answer / resume). */
export interface CeStepResult {
session: CeSession;
/** The event the seam produced for this step, if a turn ran. */
event?: InteractiveAiSessionEvent;
}
/**
* Drives a stage's interactive skill session: streams thinking/text, surfaces
* questions (persisted as `awaiting_input`), accepts answers, and on `complete`
* writes the artifact to the stage's conventional location. On interrupt/error
* it AUTO-SAVES progress and emits an observable event — never silent loss.
*
* Liveness uses the interval-relative rubric (CeSessionStore.isStale): a slow
* turn is not misclassified stale.
*/
export class CeOrchestrator {
private readonly ctx: PluginContext;
private readonly store: CeSessionStore;
private readonly pipelineStore: CePipelineStore;
private readonly factory: CreateInteractiveAiSessionFactory | undefined;
private readonly projectRoot: string;
private readonly turnTimeoutMs: number;
/** Live in-memory session handles keyed by ce_session id. */
private readonly live = new Map<string, InteractiveAiSession>();
constructor(deps: OrchestratorDeps) {
this.ctx = deps.ctx;
this.store = getCeSessionStore(deps.ctx);
this.pipelineStore = getCePipelineStore(deps.ctx);
this.factory = deps.createInteractiveAiSession ?? deps.ctx.createInteractiveAiSession;
this.projectRoot = deps.projectRoot ?? deps.ctx.taskStore.getRootDir();
this.turnTimeoutMs = deps.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
}
/**
* Build the interactive-session options for a stage. The session runs with
* `cwd` on the real project root (where it reads context and writes the stage
* artifact) and is handed BOTH the stage's `ce-*` skill id and the plugin-local
* install directory to discover it from — so the live agent actually loads the
* bundled skill (closing the U2/U5 skill-discovery carry-forward). Model
* provider/model are setting-gated (U9); omitted keys let the host pick defaults.
*/
private buildSessionOptions(stage: CeStageDefinition): Parameters<CreateInteractiveAiSessionFactory>[0] {
const defaultProvider = getDefaultProvider(this.ctx.settings);
const defaultModelId = getDefaultModelId(this.ctx.settings);
return {
cwd: this.projectRoot,
systemPrompt: buildStageSystemPrompt(stage),
tools: "coding",
requestedSkillNames: [stage.skillId],
additionalSkillPaths: resolveStageSkillPaths(),
...(defaultProvider ? { defaultProvider } : {}),
...(defaultModelId ? { defaultModelId } : {}),
};
}
/** Start a fresh session for a registered stage and run the opening turn. */
async start(stageId: string, opts: StartStageOptions): Promise<CeStepResult> {
const stage = getStage(stageId);
if (!stage) throw new Error(`Unknown CE stage: ${stageId}`);
// Setting-gated launch (U9): only stages the operator enabled may launch.
if (!getEnabledStages(this.ctx.settings).includes(stageId)) {
throw new Error(`CE stage is not enabled: ${stageId}`);
}
if (!this.factory) {
throw new Error(
"Interactive AI sessions are not available (createInteractiveAiSession is only injected on route contexts with the engine loaded).",
);
}
let session = this.store.create({
stage: stageId,
projectId: opts.projectId ?? null,
turnIntervalMs: this.turnTimeoutMs,
});
this.store.appendHistory(session.id, { role: "user", text: opts.openingMessage, at: new Date().toISOString() });
let interactive;
try {
interactive = await this.factory(this.buildSessionOptions(stage));
} catch (err) {
return { session: this.failSession(session.id, err), event: undefined };
}
this.live.set(session.id, interactive.session);
session = this.store.update(session.id, { status: "active" }) ?? session;
return this.runTurn(session.id, () => interactive.session.prompt(opts.openingMessage), interactive.session);
}
/** Answer the awaiting question and continue the loop. */
async answer(sessionId: string, questionId: string, response: unknown): Promise<CeStepResult> {
const session = this.requireSession(sessionId);
if (session.status !== "awaiting_input") {
throw new Error(`Session ${sessionId} is not awaiting input (status=${session.status}).`);
}
// Validate the questionId BEFORE mutating any persisted state. A stale/wrong
// questionId must NOT clear `currentQuestion` or flip status to active —
// doing so would destroy the recovery anchor while the seam rejects the
// mismatch, leaving the DB diverged from the live session. Reject cleanly and
// leave `currentQuestion`/status intact so the session stays answerable.
if (questionId !== session.currentQuestion?.id) {
throw new Error(
`Session ${sessionId} is awaiting question "${session.currentQuestion?.id ?? "(none)"}", not "${questionId}".`,
);
}
const live = this.live.get(sessionId);
if (!live) {
throw new Error(`Session ${sessionId} has no live handle in this process; call resume() first.`);
}
this.store.appendHistory(sessionId, {
role: "user",
text: JSON.stringify({ answer: response, questionId }),
at: new Date().toISOString(),
});
this.store.update(sessionId, { status: "active", currentQuestion: null });
return this.runTurn(sessionId, () => live.answer(questionId, response), live);
}
/**
* Resume an `awaiting_input`, `interrupted`, or `error` session — and, crucially,
* RE-ESTABLISH a live interactive handle so the resumed session can actually be
* answered (Bug 5). After an interrupt/timeout the live handle was disposed and
* removed from `this.live`; flipping persisted status back to `awaiting_input`
* without a live handle was a dead end (resume → answer → "call resume() first").
*
* When a question is still pending and no live handle exists, we rehydrate via
* the factory and REPLAY the persisted conversation history (opening message +
* prior answers) to prime the fresh agent back to the current awaiting question,
* repopulating `this.live`. Replay is side-effect-suppressed: it reconstructs
* the agent's context only — no artifact writes, no event emits, no history
* re-append (the DB already reflects the final state).
*
* If no factory is available (no live handle can ever be created in this
* process), we DO NOT advertise a misleading answerable status: the session is
* left `interrupted` with a clear error explaining it can't be continued here.
*/
async resume(sessionId: string): Promise<CeStepResult> {
const session = this.requireSession(sessionId);
// Terminal / already-answerable-with-a-live-handle cases need no rehydration.
if (session.status === "completed") return { session };
if (session.status === "awaiting_input" && this.live.has(sessionId)) {
return { session }; // already live + answerable.
}
// No pending question → nothing to re-prime to. Mark active so the caller can
// re-run the turn with fresh input (retry for `error`, resume for others).
if (!session.currentQuestion) {
const next = this.store.update(sessionId, { status: "active", error: null }) ?? session;
return { session: next };
}
// A live handle already exists (e.g. interrupted but not disposed) — just
// restore the answerable status.
if (this.live.has(sessionId)) {
const next = this.store.update(sessionId, { status: "awaiting_input", error: null }) ?? session;
return { session: next };
}
// Rehydration path: re-create the live session and replay history back to the
// current question.
if (!this.factory) {
// Honest status: we cannot back an answerable state in this process, so do
// not pretend the session is resumable here. Surface a clear error.
const next =
this.store.update(sessionId, {
status: "interrupted",
error:
"Session cannot be continued in this process: interactive AI sessions are unavailable (no factory on this context). Resume from a route context with the engine loaded.",
}) ?? session;
return { session: next };
}
try {
await this.rehydrate(session);
} catch (err) {
// Rehydration failed — keep progress, surface the failure, do not advertise
// an answerable status we can't back.
const next = this.interruptSession(sessionId, err);
return { session: next };
}
const next = this.store.update(sessionId, { status: "awaiting_input", error: null }) ?? session;
return { session: next };
}
/**
* Re-create a live interactive session and REPLAY the persisted conversation so
* the fresh agent is primed back to the current awaiting question. Side effects
* are suppressed: we drain the seam's events to advance the agent's context but
* do NOT persist/emit/write — the DB already holds the authoritative final
* state. Populates `this.live[session.id]` on success.
*/
private async rehydrate(session: CeSession): Promise<void> {
const stage = getStage(session.stage);
if (!stage) throw new Error(`Unknown CE stage: ${session.stage}`);
const interactive = await this.factory!(this.buildSessionOptions(stage));
const live = interactive.session;
// Walk the recorded user turns in order. The FIRST user turn is the opening
// message (raw text); each subsequent user turn is a serialized
// {answer, questionId} produced by answer(). Drive the seam with each, and
// drain exactly one event per drive to advance the agent's context — but
// suppress all side effects (no persist/emit/artifact-write).
const userTurns = session.conversationHistory.filter((t) => t.role === "user");
try {
for (let i = 0; i < userTurns.length; i++) {
const turn = userTurns[i];
if (i === 0) {
await live.prompt(turn.text);
} else {
const parsed = this.parseAnswerTurn(turn.text);
if (!parsed) continue; // tolerate non-answer user turns.
await live.answer(parsed.questionId, parsed.answer);
}
// Drain the agent's response for this turn to keep the seam in lockstep,
// but DISCARD it — replay reconstructs context, it does not re-run the
// turn loop's side effects.
await live.nextEvent();
}
} catch (err) {
// Replay failed mid-way — dispose the half-primed handle so we don't leave
// a broken live session behind, then propagate.
try {
live.dispose();
} catch {
// best-effort
}
throw err;
}
this.live.set(session.id, live);
}
/** Parse a serialized `{ answer, questionId }` user turn produced by answer(). */
private parseAnswerTurn(text: string): { questionId: string; answer: unknown } | undefined {
try {
const obj = JSON.parse(text) as { answer?: unknown; questionId?: unknown };
if (typeof obj?.questionId === "string") {
return { questionId: obj.questionId, answer: obj.answer };
}
} catch {
// not a JSON answer turn
}
return undefined;
}
/** Read-through accessor for routes. */
getState(sessionId: string): CeSession | undefined {
return this.store.get(sessionId);
}
/**
* Run one turn behind a timeout race, persist the resulting event, and on a
* turn-level failure auto-save + emit. The `driver` performs the prompt/answer
* against the live session; we then pull exactly one event.
*/
private async runTurn(
sessionId: string,
driver: () => Promise<void>,
live: InteractiveAiSession,
): Promise<CeStepResult> {
let event: InteractiveAiSessionEvent;
try {
event = await Promise.race([
(async () => {
await driver();
return live.nextEvent();
})(),
timeoutAfter(this.turnTimeoutMs),
]);
} catch (err) {
// Timeout or driver throw → auto-save as interrupted (progress preserved)
// and emit an observable event. Never silent loss.
const session = this.interruptSession(sessionId, err);
this.disposeLive(sessionId);
return { session, event: { type: "error", data: { message: session.error ?? "interrupted", cause: err } } };
}
const session = this.applyEvent(sessionId, event);
if (event.type === "complete" || event.type === "error") {
this.disposeLive(sessionId);
}
// Work bridge (U7): the work stage's completion payload lands derived tasks
// on the board, tagged CE-originated + recorded as pipeline links. Outbound
// only — created tasks then run the NORMAL lifecycle with no plugin hooks.
if (event.type === "complete" && session.stage === WORK_STAGE_ID) {
await this.landWorkTasks(session, event.data);
}
return { session, event };
}
/** Persist a seam event onto the session row + emit the matching observable event. */
private applyEvent(sessionId: string, event: InteractiveAiSessionEvent): CeSession {
switch (event.type) {
case "thinking":
case "text": {
this.store.appendHistory(sessionId, { role: "agent", text: event.data, at: new Date().toISOString() });
const s = this.store.update(sessionId, { status: "active" }) ?? this.requireSession(sessionId);
this.ctx.emitEvent(CE_EVENTS.turn, { sessionId, kind: event.type });
return s;
}
case "question": {
const q: PlanningQuestion = event.data;
this.store.appendHistory(sessionId, {
role: "agent",
text: JSON.stringify({ question: q }),
at: new Date().toISOString(),
});
const s = this.store.update(sessionId, { status: "awaiting_input", currentQuestion: q }) ?? this.requireSession(sessionId);
this.ctx.emitEvent(CE_EVENTS.question, { sessionId, questionId: q.id });
return s;
}
case "complete": {
const artifactPath = this.writeArtifact(sessionId, event.data);
this.store.appendHistory(sessionId, {
role: "agent",
text: JSON.stringify({ complete: true }),
at: new Date().toISOString(),
});
const s =
this.store.update(sessionId, { status: "completed", currentQuestion: null, artifactPath }) ??
this.requireSession(sessionId);
this.ctx.emitEvent(CE_EVENTS.completed, { sessionId, artifactPath });
return s;
}
case "error": {
const message = event.data.message;
// Error preserves progress (currentQuestion/history untouched) so retry
// can resume. Status error; observable event emitted.
const s = this.store.update(sessionId, { status: "error", error: message }) ?? this.requireSession(sessionId);
this.ctx.emitEvent(CE_EVENTS.error, { sessionId, message });
return s;
}
}
}
/**
* Work bridge (U7). Read the derived task list from the work stage's
* completion payload, create each as a board task tagged CE-originated, and
* record a pipeline-link row resolving task→pipeline/stage/artifact. Zero
* derived tasks is a clean no-op (no board tasks, no orphan link rows). The
* created tasks then run the normal lifecycle — no hooks attached here (U8).
*/
private async landWorkTasks(session: CeSession, data: unknown): Promise<void> {
const specs = this.extractTaskSpecs(data);
if (specs.length === 0) return;
// The session is the CE pipeline run; its id is the stable pipeline id the
// link rows (and U8's state machine) address.
const cePipelineId = session.id;
const ceStageId = session.stage;
const ceArtifactPath = session.artifactPath ?? null;
// Seed the CE-pipeline STATE record (U8). This is the pipeline's OWN state
// machine, distinct from the board task columns it will spawn. The pipeline
// is "running" at this stage until a board signal advances it.
this.pipelineStore.upsertState({
cePipelineId,
currentStage: ceStageId,
status: "running",
lastArtifactPath: ceArtifactPath,
});
for (const spec of specs) {
const description = spec.description.trim();
if (!description) continue; // createTask rejects blank descriptions.
// Shared contract: create the CE-tagged board task AND its authoritative
// pipeline-link row (FN-5719) in one place (see createCeTaskWithLink).
await createCeTaskWithLink(this.ctx.taskStore, this.pipelineStore, {
title: spec.title,
description,
column: spec.column,
cePipelineId,
ceStageId,
ceArtifactPath,
});
}
}
/** Parse the `{ tasks: [...] }` completion-payload contract; tolerant of shape. */
private extractTaskSpecs(data: unknown): CeDerivedTaskSpec[] {
if (!data || typeof data !== "object") return [];
const raw = (data as { tasks?: unknown }).tasks;
if (!Array.isArray(raw)) return [];
const specs: CeDerivedTaskSpec[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const e = entry as Record<string, unknown>;
const description = typeof e.description === "string" ? e.description : "";
if (!description.trim()) continue;
specs.push({
description,
title: typeof e.title === "string" ? e.title : undefined,
column: typeof e.column === "string" ? e.column : undefined,
});
}
return specs;
}
/** Persist `interrupted` with progress preserved and emit. */
private interruptSession(sessionId: string, cause: unknown): CeSession {
const message = cause instanceof Error ? cause.message : String(cause);
const s =
this.store.update(sessionId, { status: "interrupted", error: message }) ?? this.requireSession(sessionId);
this.ctx.emitEvent(CE_EVENTS.interrupted, { sessionId, message });
return s;
}
/** Persist `error` (session-create failure path) and emit. */
private failSession(sessionId: string, cause: unknown): CeSession {
const message = cause instanceof Error ? cause.message : String(cause);
const s = this.store.update(sessionId, { status: "error", error: message }) ?? this.requireSession(sessionId);
this.ctx.emitEvent(CE_EVENTS.error, { sessionId, message });
return s;
}
/**
* Write the stage artifact to its conventional location (R10). Accepts either
* a `{ artifact: string }` payload or a raw string. Returns the absolute path.
*/
private writeArtifact(sessionId: string, data: unknown): string {
const session = this.requireSession(sessionId);
const stage = getStage(session.stage);
const location = stage?.artifactLocation ?? `docs/ce/${session.stage}/`;
const content = this.extractArtifactContent(data);
const target = location.endsWith("/")
? join(location, `${session.stage}-${session.id}.md`)
: location;
const abs = isAbsolute(target) ? target : join(this.projectRoot, target);
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, content, "utf-8");
return abs;
}
private extractArtifactContent(data: unknown): string {
if (typeof data === "string") return data;
if (data && typeof data === "object" && "artifact" in data) {
const a = (data as { artifact: unknown }).artifact;
if (typeof a === "string") return a;
}
return JSON.stringify(data, null, 2);
}
private requireSession(sessionId: string): CeSession {
const s = this.store.get(sessionId);
if (!s) throw new Error(`CE session not found: ${sessionId}`);
return s;
}
private disposeLive(sessionId: string): void {
const live = this.live.get(sessionId);
if (live) {
try {
live.dispose();
} catch {
// best-effort
}
this.live.delete(sessionId);
}
}
}