test(KB-323): add end-to-end verification test for steering comments injection

- Add comprehensive e2e test for steering comments injection in executor
- Verify system prompt steering comments are correctly injected
- Test covers executor initialization and prompt construction
This commit is contained in:
gsxdsm
2026-03-31 12:51:06 -07:00
parent a9b0cb55f9
commit 210328000c
17 changed files with 5379 additions and 56 deletions

View File

@@ -0,0 +1,598 @@
/**
* AgentStore - Filesystem-based persistence for agent lifecycle management
*
* Agents are stored at `.kb/agents/{agentId}.json` with their metadata.
* Heartbeat events are appended to `.kb/agents/{agentId}-heartbeats.jsonl`.
*
* File Structure:
* - agents/{agentId}.json: Agent metadata (id, name, role, state, taskId, timestamps, metadata)
* - agents/{agentId}-heartbeats.jsonl: Append-only heartbeat events
*/
import { mkdir, readFile, writeFile, readdir, unlink } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type {
Agent,
AgentState,
AgentCapability,
AgentCreateInput,
AgentUpdateInput,
AgentHeartbeatEvent,
AgentHeartbeatRun,
AgentDetail,
} from "./types.js";
import { AGENT_VALID_TRANSITIONS } from "./types.js";
/** Events emitted by AgentStore */
export interface AgentStoreEvents {
/** Emitted when an agent is created */
"agent:created": (agent: Agent) => void;
/** Emitted when an agent is updated */
"agent:updated": (agent: Agent, previousState?: AgentState) => void;
/** Emitted when an agent is deleted */
"agent:deleted": (agentId: string) => void;
/** Emitted when a heartbeat is recorded */
"agent:heartbeat": (agentId: string, event: AgentHeartbeatEvent) => void;
/** Emitted when an agent state changes */
"agent:stateChanged": (agentId: string, from: AgentState, to: AgentState) => void;
}
type TypedEventEmitter<Events extends Record<string, unknown[]>> = {
[K in keyof Events]: {
emit(event: K, ...args: Events[K]): boolean;
on(event: K, listener: (...args: Events[K]) => void): TypedEventEmitter<Events>;
once(event: K, listener: (...args: Events[K]) => void): TypedEventEmitter<Events>;
off(event: K, listener: (...args: Events[K]) => void): TypedEventEmitter<Events>;
};
}[keyof Events] & EventEmitter;
/** Options for AgentStore constructor */
export interface AgentStoreOptions {
/** Root directory for kb data (default: .kb) */
rootDir?: string;
}
/** Agent data as stored on disk */
interface AgentData {
id: string;
name: string;
role: AgentCapability;
state: AgentState;
taskId?: string;
createdAt: string;
updatedAt: string;
lastHeartbeatAt?: string;
metadata: Record<string, unknown>;
}
/** Per-agent write lock for serialization */
interface AgentLock {
promise: Promise<unknown>;
}
/**
* AgentStore manages agent lifecycle with filesystem-based persistence.
* Follows the same patterns as TaskStore for consistency.
*/
export class AgentStore extends EventEmitter {
private rootDir: string;
private agentsDir: string;
private locks: Map<string, AgentLock> = new Map();
constructor(options: AgentStoreOptions = {}) {
super();
this.rootDir = options.rootDir ?? ".kb";
this.agentsDir = join(this.rootDir, "agents");
}
/**
* Initialize the store by creating necessary directories.
* Should be called before other operations.
*/
async init(): Promise<void> {
await mkdir(this.agentsDir, { recursive: true });
}
/**
* Create a new agent with "idle" state.
* @param input - Creation parameters
* @returns The created agent
* @throws Error if input is invalid
*/
async createAgent(input: AgentCreateInput): Promise<Agent> {
if (!input.name?.trim()) {
throw new Error("Agent name is required");
}
if (!input.role) {
throw new Error("Agent role is required");
}
const now = new Date().toISOString();
const agentId = `agent-${randomUUID().slice(0, 8)}`;
const agent: Agent = {
id: agentId,
name: input.name.trim(),
role: input.role,
state: "idle",
createdAt: now,
updatedAt: now,
metadata: input.metadata ?? {},
};
await this.writeAgent(agent);
this.emit("agent:created", agent);
return agent;
}
/**
* Get an agent by ID.
* @param agentId - The agent ID
* @returns The agent, or null if not found
*/
async getAgent(agentId: string): Promise<Agent | null> {
try {
const data = await this.readAgentFile(agentId);
return this.parseAgent(data);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw err;
}
}
/**
* Get detailed agent info including heartbeat history.
* @param agentId - The agent ID
* @param heartbeatLimit - Max number of heartbeat events to return (default: 50)
* @returns Agent detail, or null if not found
*/
async getAgentDetail(agentId: string, heartbeatLimit = 50): Promise<AgentDetail | null> {
const agent = await this.getAgent(agentId);
if (!agent) return null;
const [history, activeRun, completedRuns] = await Promise.all([
this.getHeartbeatHistory(agentId, heartbeatLimit),
this.getActiveHeartbeatRun(agentId),
this.getCompletedHeartbeatRuns(agentId),
]);
return {
...agent,
heartbeatHistory: history,
activeRun: activeRun ?? undefined,
completedRuns,
};
}
/**
* Update an agent with partial updates.
* @param agentId - The agent ID
* @param updates - Fields to update
* @returns The updated agent
* @throws Error if agent not found
*/
async updateAgent(agentId: string, updates: AgentUpdateInput): Promise<Agent> {
return this.withLock(agentId, async () => {
const agent = await this.getAgent(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
const updated: Agent = {
...agent,
name: updates.name?.trim() ?? agent.name,
role: updates.role ?? agent.role,
metadata: updates.metadata !== undefined ? updates.metadata : agent.metadata,
updatedAt: new Date().toISOString(),
};
await this.writeAgent(updated);
this.emit("agent:updated", updated);
return updated;
});
}
/**
* Update an agent's state with validation.
* @param agentId - The agent ID
* @param newState - The target state
* @returns The updated agent
* @throws Error if transition is invalid or agent not found
*/
async updateAgentState(agentId: string, newState: AgentState): Promise<Agent> {
return this.withLock(agentId, async () => {
const agent = await this.getAgent(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
const currentState = agent.state;
// Validate transition
if (currentState === newState) {
return agent; // No change needed
}
if (currentState === "terminated") {
throw new Error(`Cannot transition from terminated state to ${newState}`);
}
const validTransitions = AGENT_VALID_TRANSITIONS[currentState];
if (!validTransitions.includes(newState)) {
throw new Error(
`Invalid state transition: ${currentState} -> ${newState}. Valid transitions: ${validTransitions.join(", ")}`
);
}
const updated: Agent = {
...agent,
state: newState,
updatedAt: new Date().toISOString(),
};
await this.writeAgent(updated);
this.emit("agent:stateChanged", agentId, currentState, newState);
this.emit("agent:updated", updated, currentState);
// Handle heartbeat run lifecycle
if (newState === "active" && !agent.lastHeartbeatAt) {
// Starting first activity - start a heartbeat run
await this.startHeartbeatRun(agentId);
} else if (newState === "terminated") {
// End the active run if any
const activeRun = await this.getActiveHeartbeatRun(agentId);
if (activeRun) {
await this.endHeartbeatRun(activeRun.id, "terminated");
}
}
return updated;
});
}
/**
* Assign a task to an agent.
* @param agentId - The agent ID
* @param taskId - The task ID to assign, or undefined to unassign
* @returns The updated agent
*/
async assignTask(agentId: string, taskId: string | undefined): Promise<Agent> {
return this.withLock(agentId, async () => {
const agent = await this.getAgent(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
const updated: Agent = {
...agent,
taskId,
updatedAt: new Date().toISOString(),
};
await this.writeAgent(updated);
this.emit("agent:updated", updated);
return updated;
});
}
/**
* List all agents, optionally filtered by state.
* @param filter - Optional filter criteria
* @returns Array of agents
*/
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 agents: Agent[] = [];
for (const file of agentFiles) {
try {
const data = await this.readAgentFile(file.replace(".json", ""));
const agent = this.parseAgent(data);
// Apply filters
if (filter?.state && agent.state !== filter.state) continue;
if (filter?.role && agent.role !== filter.role) continue;
agents.push(agent);
} catch {
// Skip corrupted files
}
}
// Sort by createdAt desc
return agents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
/**
* Delete an agent and its heartbeat history.
* @param agentId - The agent ID
* @throws Error if agent not found
*/
async deleteAgent(agentId: string): Promise<void> {
await this.withLock(agentId, async () => {
const agentPath = join(this.agentsDir, `${agentId}.json`);
const heartbeatPath = join(this.agentsDir, `${agentId}-heartbeats.jsonl`);
// Verify agent exists
const agent = await this.getAgent(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
// Delete files
await unlink(agentPath).catch(() => {});
await unlink(heartbeatPath).catch(() => {});
this.emit("agent:deleted", agentId);
});
}
/**
* Record a heartbeat event for an agent.
* @param agentId - The agent ID
* @param status - Heartbeat status
* @param runId - Optional run ID (uses active run if not provided)
* @returns The recorded heartbeat event
*/
async recordHeartbeat(
agentId: string,
status: AgentHeartbeatEvent["status"],
runId?: string
): Promise<AgentHeartbeatEvent> {
return this.withLock(agentId, async () => {
// Verify agent exists
const agent = await this.getAgent(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
// Get or determine run ID
let effectiveRunId = runId;
if (!effectiveRunId) {
const activeRun = await this.getActiveHeartbeatRun(agentId);
effectiveRunId = activeRun?.id ?? `run-${randomUUID().slice(0, 8)}`;
}
const event: AgentHeartbeatEvent = {
timestamp: new Date().toISOString(),
status,
runId: effectiveRunId,
};
// Append to heartbeat log
const heartbeatPath = join(this.agentsDir, `${agentId}-heartbeats.jsonl`);
const line = JSON.stringify(event) + "\n";
await writeFile(heartbeatPath, line, { flag: "a" });
// Update agent's lastHeartbeatAt if status is ok
if (status === "ok") {
const updated: Agent = {
...agent,
lastHeartbeatAt: event.timestamp,
updatedAt: event.timestamp,
};
await this.writeAgent(updated);
}
this.emit("agent:heartbeat", agentId, event);
return event;
});
}
/**
* Get heartbeat history for an agent.
* @param agentId - The agent ID
* @param limit - Maximum number of events to return (default: 50)
* @returns Array of heartbeat events (newest first)
*/
async getHeartbeatHistory(agentId: string, limit = 50): Promise<AgentHeartbeatEvent[]> {
const heartbeatPath = join(this.agentsDir, `${agentId}-heartbeats.jsonl`);
if (!existsSync(heartbeatPath)) {
return [];
}
try {
const content = await readFile(heartbeatPath, "utf-8");
const lines = content.trim().split("\n").filter(Boolean);
// Parse events and reverse (newest first)
const events: AgentHeartbeatEvent[] = lines
.map((line) => JSON.parse(line) as AgentHeartbeatEvent)
.reverse()
.slice(0, limit);
return events;
} catch {
return [];
}
}
/**
* Start a new heartbeat run for an agent.
* @param agentId - The agent ID
* @returns The created run
*/
async startHeartbeatRun(agentId: string): Promise<AgentHeartbeatRun> {
const runId = `run-${randomUUID().slice(0, 8)}`;
const run: AgentHeartbeatRun = {
id: runId,
agentId,
startedAt: new Date().toISOString(),
endedAt: null,
status: "active",
};
// Record as heartbeat event to track runs
await this.recordHeartbeat(agentId, "ok", runId);
return run;
}
/**
* End a heartbeat run.
* @param runId - The run ID
* @param status - End status (completed or terminated)
*/
async endHeartbeatRun(runId: string, status: "completed" | "terminated"): Promise<void> {
// Find the agent for this run by scanning heartbeat files
const files = await readdir(this.agentsDir).catch(() => [] as string[]);
const heartbeatFiles = files.filter((f) => f.endsWith("-heartbeats.jsonl"));
for (const file of heartbeatFiles) {
const agentId = file.replace("-heartbeats.jsonl", "");
const history = await this.getHeartbeatHistory(agentId, 1000);
// Check if this run exists in the history
const hasRun = history.some((h) => h.runId === runId);
if (hasRun) {
// Record end as special event
await this.recordHeartbeat(agentId, status === "terminated" ? "missed" : "ok", runId);
return;
}
}
}
/**
* Get the active heartbeat run for an agent.
* @param agentId - The agent ID
* @returns The active run, or null if none
*/
async getActiveHeartbeatRun(agentId: string): Promise<AgentHeartbeatRun | null> {
const history = await this.getHeartbeatHistory(agentId, 100);
// Find the most recent run that started but hasn't ended
// A run is considered ended if there's a terminal state transition
const runs = new Map<string, AgentHeartbeatRun>();
for (const event of history) {
if (!runs.has(event.runId)) {
runs.set(event.runId, {
id: event.runId,
agentId,
startedAt: event.timestamp,
endedAt: null,
status: "active",
});
}
// Update based on event status
const run = runs.get(event.runId)!;
if (event.status === "missed") {
run.endedAt = event.timestamp;
run.status = "terminated";
}
}
// Return the most recent active run
for (const run of runs.values()) {
if (run.status === "active") {
return run;
}
}
return null;
}
/**
* Get all completed heartbeat runs for an agent.
* @param agentId - The agent ID
* @returns Array of completed runs
*/
async getCompletedHeartbeatRuns(agentId: string): Promise<AgentHeartbeatRun[]> {
const history = await this.getHeartbeatHistory(agentId, 1000);
const runs = new Map<string, AgentHeartbeatRun>();
for (const event of history) {
if (!runs.has(event.runId)) {
runs.set(event.runId, {
id: event.runId,
agentId,
startedAt: event.timestamp,
endedAt: null,
status: "active",
});
}
const run = runs.get(event.runId)!;
if (event.status === "missed") {
run.endedAt = event.timestamp;
run.status = "terminated";
}
}
return Array.from(runs.values()).filter((r) => r.status !== "active");
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
private async readAgentFile(agentId: string): Promise<AgentData> {
const path = join(this.agentsDir, `${agentId}.json`);
const content = await readFile(path, "utf-8");
return JSON.parse(content) as AgentData;
}
private parseAgent(data: AgentData): Agent {
return {
id: data.id,
name: data.name,
role: data.role,
state: data.state,
taskId: data.taskId,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
lastHeartbeatAt: data.lastHeartbeatAt,
metadata: data.metadata ?? {},
};
}
private async writeAgent(agent: Agent): Promise<void> {
const path = join(this.agentsDir, `${agent.id}.json`);
const data: AgentData = {
id: agent.id,
name: agent.name,
role: agent.role,
state: agent.state,
taskId: agent.taskId,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
lastHeartbeatAt: agent.lastHeartbeatAt,
metadata: agent.metadata,
};
// Write atomically using temp file
const tempPath = `${path}.tmp.${Date.now()}`;
await writeFile(tempPath, JSON.stringify(data, null, 2));
// Rename temp file to final path (atomic on most filesystems)
const { rename } = await import("node:fs/promises");
await rename(tempPath, path);
}
private async withLock<T>(agentId: string, fn: () => Promise<T>): Promise<T> {
// Get or create lock for this agent
let lock = this.locks.get(agentId);
if (!lock) {
lock = { promise: Promise.resolve() };
this.locks.set(agentId, lock);
}
// Chain operations
const operation = lock.promise.then(fn, fn);
lock.promise = operation;
return operation as Promise<T>;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
import { EventEmitter } from "node:events";
import { execSync } from "node:child_process";
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
import { join, sep } from "node:path";
import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS } from "./types.js";
import { GlobalSettingsStore } from "./global-settings.js";
export interface TaskStoreEvents {
"task:created": [task: Task];
"task:moved": [data: { task: Task; from: Column; to: Column }];
"task:updated": [task: Task];
"task:deleted": [task: Task];
"task:merged": [result: MergeResult];
"settings:updated": [data: { settings: Settings; previous: Settings }];
"agent:log": [entry: AgentLogEntry];
}
export class TaskStore extends EventEmitter<TaskStoreEvents> {
private kbDir: string;
private tasksDir: string;
private configPath: string;
private archiveLogPath: string;
private activityLogPath: string;
/** File-system watcher instance */
private watcher: FSWatcher | null = null;
/** In-memory cache of tasks for diffing watcher events */
private taskCache: Map<string, Task> = new Map();
/** Paths recently written by in-process mutations (suppresses duplicate events) */
private recentlyWritten: Set<string> = new Set();
/** Pending debounce timers keyed by task ID */
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
/** Debounce interval in ms */
private debounceMs = 150;
/** Per-task promise chain for serializing writes */
private taskLocks: Map<string, Promise<void>> = new Map();
/** Promise chain for serializing config.json read-modify-write cycles */
private configLock: Promise<void> = Promise.resolve();
/** Global settings store (`~/.pi/kb/settings.json`) */
private globalSettingsStore: GlobalSettingsStore;
constructor(private rootDir: string, globalSettingsDir?: string) {
super();
this.setMaxListeners(100);
this.kbDir = join(rootDir, ".kb");
this.tasksDir = join(this.kbDir, "tasks");
this.configPath = join(this.kbDir, "config.json");
this.archiveLogPath = join(this.kbDir, "archive.jsonl");

View File

@@ -833,3 +833,90 @@ export interface PlanningSession {
createdAt: Date;
updatedAt: Date;
}
// ── Agent Types ────────────────────────────────────────────────────────────
/** Agent lifecycle states */
export const AGENT_STATES = ["idle", "active", "paused", "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"],
paused: ["active", "terminated"],
terminated: [], // Terminal state - no exits
};
/** Single heartbeat event recorded for an agent */
export interface AgentHeartbeatEvent {
/** ISO-8601 timestamp of when the heartbeat was recorded */
timestamp: string;
/** Status of the heartbeat */
status: "ok" | "missed" | "recovered";
/** ID of the heartbeat run this event belongs to */
runId: string;
}
/** A continuous heartbeat session/run for an agent */
export interface AgentHeartbeatRun {
/** Unique identifier for this run */
id: string;
/** ID of the agent this run belongs to */
agentId: string;
/** ISO-8601 timestamp when the run started */
startedAt: string;
/** ISO-8601 timestamp when the run ended (null if active) */
endedAt: string | null;
/** Status of the run */
status: "active" | "completed" | "terminated";
}
/** Capabilities/roles an agent can have */
export type AgentCapability = "triage" | "executor" | "reviewer" | "merger" | "scheduler" | "custom";
/** Agent record stored in the system */
export interface Agent {
/** Unique identifier (e.g., "agent-001") */
id: string;
/** Display name */
name: string;
/** Role/capability of the agent */
role: AgentCapability;
/** Current lifecycle state */
state: AgentState;
/** ID of the task this agent is currently working on (if any) */
taskId?: string;
/** ISO-8601 timestamp when the agent was created */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
/** ISO-8601 timestamp of last successful heartbeat */
lastHeartbeatAt?: string;
/** Optional metadata */
metadata: Record<string, unknown>;
}
/** Extended agent information including heartbeat history */
export interface AgentDetail extends Agent {
/** Recent heartbeat events (last N events) */
heartbeatHistory: AgentHeartbeatEvent[];
/** Current active heartbeat run (if any) */
activeRun?: AgentHeartbeatRun;
/** All completed runs for this agent */
completedRuns: AgentHeartbeatRun[];
}
/** Input for creating a new agent */
export interface AgentCreateInput {
name: string;
role: AgentCapability;
metadata?: Record<string, unknown>;
}
/** Input for updating an existing agent */
export interface AgentUpdateInput {
name?: string;
role?: AgentCapability;
metadata?: Record<string, unknown>;
}