fix blocking hot path operations
This commit is contained in:
5
.changeset/fix-blocking-engine-operations.md
Normal file
5
.changeset/fix-blocking-engine-operations.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gsxdsm/fusion": patch
|
||||
---
|
||||
|
||||
Avoid blocking task sweeps and configured command execution in hot engine, dashboard, and CLI paths.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { execSync, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { cpSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
@@ -34,6 +34,33 @@ function hasKnownBunSqliteLimitation(result: { stderr: string | null }): boolean
|
||||
return result.stderr?.includes("No such built-in module: node:sqlite") ?? false;
|
||||
}
|
||||
|
||||
async function stopChildProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(sigtermTimeout);
|
||||
clearTimeout(sigkillTimeout);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const sigtermTimeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 1_000);
|
||||
const sigkillTimeout = setTimeout(finish, 2_000);
|
||||
|
||||
child.once("close", finish);
|
||||
child.kill("SIGTERM");
|
||||
});
|
||||
}
|
||||
|
||||
describe("build-exe", () => {
|
||||
beforeAll(() => {
|
||||
// Build the executable (skip if already built to speed up re-runs)
|
||||
@@ -101,12 +128,13 @@ describe("build-exe", () => {
|
||||
it("binary starts dashboard and can create PTY terminal sessions", async () => {
|
||||
const { spawn } = await import("node:child_process");
|
||||
const { binary, dir, cleanup } = createIsolatedDir();
|
||||
const port = 15040 + Math.floor(Math.random() * 1000);
|
||||
let child: ReturnType<typeof spawn> | null = null;
|
||||
let port: number | undefined;
|
||||
|
||||
try {
|
||||
// Start the dashboard
|
||||
child = spawn(binary, ["dashboard", "-p", String(port)], {
|
||||
// Ask the OS for an ephemeral port so this smoke test never collides
|
||||
// with a developer's running dashboard or dev server.
|
||||
child = spawn(binary, ["dashboard", "-p", "0"], {
|
||||
cwd: dir,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
@@ -160,10 +188,9 @@ describe("build-exe", () => {
|
||||
|
||||
child!.stdout!.on("data", (d: Buffer) => {
|
||||
startupOutput += d.toString();
|
||||
if (
|
||||
startupOutput.includes("fn board") &&
|
||||
startupOutput.includes(`→ http://localhost:${port}`)
|
||||
) {
|
||||
const portMatch = startupOutput.match(/http:\/\/localhost:(\d+)/);
|
||||
if (startupOutput.includes("fn board") && portMatch) {
|
||||
port = Number(portMatch[1]);
|
||||
settle("ready");
|
||||
}
|
||||
});
|
||||
@@ -203,6 +230,10 @@ describe("build-exe", () => {
|
||||
if (outcome === "sqlite-unsupported") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (port === undefined) {
|
||||
throw new Error(`Dashboard reported ready without a port\nOutput:\n${startupOutput}`);
|
||||
}
|
||||
|
||||
// outcome === "ready" — verify PTY session creation endpoint
|
||||
let response: Response | undefined;
|
||||
@@ -255,11 +286,7 @@ describe("build-exe", () => {
|
||||
expect(data.shell).toBeDefined();
|
||||
}
|
||||
} finally {
|
||||
if (child) {
|
||||
child.kill("SIGTERM");
|
||||
// Give it time to clean up
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
await stopChildProcess(child);
|
||||
cleanup();
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
@@ -805,7 +805,7 @@ export async function runServe(
|
||||
);
|
||||
|
||||
if (settings.autoMerge) {
|
||||
const existing = await store.listTasks();
|
||||
const existing = await store.listTasks({ column: "in-review" });
|
||||
const inReview = existing.filter((t) => !getTaskMergeBlocker(t));
|
||||
if (inReview.length > 0) {
|
||||
console.log(
|
||||
@@ -836,7 +836,7 @@ export async function runServe(
|
||||
|
||||
if (s.autoMerge) {
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ column: "in-review" });
|
||||
for (const t of tasks) {
|
||||
if (!getTaskMergeBlocker(t)) {
|
||||
enqueueMerge(t.id);
|
||||
@@ -862,7 +862,7 @@ export async function runServe(
|
||||
|
||||
if (s.autoMerge) {
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ column: "in-review" });
|
||||
for (const t of tasks) {
|
||||
if (!getTaskMergeBlocker(t)) {
|
||||
enqueueMerge(t.id);
|
||||
@@ -916,7 +916,7 @@ export async function runServe(
|
||||
const s = await store.getSettings();
|
||||
cachedMaxConcurrent = s.maxConcurrent;
|
||||
if (!s.globalPause && !s.enginePaused && s.autoMerge) {
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ column: "in-review" });
|
||||
for (const t of tasks) {
|
||||
if (!getTaskMergeBlocker(t)) {
|
||||
enqueueMerge(t.id);
|
||||
|
||||
@@ -10,8 +10,18 @@ vi.mock("node:child_process", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./run-command.js", async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import("./run-command.js")>();
|
||||
return {
|
||||
...mod,
|
||||
runCommandAsync: vi.fn((...args: Parameters<typeof mod.runCommandAsync>) => mod.runCommandAsync(...args)),
|
||||
};
|
||||
});
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
import { runCommandAsync } from "./run-command.js";
|
||||
const mockedRunCommandAsync = vi.mocked(runCommandAsync);
|
||||
|
||||
import { TaskStore } from "./store.js";
|
||||
import { readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||
@@ -6171,6 +6181,7 @@ Task with acceptance criteria
|
||||
describe("branch cleanup on delete and archive", () => {
|
||||
beforeEach(() => {
|
||||
mockedExecSync.mockClear();
|
||||
mockedRunCommandAsync.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -6181,21 +6192,27 @@ Task with acceptance criteria
|
||||
return realExecSync(...args);
|
||||
},
|
||||
);
|
||||
mockedRunCommandAsync.mockImplementation((...args: Parameters<typeof runCommandAsync>) =>
|
||||
vi.importActual<typeof import("./run-command.js")>("./run-command.js").then((mod) =>
|
||||
mod.runCommandAsync(...args),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteTask attempts branch cleanup via cleanupBranchForTask", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
// Mock: verify succeeds, delete succeeds
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (typeof cmd === "string" && cmd.includes("git rev-parse --verify")) return Buffer.from("");
|
||||
if (typeof cmd === "string" && cmd.includes("git branch -D")) return Buffer.from("");
|
||||
throw new Error(`unexpected execSync call: ${cmd}`);
|
||||
mockedRunCommandAsync.mockImplementation(async (cmd: string) => {
|
||||
if (cmd.includes("git rev-parse --verify") || cmd.includes("git branch -D")) {
|
||||
return { stdout: "", stderr: "", exitCode: 0, signal: null, bufferExceeded: false, timedOut: false };
|
||||
}
|
||||
throw new Error(`unexpected runCommandAsync call: ${cmd}`);
|
||||
});
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
|
||||
const calls = mockedExecSync.mock.calls.map((c) => c[0] as string);
|
||||
const calls = mockedRunCommandAsync.mock.calls.map((c) => c[0] as string);
|
||||
const verifyCalls = calls.filter((c) => c.includes("git rev-parse --verify") && c.includes(`fusion/${task.id.toLowerCase()}`));
|
||||
const deleteCalls = calls.filter((c) => c.includes("git branch -D") && c.includes(`fusion/${task.id.toLowerCase()}`));
|
||||
expect(verifyCalls.length).toBeGreaterThanOrEqual(1);
|
||||
@@ -6206,15 +6223,16 @@ Task with acceptance criteria
|
||||
const task = await store.createTask({ description: "Branch test" });
|
||||
await store.updateTask(task.id, { branch: "fusion/my-custom-branch" });
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (typeof cmd === "string" && cmd.includes("git rev-parse --verify")) return Buffer.from("");
|
||||
if (typeof cmd === "string" && cmd.includes("git branch -D")) return Buffer.from("");
|
||||
throw new Error(`unexpected execSync call: ${cmd}`);
|
||||
mockedRunCommandAsync.mockImplementation(async (cmd: string) => {
|
||||
if (cmd.includes("git rev-parse --verify") || cmd.includes("git branch -D")) {
|
||||
return { stdout: "", stderr: "", exitCode: 0, signal: null, bufferExceeded: false, timedOut: false };
|
||||
}
|
||||
throw new Error(`unexpected runCommandAsync call: ${cmd}`);
|
||||
});
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
|
||||
const calls = mockedExecSync.mock.calls.map((c) => c[0] as string);
|
||||
const calls = mockedRunCommandAsync.mock.calls.map((c) => c[0] as string);
|
||||
|
||||
// Should verify and delete both stored and derived branches
|
||||
const customBranchVerify = calls.filter((c) => c.includes(`git rev-parse --verify "fusion/my-custom-branch"`));
|
||||
@@ -6230,8 +6248,13 @@ Task with acceptance criteria
|
||||
it("deleteTask succeeds even when branch cleanup fails", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("not a git repo");
|
||||
mockedRunCommandAsync.mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "not a git repo",
|
||||
exitCode: 128,
|
||||
signal: null,
|
||||
bufferExceeded: false,
|
||||
timedOut: false,
|
||||
});
|
||||
|
||||
const deleted = await store.deleteTask(task.id);
|
||||
@@ -6245,15 +6268,16 @@ Task with acceptance criteria
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (typeof cmd === "string" && cmd.includes("git rev-parse --verify")) return Buffer.from("");
|
||||
if (typeof cmd === "string" && cmd.includes("git branch -D")) return Buffer.from("");
|
||||
throw new Error(`unexpected execSync call: ${cmd}`);
|
||||
mockedRunCommandAsync.mockImplementation(async (cmd: string) => {
|
||||
if (cmd.includes("git rev-parse --verify") || cmd.includes("git branch -D")) {
|
||||
return { stdout: "", stderr: "", exitCode: 0, signal: null, bufferExceeded: false, timedOut: false };
|
||||
}
|
||||
throw new Error(`unexpected runCommandAsync call: ${cmd}`);
|
||||
});
|
||||
|
||||
await store.archiveTask(task.id, true);
|
||||
|
||||
const calls = mockedExecSync.mock.calls.map((c) => c[0] as string);
|
||||
const calls = mockedRunCommandAsync.mock.calls.map((c) => c[0] as string);
|
||||
const verifyCalls = calls.filter((c) => c.includes("git rev-parse --verify") && c.includes(`fusion/${task.id.toLowerCase()}`));
|
||||
const deleteCalls = calls.filter((c) => c.includes("git branch -D") && c.includes(`fusion/${task.id.toLowerCase()}`));
|
||||
expect(verifyCalls.length).toBeGreaterThanOrEqual(1);
|
||||
@@ -6267,13 +6291,11 @@ Task with acceptance criteria
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("mocked: no git repo");
|
||||
});
|
||||
mockedRunCommandAsync.mockClear();
|
||||
|
||||
await store.archiveTask(task.id, false);
|
||||
|
||||
const calls = mockedExecSync.mock.calls.map((c) => c[0] as string);
|
||||
const calls = mockedRunCommandAsync.mock.calls.map((c) => c[0] as string);
|
||||
const branchCommands = calls.filter((c) => c.includes("git branch -D") || c.includes("git rev-parse --verify"));
|
||||
expect(branchCommands).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appendFile, mkdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -15,6 +14,7 @@ import { BackwardCompat, ProjectRequiredError } from "./migration.js";
|
||||
import { CentralCore } from "./central-core.js";
|
||||
import { getTaskMergeBlocker } from "./task-merge.js";
|
||||
import { ensureMemoryFile } from "./project-memory.js";
|
||||
import { runCommandAsync } from "./run-command.js";
|
||||
|
||||
export interface TaskStoreEvents {
|
||||
"task:created": [task: Task];
|
||||
@@ -2252,7 +2252,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
|
||||
// Clean up the task's branch before deleting from DB
|
||||
const cleanedBranches = this.cleanupBranchForTask(task);
|
||||
const cleanedBranches = await this.cleanupBranchForTask(task);
|
||||
if (cleanedBranches.length > 0) {
|
||||
if (!task.log) task.log = [];
|
||||
task.log.push({
|
||||
@@ -2292,7 +2292,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
*
|
||||
* @returns Array of branch names that were successfully deleted
|
||||
*/
|
||||
private cleanupBranchForTask(task: Task): string[] {
|
||||
private async runGitCommand(command: string, timeoutMs = 10_000) {
|
||||
return runCommandAsync(command, {
|
||||
cwd: this.rootDir,
|
||||
timeoutMs,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
private async cleanupBranchForTask(task: Task): Promise<string[]> {
|
||||
const branches = new Set<string>();
|
||||
if (task.branch) {
|
||||
branches.add(task.branch);
|
||||
@@ -2301,49 +2309,36 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
const deleted: string[] = [];
|
||||
for (const branch of branches) {
|
||||
try {
|
||||
// Verify branch exists before trying to delete
|
||||
execSync(`git rev-parse --verify "${branch}"`, {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
timeout: 10_000,
|
||||
});
|
||||
execSync(`git branch -D "${branch}"`, {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
timeout: 10_000,
|
||||
});
|
||||
const verify = await this.runGitCommand(`git rev-parse --verify "${branch}"`);
|
||||
if (verify.exitCode !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const remove = await this.runGitCommand(`git branch -D "${branch}"`);
|
||||
if (remove.exitCode === 0) {
|
||||
deleted.push(branch);
|
||||
} catch {
|
||||
// Branch doesn't exist or deletion failed — silently skip
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private collectMergeDetails(_id: string, _branch: string, task: Task, commitMessage: string): import("./types.js").MergeDetails {
|
||||
private async collectMergeDetails(_id: string, _branch: string, task: Task, commitMessage: string): Promise<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 {
|
||||
const headResult = await this.runGitCommand("git rev-parse HEAD");
|
||||
if (headResult.exitCode === 0) {
|
||||
commitSha = headResult.stdout.trim() || undefined;
|
||||
} else {
|
||||
commitSha = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const statsOutput = execSync("git show --shortstat --format= HEAD", {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
const statsResult = await this.runGitCommand("git show --shortstat --format= HEAD");
|
||||
if (statsResult.exitCode === 0) {
|
||||
const statsOutput = statsResult.stdout.trim();
|
||||
const normalized = statsOutput.replace(/\n/g, " ");
|
||||
const filesMatch = normalized.match(/(\d+) files? changed/);
|
||||
const insertionsMatch = normalized.match(/(\d+) insertions?\(\+\)/);
|
||||
@@ -2351,7 +2346,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
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 {
|
||||
} else {
|
||||
filesChanged = undefined;
|
||||
insertions = undefined;
|
||||
deletions = undefined;
|
||||
@@ -2398,12 +2393,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
};
|
||||
|
||||
// 1. Check the branch exists
|
||||
try {
|
||||
execSync(`git rev-parse --verify "${branch}"`, {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
const verifyBranch = await this.runGitCommand(`git rev-parse --verify "${branch}"`);
|
||||
if (verifyBranch.exitCode !== 0) {
|
||||
// No branch — might have been manually merged. Just move to done.
|
||||
result.error = `Branch '${branch}' not found — moving to done without merge`;
|
||||
task.mergeDetails = {
|
||||
@@ -2419,26 +2410,19 @@ 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 "${mergeCommitMessage}"`, {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const merge = await this.runGitCommand(`git merge --squash "${branch}"`, 120_000);
|
||||
const commit = merge.exitCode === 0
|
||||
? await this.runGitCommand(`git commit --no-edit -m "${mergeCommitMessage}"`, 120_000)
|
||||
: merge;
|
||||
|
||||
if (merge.exitCode === 0 && commit.exitCode === 0) {
|
||||
result.merged = true;
|
||||
const mergeDetails = this.collectMergeDetails(id, branch, task, mergeCommitMessage);
|
||||
const mergeDetails = await this.collectMergeDetails(id, branch, task, mergeCommitMessage);
|
||||
task.mergeDetails = mergeDetails;
|
||||
Object.assign(result, mergeDetails);
|
||||
} catch (err: any) {
|
||||
} else {
|
||||
// Squash conflict — reset and report
|
||||
try {
|
||||
execSync("git reset --merge", { cwd: this.rootDir, stdio: "pipe" });
|
||||
} catch {
|
||||
// already clean
|
||||
}
|
||||
await this.runGitCommand("git reset --merge");
|
||||
throw new Error(
|
||||
`Merge conflict merging '${branch}'. Resolve manually:\n` +
|
||||
` cd ${this.rootDir}\n` +
|
||||
@@ -2449,34 +2433,21 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
// 3. Remove worktree
|
||||
if (worktreePath && existsSync(worktreePath)) {
|
||||
try {
|
||||
execSync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const removeWorktree = await this.runGitCommand(`git worktree remove "${worktreePath}" --force`, 120_000);
|
||||
if (removeWorktree.exitCode === 0) {
|
||||
result.worktreeRemoved = true;
|
||||
} catch {
|
||||
// Non-fatal — worktree may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Delete the branch
|
||||
try {
|
||||
execSync(`git branch -d "${branch}"`, {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const deleteBranch = await this.runGitCommand(`git branch -d "${branch}"`);
|
||||
if (deleteBranch.exitCode === 0) {
|
||||
result.branchDeleted = true;
|
||||
} catch {
|
||||
} else {
|
||||
// Branch might not be fully merged in some edge cases; try force
|
||||
try {
|
||||
execSync(`git branch -D "${branch}"`, {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const forceDeleteBranch = await this.runGitCommand(`git branch -D "${branch}"`);
|
||||
if (forceDeleteBranch.exitCode === 0) {
|
||||
result.branchDeleted = true;
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2543,7 +2514,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
// If cleanup requested, write archive entry BEFORE removing directory
|
||||
if (cleanup) {
|
||||
// Clean up the task's branch before removing from DB
|
||||
const cleanedBranches = this.cleanupBranchForTask(task);
|
||||
const cleanedBranches = await this.cleanupBranchForTask(task);
|
||||
if (cleanedBranches.length > 0) {
|
||||
task.log.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@@ -3646,9 +3646,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
router.post("/git/remotes", async (req, res) => {
|
||||
try {
|
||||
const rootDir = store.getRootDir();
|
||||
if (!isGitRepo(rootDir)) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
const { name, url } = req.body;
|
||||
if (!name || typeof name !== "string") {
|
||||
throw badRequest("name is required");
|
||||
@@ -3656,6 +3653,15 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
if (!url || typeof url !== "string") {
|
||||
throw badRequest("url is required");
|
||||
}
|
||||
if (!isValidBranchName(name)) {
|
||||
throw badRequest("Invalid remote name");
|
||||
}
|
||||
if (!isValidGitUrl(url)) {
|
||||
throw badRequest("Invalid git URL format");
|
||||
}
|
||||
if (!isGitRepo(rootDir)) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
await addGitRemote(name, url, rootDir);
|
||||
res.status(201).json({ name, added: true });
|
||||
} catch (err: any) {
|
||||
@@ -3993,7 +3999,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
// Get tasks to correlate with worktrees
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
const worktrees = getGitWorktrees(tasks, rootDir);
|
||||
res.json(worktrees);
|
||||
} catch (err: any) {
|
||||
@@ -4530,7 +4536,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
|
||||
// Check if already imported
|
||||
const existingTasks = await scopedStore.listTasks();
|
||||
const existingTasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
||||
const sourceUrl = issue.html_url;
|
||||
for (const existingTask of existingTasks) {
|
||||
if (existingTask.description.includes(sourceUrl)) {
|
||||
@@ -4640,7 +4646,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const scopedStore = await getScopedStore(req);
|
||||
|
||||
// Get existing tasks to check for duplicates
|
||||
const existingTasks = await scopedStore.listTasks();
|
||||
const existingTasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
||||
|
||||
// Process issues sequentially with throttling
|
||||
const results: Array<{
|
||||
@@ -4856,7 +4862,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
|
||||
// Check if already imported
|
||||
const existingTasks = await scopedStore.listTasks();
|
||||
const existingTasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
||||
const sourceUrl = pr.html_url;
|
||||
for (const existingTask of existingTasks) {
|
||||
if (existingTask.description.includes(sourceUrl)) {
|
||||
@@ -5089,7 +5095,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
|
||||
// Find all matching tasks by badge URL
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
const matchingTasks: Array<{ id: string; resourceType: "pr" | "issue"; current: unknown }> = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
@@ -5973,7 +5979,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.get("/workspaces", async (_req, res) => {
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
res.json({
|
||||
project: store.getRootDir(),
|
||||
tasks: tasks
|
||||
@@ -10115,7 +10121,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
const tasks = await scopedStore.listTasks();
|
||||
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
||||
res.json(tasks.filter((task) => task.assignedAgentId === req.params.id));
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -12169,7 +12175,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const projectPath = await realpath(project.path);
|
||||
|
||||
if (storePath === projectPath) {
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ slim: true });
|
||||
const activeCols = new Set(["triage", "todo", "in-progress", "in-review"]);
|
||||
const activeTaskCount = tasks.filter((t) => activeCols.has(t.column)).length;
|
||||
const inFlightAgentCount = tasks.filter((t) => t.column === "in-progress").length;
|
||||
|
||||
@@ -32,6 +32,15 @@ vi.mock("node:fs", () => ({
|
||||
const mockExecFileSync = vi.fn();
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFileSync: (...args: any[]) => mockExecFileSync(...args),
|
||||
execFile: (cmd: string, args: string[], options: any, callback: any) => {
|
||||
const cb = typeof options === "function" ? options : callback;
|
||||
try {
|
||||
const stdout = mockExecFileSync(cmd, args, options);
|
||||
cb(null, stdout, "");
|
||||
} catch (error) {
|
||||
cb(error, "", "");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock node-pty for CLI fallback — default: not available (simulates test env)
|
||||
|
||||
@@ -3,6 +3,21 @@ import * as path from "node:path";
|
||||
import * as https from "node:https";
|
||||
import * as child_process from "node:child_process";
|
||||
|
||||
function execFileAsync(
|
||||
file: string,
|
||||
args: string[],
|
||||
options: child_process.ExecFileOptionsWithStringEncoding,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
child_process.execFile(file, args, options, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: String(stdout), stderr: String(stderr) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pace information for weekly usage windows
|
||||
@@ -234,14 +249,14 @@ function readPiAuthKey(provider: string): string | null {
|
||||
* Read Claude credentials from macOS keychain.
|
||||
* Returns the parsed credentials object or null if not found/error.
|
||||
*/
|
||||
function readClaudeKeychainCredentials(): any | null {
|
||||
async function readClaudeKeychainCredentials(): Promise<any | null> {
|
||||
try {
|
||||
const result = child_process.execFileSync(
|
||||
const { stdout } = await execFileAsync(
|
||||
"security",
|
||||
["find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
||||
{ encoding: "utf-8", timeout: 5000 }
|
||||
);
|
||||
return JSON.parse(result.trim());
|
||||
return JSON.parse(stdout.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -764,7 +779,7 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
|
||||
// Fallback to macOS keychain if file credentials not found
|
||||
if (!creds) {
|
||||
creds = readClaudeKeychainCredentials();
|
||||
creds = await readClaudeKeychainCredentials();
|
||||
}
|
||||
|
||||
const oauthCreds = creds?.claudeAiOauth || creds;
|
||||
|
||||
@@ -172,7 +172,7 @@ export class AgentReflectionService {
|
||||
const effectiveLimit = Math.max(1, limit);
|
||||
|
||||
const [tasks, recentRuns, agent] = await Promise.all([
|
||||
this.taskStore.listTasks(),
|
||||
this.taskStore.listTasks({ slim: true, includeArchived: false }),
|
||||
this.agentStore.getRecentRuns(agentId, effectiveLimit * 4),
|
||||
this.agentStore.getAgent(agentId),
|
||||
]);
|
||||
|
||||
@@ -719,7 +719,7 @@ export class TaskExecutor {
|
||||
* directly to in-review without spawning a new agent session.
|
||||
*/
|
||||
async resumeOrphaned(): Promise<void> {
|
||||
const tasks = await this.store.listTasks();
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "in-progress" });
|
||||
const inProgress = tasks.filter(
|
||||
(t) => t.column === "in-progress" && !this.executing.has(t.id) && !t.paused,
|
||||
);
|
||||
@@ -888,7 +888,7 @@ export class TaskExecutor {
|
||||
|
||||
try {
|
||||
// Check dependencies
|
||||
const allTasks = await this.store.listTasks();
|
||||
const allTasks = await this.store.listTasks({ slim: true, includeArchived: false });
|
||||
const unmetDeps = task.dependencies.filter((depId) => {
|
||||
const dep = allTasks.find((t) => t.id === depId);
|
||||
return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived";
|
||||
|
||||
@@ -188,10 +188,11 @@ async function syncDependenciesForMerge(
|
||||
mergerLog.log(`${taskId}: syncing dependencies before merge build verification`);
|
||||
await store.logEntry(taskId, `Syncing dependencies before merge build verification: ${installCommand}`);
|
||||
try {
|
||||
execSync(installCommand, {
|
||||
await execAsync(installCommand, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 300_000,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const details = error?.stderr || error?.stdout || error?.message || String(error);
|
||||
@@ -859,7 +860,7 @@ export async function findWorktreeUser(
|
||||
worktreePath: string,
|
||||
excludeTaskId: string,
|
||||
): Promise<string | null> {
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
for (const t of tasks) {
|
||||
if (t.id === excludeTaskId) continue;
|
||||
if (t.worktree === worktreePath && t.column !== "done") {
|
||||
|
||||
@@ -126,7 +126,7 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
* This handles the case where the engine was shut down mid-validation
|
||||
* or mid-fix, ensuring those features continue their loop progression.
|
||||
*/
|
||||
async recoverActiveMissions(): Promise<void> {
|
||||
async recoverActiveMissions(): Promise<{ recoveredCount: number }> {
|
||||
loopLog.log("Starting active mission recovery...");
|
||||
|
||||
try {
|
||||
@@ -188,8 +188,10 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
}
|
||||
|
||||
loopLog.log(`Active mission recovery complete: recovered ${recoveredCount} features`);
|
||||
return { recoveredCount };
|
||||
} catch (err) {
|
||||
loopLog.error("Error during active mission recovery:", err);
|
||||
return { recoveredCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -452,7 +452,7 @@ export class Scheduler {
|
||||
this.scheduling = true;
|
||||
|
||||
try {
|
||||
const tasks = await this.store.listTasks();
|
||||
const tasks = await this.store.listTasks({ slim: true, includeArchived: false });
|
||||
const settings = await this.store.getSettings();
|
||||
const maxConcurrent = settings.maxConcurrent ?? this.options.maxConcurrent ?? 2;
|
||||
const maxWorktrees = settings.maxWorktrees ?? this.options.maxWorktrees ?? 4;
|
||||
|
||||
@@ -422,7 +422,7 @@ export class SelfHealingManager {
|
||||
if (!recoverFn) return 0;
|
||||
|
||||
try {
|
||||
const tasks = await this.store.listTasks();
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "in-progress" });
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
|
||||
const stuckCompleted = tasks.filter((t) =>
|
||||
@@ -465,7 +465,7 @@ export class SelfHealingManager {
|
||||
*/
|
||||
async recoverMergeableReviewTasks(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks();
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "in-review" });
|
||||
|
||||
const mergeable = tasks.filter((t) =>
|
||||
t.column === "in-review" &&
|
||||
@@ -516,7 +516,7 @@ export class SelfHealingManager {
|
||||
*/
|
||||
async recoverMergedReviewTasks(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks();
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "in-review" });
|
||||
|
||||
const mergedButNotDone = tasks.filter((t) =>
|
||||
t.column === "in-review" &&
|
||||
@@ -569,7 +569,7 @@ export class SelfHealingManager {
|
||||
*/
|
||||
async recoverMisclassifiedFailures(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks();
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "in-review" });
|
||||
|
||||
const misclassified = tasks.filter((t) =>
|
||||
t.column === "in-review" &&
|
||||
@@ -618,7 +618,7 @@ export class SelfHealingManager {
|
||||
*/
|
||||
async recoverOrphanedExecutions(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks();
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "in-progress" });
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -687,7 +687,7 @@ export class SelfHealingManager {
|
||||
if (!recoverFn) return 0;
|
||||
|
||||
try {
|
||||
const tasks = await this.store.listTasks();
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "triage" });
|
||||
const specifyingIds = this.options.getSpecifyingTaskIds?.() ?? new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
@@ -464,7 +464,7 @@ export class TriageProcessor {
|
||||
}
|
||||
this.wasEnginePaused = false;
|
||||
|
||||
const tasks = await this.store.listTasks();
|
||||
const tasks = await this.store.listTasks({ slim: true, column: "triage" });
|
||||
const now = Date.now();
|
||||
const triageTasks = tasks.filter(
|
||||
(t) => t.column === "triage" && !this.processing.has(t.id) && !t.paused
|
||||
@@ -868,7 +868,7 @@ export class TriageProcessor {
|
||||
"and dependencies for each. Use to check for duplicates before specifying.",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
const active = tasks.filter((t) => t.column !== "done");
|
||||
if (active.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -378,7 +378,7 @@ export async function scanOrphanedBranches(rootDir: string, store: TaskStore): P
|
||||
if (allBranches.length === 0) return [];
|
||||
|
||||
// Build set of branches associated with active (non-archived, non-merger-managed) tasks
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
const activeBranches = new Set<string>();
|
||||
for (const task of tasks) {
|
||||
// Skip tasks in columns where the merger handles branch cleanup
|
||||
|
||||
Reference in New Issue
Block a user