test(KB-601): add test for pause/unpause with missing directory

This commit is contained in:
gsxdsm
2026-03-31 14:43:55 -07:00
parent fdc70cbd5c
commit b36c164261
21 changed files with 1090 additions and 24 deletions

View File

@@ -166,11 +166,11 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
modelId, validatorModelProvider, validatorModelId, mergeRetries, error,
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
dependencies, steps, log, attachments, steeringComments,
workflowStepResults, prInfo, issueInfo, breakIntoSubtasks,
enabledWorkflowSteps
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`);
@@ -214,9 +214,11 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
toJson(task.log || []),
toJson(task.attachments || []),
toJson(task.steeringComments || []),
toJson(task.comments || []),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),
toJsonNullable(task.mergeDetails),
task.breakIntoSubtasks ? 1 : 0,
toJson(task.enabledWorkflowSteps || []),
);

View File

@@ -92,9 +92,11 @@ CREATE TABLE IF NOT EXISTS tasks (
log TEXT DEFAULT '[]',
attachments TEXT DEFAULT '[]',
steeringComments TEXT DEFAULT '[]',
comments TEXT DEFAULT '[]',
workflowStepResults TEXT DEFAULT '[]',
prInfo TEXT,
issueInfo TEXT,
mergeDetails TEXT,
breakIntoSubtasks INTEGER DEFAULT 0,
enabledWorkflowSteps TEXT DEFAULT '[]'
);

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, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent } 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, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent } from "./types.js";
export { AgentStore } from "./agent-store.js";
export type { AgentStoreEvents } from "./agent-store.js";
export { TaskStore } from "./store.js";

View File

@@ -1017,6 +1017,72 @@ describe("TaskStore", () => {
});
});
describe("task comments", () => {
it("adds a task comment to a task", async () => {
const task = await createTestTask();
const updated = await store.addTaskComment(task.id, "Please review this", "alice");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("Please review this");
expect(updated.comments![0].author).toBe("alice");
expect(updated.comments![0].id).toBeDefined();
expect(updated.comments![0].createdAt).toBeDefined();
expect(updated.comments![0].updatedAt).toBeUndefined();
});
it("updates an existing task comment", async () => {
const task = await createTestTask();
const added = await store.addTaskComment(task.id, "First draft", "alice");
const commentId = added.comments![0].id;
const updated = await store.updateTaskComment(task.id, commentId, "Updated draft");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("Updated draft");
expect(updated.comments![0].updatedAt).toBeDefined();
expect(updated.log.some((entry) => entry.action === "Comment updated")).toBe(true);
});
it("deletes a task comment", async () => {
const task = await createTestTask();
const added = await store.addTaskComment(task.id, "Disposable", "alice");
const commentId = added.comments![0].id;
const updated = await store.deleteTaskComment(task.id, commentId);
expect(updated.comments).toBeUndefined();
expect(updated.log.some((entry) => entry.action === "Comment deleted")).toBe(true);
});
it("throws when updating a missing task comment", async () => {
const task = await createTestTask();
await expect(store.updateTaskComment(task.id, "missing", "Nope")).rejects.toThrow(
`Comment missing not found on task ${task.id}`,
);
});
it("throws when deleting a missing task comment", async () => {
const task = await createTestTask();
await expect(store.deleteTaskComment(task.id, "missing")).rejects.toThrow(
`Comment missing not found on task ${task.id}`,
);
});
it("persists task comments independently from steering comments", async () => {
const task = await createTestTask();
await store.addTaskComment(task.id, "General note", "alice");
await store.addSteeringComment(task.id, "Execution note");
const reopened = await store.getTask(task.id);
expect(reopened.comments).toHaveLength(1);
expect(reopened.comments![0].text).toBe("General note");
expect(reopened.steeringComments).toHaveLength(1);
expect(reopened.steeringComments![0].text).toBe("Execution note");
});
});
describe("addSteeringComment", () => {
it("adds a steering comment to a task", async () => {
const task = await createTestTask();
@@ -1226,6 +1292,43 @@ describe("TaskStore", () => {
});
});
describe("task comments and merge details types", () => {
it("keeps task comments distinct from steering comments on new tasks", async () => {
const task = await createTestTask();
const reopened = await store.getTask(task.id);
expect(reopened.comments).toBeUndefined();
expect(reopened.steeringComments).toBeUndefined();
});
it("supports the task comment and merge details shapes", async () => {
const comment: NonNullable<Task["comments"]>[number] = {
id: `comment-${Date.now()}`,
text: "Looks good",
author: "alice",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mergeDetails: NonNullable<Task["mergeDetails"]> = {
commitSha: "abc123def456",
filesChanged: 3,
insertions: 10,
deletions: 2,
mergeCommitMessage: "feat(KB-001): merge kb/kb-001",
mergedAt: new Date().toISOString(),
mergeConfirmed: true,
prNumber: 42,
};
const taskShape: Pick<Task, "comments" | "mergeDetails"> = {
comments: [comment],
mergeDetails,
};
expect(taskShape.comments).toEqual([comment]);
expect(taskShape.mergeDetails).toEqual(mergeDetails);
});
});
describe("updatePrInfo", () => {
it("adds PR info to a task without existing PR", async () => {
const task = await createTestTask();
@@ -3169,6 +3272,38 @@ describe("TaskStore", () => {
expect(logs[0].type).toBe("task:deleted");
});
it("captures merge details when merging a task", async () => {
const task = await store.createTask({ description: "Test merge details" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
worktree: "/tmp/test-worktree",
});
const { execSync } = await import("node:child_process");
try {
execSync(`git checkout -b kb/${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 {
return;
}
try {
const result = await store.mergeTask(task.id);
expect(result.mergeConfirmed ?? result.merged).toBeDefined();
expect(result.task.mergeDetails).toBeDefined();
if (result.merged) {
expect(result.task.mergeDetails?.commitSha).toBeTruthy();
expect(result.task.mergeDetails?.mergeCommitMessage).toContain(task.id);
expect(result.task.mergeDetails?.mergedAt).toBeDefined();
}
} catch {
// merge may fail depending on repo state; skip strict assertions in that case
}
});
it("records activity on task:merged", async () => {
const task = await store.createTask({ description: "Test merged event" });
await store.moveTask(task.id, "todo");

View File

@@ -141,9 +141,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
log: fromJson<import("./types.js").TaskLogEntry[]>(row.log) || [],
attachments: (() => { const a = fromJson<TaskAttachment[]>(row.attachments); return a && a.length > 0 ? a : undefined; })(),
steeringComments: (() => { const s = fromJson<import("./types.js").SteeringComment[]>(row.steeringComments); return s && s.length > 0 ? s : undefined; })(),
comments: (() => { const c = fromJson<import("./types.js").TaskComment[]>(row.comments); return c && c.length > 0 ? c : undefined; })(),
workflowStepResults: (() => { const w = fromJson<import("./types.js").WorkflowStepResult[]>(row.workflowStepResults); return w && w.length > 0 ? w : undefined; })(),
prInfo: fromJson<import("./types.js").PrInfo>(row.prInfo),
issueInfo: fromJson<import("./types.js").IssueInfo>(row.issueInfo),
mergeDetails: fromJson<import("./types.js").MergeDetails>(row.mergeDetails),
breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined,
enabledWorkflowSteps: (() => { const e = fromJson<string[]>(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(),
};
@@ -160,11 +162,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
modelId, validatorModelProvider, validatorModelId, mergeRetries, error,
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
dependencies, steps, log, attachments, steeringComments,
workflowStepResults, prInfo, issueInfo, breakIntoSubtasks,
enabledWorkflowSteps
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`).run(
task.id,
@@ -196,9 +198,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
toJson(task.log || []),
toJson(task.attachments || []),
toJson(task.steeringComments || []),
toJson(task.comments || []),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),
toJsonNullable(task.mergeDetails),
task.breakIntoSubtasks ? 1 : 0,
toJson(task.enabledWorkflowSteps || []),
);
@@ -1162,6 +1166,58 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
private collectMergeDetails(id: string, branch: string, task: Task, commitMessage: string): import("./types.js").MergeDetails {
const mergedAt = new Date().toISOString();
let commitSha: string | undefined;
let filesChanged: number | undefined;
let insertions: number | undefined;
let deletions: number | undefined;
try {
commitSha = execSync("git rev-parse HEAD", {
cwd: this.rootDir,
stdio: "pipe",
encoding: "utf-8",
}).trim() || undefined;
} catch {
commitSha = undefined;
}
try {
const statsOutput = execSync("git show --shortstat --format= HEAD", {
cwd: this.rootDir,
stdio: "pipe",
encoding: "utf-8",
}).trim();
const normalized = statsOutput.replace(/\n/g, " ");
const filesMatch = normalized.match(/(\d+) files? changed/);
const insertionsMatch = normalized.match(/(\d+) insertions?\(\+\)/);
const deletionsMatch = normalized.match(/(\d+) deletions?\(-\)/);
filesChanged = filesMatch ? Number.parseInt(filesMatch[1], 10) : 0;
insertions = insertionsMatch ? Number.parseInt(insertionsMatch[1], 10) : 0;
deletions = deletionsMatch ? Number.parseInt(deletionsMatch[1], 10) : 0;
} catch {
filesChanged = undefined;
insertions = undefined;
deletions = undefined;
}
return {
commitSha,
filesChanged,
insertions,
deletions,
mergeCommitMessage: commitMessage,
mergedAt,
mergeConfirmed: true,
prNumber: task.prInfo?.number,
resolutionStrategy: task.mergeDetails?.resolutionStrategy,
resolutionMethod: task.mergeDetails?.resolutionMethod,
attemptsMade: task.mergeDetails?.attemptsMade,
autoResolvedCount: task.mergeDetails?.autoResolvedCount,
};
}
/**
* Merge an in-review task's branch into the current branch,
* clean up the worktree, and move the task to done.
@@ -1196,6 +1252,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} catch {
// No branch — might have been manually merged. Just move to done.
result.error = `Branch '${branch}' not found — moving to done without merge`;
task.mergeDetails = {
mergedAt: new Date().toISOString(),
mergeConfirmed: false,
prNumber: task.prInfo?.number,
};
await this.moveToDone(task, dir);
result.task = { ...task, column: "done" };
this.emit("task:merged", result);
@@ -1203,16 +1264,20 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
// 2. Merge the branch
const mergeCommitMessage = `feat(${id}): merge ${branch}`;
try {
execSync(`git merge --squash "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
execSync(`git commit --no-edit -m "feat(${id}): merge ${branch}"`, {
execSync(`git commit --no-edit -m "${mergeCommitMessage}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.merged = true;
const mergeDetails = this.collectMergeDetails(id, branch, task, mergeCommitMessage);
task.mergeDetails = mergeDetails;
Object.assign(result, mergeDetails);
} catch (err: any) {
// Squash conflict — reset and report
try {
@@ -1806,6 +1871,95 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.emit("agent:log", entry);
}
async addTaskComment(id: string, text: string, author: string): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
if (!task.log) {
task.log = [];
}
const commentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const comment: import("./types.js").TaskComment = {
id: commentId,
text,
author,
createdAt: new Date().toISOString(),
};
if (!task.comments) {
task.comments = [];
}
task.comments.push(comment);
task.updatedAt = new Date().toISOString();
task.log.push({
timestamp: task.updatedAt,
action: `Comment added by ${author}`,
});
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
async updateTaskComment(id: string, commentId: string, text: string): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const comments = task.comments || [];
const comment = comments.find((entry) => entry.id === commentId);
if (!comment) {
throw new Error(`Comment ${commentId} not found on task ${id}`);
}
comment.text = text;
comment.updatedAt = new Date().toISOString();
task.comments = comments;
task.updatedAt = comment.updatedAt;
task.log.push({
timestamp: task.updatedAt,
action: "Comment updated",
});
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
async deleteTaskComment(id: string, commentId: string): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const currentComments = task.comments || [];
const nextComments = currentComments.filter((entry) => entry.id !== commentId);
if (nextComments.length === currentComments.length) {
throw new Error(`Comment ${commentId} not found on task ${id}`);
}
task.comments = nextComments.length > 0 ? nextComments : undefined;
task.updatedAt = new Date().toISOString();
task.log.push({
timestamp: task.updatedAt,
action: "Comment deleted",
});
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
/**
* Add a steering comment to a task.
* Steering comments are user-provided feedback injected into the AI execution context.

View File

@@ -333,6 +333,34 @@ export interface SteeringComment {
author: "user" | "agent";
}
export interface TaskComment {
id: string;
text: string;
author: string;
createdAt: string;
updatedAt?: string;
}
export interface TaskCommentInput {
text: string;
author: string;
}
export interface MergeDetails {
commitSha?: string;
filesChanged?: number;
insertions?: number;
deletions?: number;
mergeCommitMessage?: string;
mergedAt?: string;
mergeConfirmed?: boolean;
prNumber?: number;
resolutionStrategy?: "ai" | "auto-resolve" | "theirs";
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs";
attemptsMade?: 1 | 2 | 3;
autoResolvedCount?: number;
}
export interface Task {
id: string;
title?: string;
@@ -360,8 +388,10 @@ export interface Task {
baseBranch?: string;
attachments?: TaskAttachment[];
steeringComments?: SteeringComment[];
comments?: TaskComment[];
/** PR information for tasks linked to GitHub pull requests */
prInfo?: PrInfo;
mergeDetails?: MergeDetails;
/** Issue information for tasks imported from GitHub issues */
issueInfo?: IssueInfo;
log: TaskLogEntry[];
@@ -723,21 +753,13 @@ export interface BoardConfig {
nextWorkflowStepId?: number;
}
export interface MergeResult {
export interface MergeResult extends MergeDetails {
task: Task;
branch: string;
merged: boolean;
worktreeRemoved: boolean;
branchDeleted: boolean;
error?: string;
/** Strategy that successfully resolved the merge, if any */
resolutionStrategy?: "ai" | "auto-resolve" | "theirs";
/** Alias for resolutionStrategy — how conflicts were resolved (for metrics/debugging) */
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs";
/** Number of retry attempts made (1 = first attempt succeeded, 2-3 = retries needed) */
attemptsMade?: 1 | 2 | 3;
/** Number of files auto-resolved (for tracking mixed resolution scenarios) */
autoResolvedCount?: number;
}
export const COLUMN_LABELS: Record<Column, string> = {