fix(FN-834): fix branch prefix drift, add merger branch guard, and fix test OOM

- Fix resolveBaseBranch to use stored branch name and consistent fusion/ prefix
  for both explicit deps and blockedBy paths (was using kb/ for blockedBy)
- Add main branch checkout verification in merger before squash merge to prevent
  feature code from landing on wrong branch lineage
- Align all branch prefix references from stale kb/ to fusion/ across executor,
  merger, store, and routes
- Fix executor test OOM by mocking merger fully, adding fake timers to retry
  tests, and switching vitest pool to vmThreads
- Update all test assertions to use fusion/ branch prefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-04 11:11:59 -07:00
parent d1da9721a1
commit 357086ab61
26 changed files with 1985 additions and 236 deletions

View File

@@ -23,6 +23,7 @@ import type {
AgentHeartbeatEvent,
AgentHeartbeatRun,
AgentDetail,
AgentTaskSession,
} from "./types.js";
import { AGENT_VALID_TRANSITIONS } from "./types.js";
@@ -66,6 +67,15 @@ interface AgentData {
updatedAt: string;
lastHeartbeatAt?: string;
metadata: Record<string, unknown>;
title?: string;
icon?: string;
reportsTo?: string;
runtimeConfig?: Record<string, unknown>;
pauseReason?: string;
permissions?: Record<string, boolean>;
totalInputTokens?: number;
totalOutputTokens?: number;
lastError?: string;
}
/** Per-agent write lock for serialization */
@@ -121,6 +131,11 @@ export class AgentStore extends EventEmitter {
createdAt: now,
updatedAt: now,
metadata: input.metadata ?? {},
...(input.title && { title: input.title }),
...(input.icon && { icon: input.icon }),
...(input.reportsTo && { reportsTo: input.reportsTo }),
...(input.runtimeConfig && { runtimeConfig: input.runtimeConfig }),
...(input.permissions && { permissions: input.permissions }),
};
await this.writeAgent(agent);
@@ -190,6 +205,15 @@ export class AgentStore extends EventEmitter {
role: updates.role ?? agent.role,
metadata: updates.metadata !== undefined ? updates.metadata : agent.metadata,
updatedAt: new Date().toISOString(),
...(updates.title !== undefined && { title: updates.title }),
...(updates.icon !== undefined && { icon: updates.icon }),
...(updates.reportsTo !== undefined && { reportsTo: updates.reportsTo }),
...(updates.runtimeConfig !== undefined && { runtimeConfig: updates.runtimeConfig }),
...(updates.pauseReason !== undefined && { pauseReason: updates.pauseReason }),
...(updates.permissions !== undefined && { permissions: updates.permissions }),
...(updates.lastError !== undefined && { lastError: updates.lastError }),
...(updates.totalInputTokens !== undefined && { totalInputTokens: updates.totalInputTokens }),
...(updates.totalOutputTokens !== undefined && { totalOutputTokens: updates.totalOutputTokens }),
};
await this.writeAgent(updated);
@@ -290,7 +314,7 @@ export class AgentStore extends EventEmitter {
*/
async listAgents(filter?: { state?: AgentState; role?: AgentCapability }): Promise<Agent[]> {
const files = await readdir(this.agentsDir).catch(() => [] as string[]);
const agentFiles = files.filter((f) => f.endsWith(".json") && !f.includes("-heartbeats"));
const agentFiles = files.filter((f) => f.endsWith(".json") && !f.includes("-heartbeats") && !f.includes("-sessions") && !f.includes("-runs"));
const agents: Agent[] = [];
for (const file of agentFiles) {
@@ -332,6 +356,13 @@ export class AgentStore extends EventEmitter {
await unlink(agentPath).catch(() => {});
await unlink(heartbeatPath).catch(() => {});
// Clean up sessions and runs directories
const { rm } = await import("node:fs/promises");
const sessionsDir = join(this.agentsDir, `${agentId}-sessions`);
const runsDir = join(this.agentsDir, `${agentId}-runs`);
await rm(sessionsDir, { recursive: true, force: true }).catch(() => {});
await rm(runsDir, { recursive: true, force: true }).catch(() => {});
this.emit("agent:deleted", agentId);
});
}
@@ -534,6 +565,146 @@ export class AgentStore extends EventEmitter {
return Array.from(runs.values()).filter((r) => r.status !== "active");
}
// ─────────────────────────────────────────────────────────────────────────
// Task Session Management
// ─────────────────────────────────────────────────────────────────────────
/**
* Get a task session for an agent.
* @param agentId - The agent ID
* @param taskId - The task ID
* @returns The session, or null if not found
*/
async getTaskSession(agentId: string, taskId: string): Promise<AgentTaskSession | null> {
const sessionsDir = join(this.agentsDir, `${agentId}-sessions`);
const sessionPath = join(sessionsDir, `${taskId}.json`);
try {
const content = await readFile(sessionPath, "utf-8");
return JSON.parse(content) as AgentTaskSession;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw err;
}
}
/**
* Create or update a task session for an agent.
* @param session - The session data
* @returns The saved session
*/
async upsertTaskSession(session: AgentTaskSession): Promise<AgentTaskSession> {
const sessionsDir = join(this.agentsDir, `${session.agentId}-sessions`);
await mkdir(sessionsDir, { recursive: true });
const now = new Date().toISOString();
const existing = await this.getTaskSession(session.agentId, session.taskId);
const saved: AgentTaskSession = {
...session,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
const sessionPath = join(sessionsDir, `${session.taskId}.json`);
await writeFile(sessionPath, JSON.stringify(saved, null, 2));
return saved;
}
/**
* Delete a task session.
* @param agentId - The agent ID
* @param taskId - The task ID
*/
async deleteTaskSession(agentId: string, taskId: string): Promise<void> {
const sessionPath = join(this.agentsDir, `${agentId}-sessions`, `${taskId}.json`);
await unlink(sessionPath).catch(() => {});
}
// ─────────────────────────────────────────────────────────────────────────
// Org Hierarchy
// ─────────────────────────────────────────────────────────────────────────
/**
* Get agents that report to a specific agent.
* @param agentId - The parent agent ID
* @returns Array of agents that report to this agent
*/
async getAgentsByReportsTo(agentId: string): Promise<Agent[]> {
const all = await this.listAgents();
return all.filter((a) => a.reportsTo === agentId);
}
// ─────────────────────────────────────────────────────────────────────────
// Rich Run Storage
// ─────────────────────────────────────────────────────────────────────────
/**
* Save a rich heartbeat run record (structured JSON, not JSONL events).
* @param run - The heartbeat run data
*/
async saveRun(run: AgentHeartbeatRun): Promise<void> {
const runsDir = join(this.agentsDir, `${run.agentId}-runs`);
await mkdir(runsDir, { recursive: true });
const runPath = join(runsDir, `${run.id}.json`);
await writeFile(runPath, JSON.stringify(run, null, 2));
}
/**
* Get a specific run by ID.
* @param agentId - The agent ID
* @param runId - The run ID
* @returns The run detail, or null if not found
*/
async getRunDetail(agentId: string, runId: string): Promise<AgentHeartbeatRun | null> {
const runPath = join(this.agentsDir, `${agentId}-runs`, `${runId}.json`);
try {
const content = await readFile(runPath, "utf-8");
return JSON.parse(content) as AgentHeartbeatRun;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw err;
}
}
/**
* Get recent runs for an agent from structured run storage.
* @param agentId - The agent ID
* @param limit - Max number of runs to return (default: 20)
* @returns Array of runs (newest first)
*/
async getRecentRuns(agentId: string, limit = 20): Promise<AgentHeartbeatRun[]> {
const runsDir = join(this.agentsDir, `${agentId}-runs`);
let files: string[];
try {
files = await readdir(runsDir);
} catch {
return [];
}
const runFiles = files.filter((f) => f.endsWith(".json"));
const runs: AgentHeartbeatRun[] = [];
for (const file of runFiles) {
try {
const content = await readFile(join(runsDir, file), "utf-8");
runs.push(JSON.parse(content) as AgentHeartbeatRun);
} catch {
// Skip corrupted files
}
}
// Sort by startedAt desc and limit
return runs
.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())
.slice(0, limit);
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
@@ -555,6 +726,15 @@ export class AgentStore extends EventEmitter {
updatedAt: data.updatedAt,
lastHeartbeatAt: data.lastHeartbeatAt,
metadata: data.metadata ?? {},
title: data.title,
icon: data.icon,
reportsTo: data.reportsTo,
runtimeConfig: data.runtimeConfig,
pauseReason: data.pauseReason,
permissions: data.permissions,
totalInputTokens: data.totalInputTokens,
totalOutputTokens: data.totalOutputTokens,
lastError: data.lastError,
};
}
@@ -570,6 +750,15 @@ export class AgentStore extends EventEmitter {
updatedAt: agent.updatedAt,
lastHeartbeatAt: agent.lastHeartbeatAt,
metadata: agent.metadata,
title: agent.title,
icon: agent.icon,
reportsTo: agent.reportsTo,
runtimeConfig: agent.runtimeConfig,
pauseReason: agent.pauseReason,
permissions: agent.permissions,
totalInputTokens: agent.totalInputTokens,
totalOutputTokens: agent.totalOutputTokens,
lastError: agent.lastError,
};
// Write atomically using temp file

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent, AgentHeartbeatRun, NtfyNotificationEvent, SteeringComment } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment } from "./types.js";
export { AgentStore } from "./agent-store.js";
export type { AgentStoreEvents } from "./agent-store.js";
export { TaskStore } from "./store.js";

View File

@@ -4301,7 +4301,7 @@ Task with acceptance criteria
const { execSync } = await import("node:child_process");
try {
execSync(`git checkout -b kb/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
execSync(`git checkout -b fusion/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
execSync('git commit --allow-empty -m "test commit"', { cwd: rootDir, stdio: "pipe" });
execSync("git checkout main || git checkout master", { cwd: rootDir, stdio: "pipe" });
} catch {
@@ -4333,7 +4333,7 @@ Task with acceptance criteria
// Create branch for merge
const { execSync } = await import("node:child_process");
try {
execSync(`git checkout -b kb/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
execSync(`git checkout -b fusion/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
execSync('git commit --allow-empty -m "test commit"', { cwd: rootDir, stdio: "pipe" });
execSync("git checkout main || git checkout master", { cwd: rootDir, stdio: "pipe" });
} catch {

View File

@@ -1016,7 +1016,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; branch?: string; baseCommitSha?: string; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; stuckKillCount?: number | null; recoveryRetryCount?: number | null; nextRecoveryAt?: string | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null; summary?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; branch?: string; baseCommitSha?: string; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; stuckKillCount?: number | null; recoveryRetryCount?: number | null; nextRecoveryAt?: string | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
): Promise<Task> {
return this.withTaskLock(id, async () => {
// Validate that task doesn't depend on itself
@@ -1115,6 +1115,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.summary !== undefined) {
task.summary = updates.summary;
}
if (updates.sessionFile === null) {
task.sessionFile = undefined;
} else if (updates.sessionFile !== undefined) {
task.sessionFile = updates.sessionFile;
}
if (updates.workflowStepResults === null) {
task.workflowStepResults = undefined;
} else if (updates.workflowStepResults !== undefined) {
@@ -1475,7 +1480,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
throw new Error(`Cannot merge ${id}: ${mergeBlocker}`);
}
const branch = `kb/${id.toLowerCase()}`;
const branch = `fusion/${id.toLowerCase()}`;
const worktreePath = task.worktree;
const result: MergeResult = {
task,

View File

@@ -524,6 +524,10 @@ export interface Task {
nextRecoveryAt?: string;
/** Thinking level for AI agent sessions — controls reasoning effort (off/minimal/low/medium/high) */
thinkingLevel?: ThinkingLevel;
/** Path to the persisted agent session file, enabling pause/resume without
* losing conversation context. Set when execution starts; cleared on
* completion or terminal failure. */
sessionFile?: string;
/** Error message from the last failure, if the task failed during execution */
error?: string;
/** Optional summary of what was changed/fixed when task is completed */
@@ -1243,14 +1247,16 @@ export interface PlanningSession {
// ── Agent Types ────────────────────────────────────────────────────────────
/** Agent lifecycle states */
export const AGENT_STATES = ["idle", "active", "paused", "terminated"] as const;
export const AGENT_STATES = ["idle", "active", "running", "paused", "error", "terminated"] as const;
export type AgentState = (typeof AGENT_STATES)[number];
/** Valid state transitions for agents */
export const AGENT_VALID_TRANSITIONS: Record<AgentState, AgentState[]> = {
idle: ["active"],
active: ["paused", "terminated"],
active: ["running", "paused", "terminated"],
running: ["active", "paused", "error", "terminated"],
paused: ["active", "terminated"],
error: ["active", "terminated"],
terminated: [], // Terminal state - no exits
};
@@ -1264,6 +1270,9 @@ export interface AgentHeartbeatEvent {
runId: string;
}
/** What triggered a heartbeat run */
export type HeartbeatInvocationSource = "on_demand" | "timer" | "assignment" | "automation";
/** A continuous heartbeat session/run for an agent */
export interface AgentHeartbeatRun {
/** Unique identifier for this run */
@@ -1275,11 +1284,33 @@ export interface AgentHeartbeatRun {
/** ISO-8601 timestamp when the run ended (null if active) */
endedAt: string | null;
/** Status of the run */
status: "active" | "completed" | "terminated";
status: "active" | "completed" | "terminated" | "failed";
/** What triggered this run */
invocationSource?: HeartbeatInvocationSource;
/** Trigger detail (manual, ping, scheduler, system) */
triggerDetail?: string;
/** PID of the agent process */
processPid?: number;
/** Exit code of the agent process */
exitCode?: number;
/** Session ID before execution (for continuity tracking) */
sessionIdBefore?: string;
/** Session ID after execution */
sessionIdAfter?: string;
/** Token usage for this run */
usageJson?: { inputTokens: number; outputTokens: number; cachedTokens: number };
/** Structured result from the run */
resultJson?: Record<string, unknown>;
/** Snapshot of context at run start (taskId, projectId, etc.) */
contextSnapshot?: Record<string, unknown>;
/** Excerpt of stdout output */
stdoutExcerpt?: string;
/** Excerpt of stderr output */
stderrExcerpt?: string;
}
/** Capabilities/roles an agent can have */
export type AgentCapability = "triage" | "executor" | "reviewer" | "merger" | "scheduler" | "custom";
export type AgentCapability = "triage" | "executor" | "reviewer" | "merger" | "scheduler" | "engineer" | "custom";
/** Agent record stored in the system */
export interface Agent {
@@ -1301,6 +1332,24 @@ export interface Agent {
lastHeartbeatAt?: string;
/** Optional metadata */
metadata: Record<string, unknown>;
/** Job title / description for the agent */
title?: string;
/** Custom icon identifier */
icon?: string;
/** Agent ID this agent reports to (org hierarchy) */
reportsTo?: string;
/** Runtime configuration (maxTurns, thinkingLevel, etc.) */
runtimeConfig?: Record<string, unknown>;
/** Why the agent was paused (error, manual, etc.) */
pauseReason?: string;
/** Capability permission flags */
permissions?: Record<string, boolean>;
/** Cumulative input tokens across all runs */
totalInputTokens?: number;
/** Cumulative output tokens across all runs */
totalOutputTokens?: number;
/** Last error message */
lastError?: string;
}
/** Extended agent information including heartbeat history */
@@ -1318,6 +1367,11 @@ export interface AgentCreateInput {
name: string;
role: AgentCapability;
metadata?: Record<string, unknown>;
title?: string;
icon?: string;
reportsTo?: string;
runtimeConfig?: Record<string, unknown>;
permissions?: Record<string, boolean>;
}
/** Input for updating an existing agent */
@@ -1325,6 +1379,45 @@ export interface AgentUpdateInput {
name?: string;
role?: AgentCapability;
metadata?: Record<string, unknown>;
title?: string;
icon?: string;
reportsTo?: string;
runtimeConfig?: Record<string, unknown>;
pauseReason?: string;
permissions?: Record<string, boolean>;
lastError?: string;
totalInputTokens?: number;
totalOutputTokens?: number;
}
/** Per-task session persistence for an agent */
export interface AgentTaskSession {
/** Agent ID */
agentId: string;
/** Task ID */
taskId: string;
/** Session state for resuming context across runs */
sessionParams: Record<string, unknown>;
/** Human-readable session identifier */
sessionDisplayId?: string;
/** ISO-8601 timestamp when session was created */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/** Aggregate statistics for agents */
export interface AgentStats {
/** Number of agents in active/running state */
activeCount: number;
/** Number of tasks assigned to agents */
assignedTaskCount: number;
/** Total completed runs */
completedRuns: number;
/** Total failed runs */
failedRuns: number;
/** Success rate (0-1) */
successRate: number;
}
// ── Multi-Project First-Run & Migration Types ───────────────────────────────