fix blocking hot path operations

This commit is contained in:
gsxdsm
2026-04-11 21:23:36 -07:00
parent d423dfcff6
commit 003ef625ab
16 changed files with 203 additions and 145 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Avoid blocking task sweeps and configured command execution in hot engine, dashboard, and CLI paths.

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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(),

View File

@@ -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;

View File

@@ -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)

View File

@@ -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;

View File

@@ -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),
]);

View File

@@ -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";

View File

@@ -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") {

View File

@@ -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 };
}
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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