feat(FN-4217): complete Step 3 — hydrate in-review stall signal

Fusion-Task-Id: FN-4217
Fusion-Task-Lineage: f5434e90-ad0c-41c5-bee9-1244cb69622b
This commit is contained in:
Fusion
2026-05-13 02:08:29 -07:00
committed by gsxdsm
parent ceb7aa4389
commit 426d4fb67d
2 changed files with 79 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "../store.js";
describe("TaskStore inReviewStall hydration", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = await mkdtemp(join(tmpdir(), "store-in-review-stall-"));
globalDir = join(rootDir, ".fusion-global-settings");
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
await store.close();
await rm(rootDir, { recursive: true, force: true });
});
async function seedTask(id: string, overrides: { paused?: boolean; mergeDetails?: Record<string, unknown>; status?: string; mergeRetries?: number }) {
const now = Date.now();
const updatedAt = new Date(now - 6 * 60_000).toISOString();
await store.createTaskWithReservedId(
{ description: id, column: "in-review" },
{ taskId: id, createdAt: updatedAt, updatedAt, applyDefaultWorkflowSteps: false },
);
const db = (store as unknown as { db: { prepare: (sql: string) => { run: (...params: unknown[]) => unknown } } }).db;
db.prepare(`UPDATE tasks
SET status = ?, paused = ?, mergeRetries = ?, mergeDetails = ?, worktree = ?, updatedAt = ?
WHERE id = ?`).run(
overrides.status ?? "merging",
overrides.paused ? 1 : 0,
overrides.mergeRetries ?? 0,
JSON.stringify(overrides.mergeDetails ?? {}),
`/tmp/${id}`,
updatedAt,
id,
);
}
it("hydrates transient stall for FN-4110 shape in slim list", async () => {
await seedTask("FN-4110", {});
const tasks = await store.listTasks({ slim: true });
const task = tasks.find((entry) => entry.id === "FN-4110");
expect(task?.inReviewStall?.code).toBe("transient-merge-status-no-owner");
expect(task?.inReviewStall?.reason).toContain("no active merger");
});
it("omits inReviewStall for paused in-review task", async () => {
await seedTask("FN-4217-PAUSED", { paused: true });
const tasks = await store.listTasks({ slim: true });
const task = tasks.find((entry) => entry.id === "FN-4217-PAUSED");
expect(task?.inReviewStall).toBeUndefined();
});
it("omits inReviewStall when merge is confirmed", async () => {
await seedTask("FN-4217-CONFIRMED", { mergeDetails: { mergeConfirmed: true } });
const tasks = await store.listTasks({ slim: true });
const task = tasks.find((entry) => entry.id === "FN-4217-CONFIRMED");
expect(task?.inReviewStall).toBeUndefined();
});
});

View File

@@ -21,6 +21,7 @@ import { EvalStore } from "./eval-store.js";
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
import { CentralCore } from "./central-core.js";
import { getTaskMergeBlocker, resolveTaskMergeTarget } from "./task-merge.js";
import { getInReviewStallReason } from "./in-review-stall.js";
import { ensureMemoryFileWithBackend } from "./project-memory.js";
import { runCommandAsync } from "./run-command.js";
import { createLogger } from "./logger.js";
@@ -3073,8 +3074,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const sql = `SELECT ${selectClause} FROM tasks${whereClause} ORDER BY createdAt ASC`;
const rows = this.db.prepare(sql).all(...params);
const now = Date.now();
const activeTasks = await Promise.all((rows as unknown as TaskRow[]).map(async (row) => {
const task = this.rowToTask(row);
task.inReviewStall = getInReviewStallReason(task, { now });
// Slim path: aggregate the timed-execution total server-side, then
// strip the heavy log payload from the wire response. Without this
@@ -3156,8 +3159,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
).all(since, resolvedLimit + 1) as TaskRow[]);
const hasMore = rows.length > resolvedLimit;
const now = Date.now();
const tasks = rows.slice(0, resolvedLimit).map((row) => {
const task = this.rowToTask(row);
task.inReviewStall = getInReviewStallReason(task, { now });
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
task.log = [];
task.githubTracking = undefined;
@@ -3264,8 +3269,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
`).all(...params) as unknown as TaskRow[];
}
const now = Date.now();
const activeMatches = await Promise.all(rows.map(async (row) => {
const task = this.rowToTask(row);
task.inReviewStall = getInReviewStallReason(task, { now });
// Slim path mirrors `listTasks`: aggregate timed execution server-side
// before stripping the heavy log payload from the wire response.