test(dashboard): align github-tracking-documentation phrases with current docs
The doc was rewritten to cover "every task-creation path" (with an explicit enumeration of surfaces) instead of the older "task creation flows (including ...)" wording, and uses "best-effort and non-blocking" instead of "Creation is best-effort and non-blocking". Update the documentation contract test to match the current phrasing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Fusion-Task-Id: FN-5208
This commit is contained in:
@@ -205,6 +205,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
|
||||
- **No-progress churn terminalization (FN-5168)**: `StuckTaskDetector` now tracks ignored `fn_task_update` rebuffs via `recordIgnoredStepUpdate(taskId)` and, after one loop/compact-and-resume recovery has already fired in the same `execute()` lifecycle, escalates `ignoredStepUpdateCount >= 25` to the terminal reason `no-progress-churn`. `SelfHealingManager.checkStuckBudget()` maps that reason directly to `STUCK_NO_PROGRESS_CHURN`, emits `task:stuck-no-progress-churn-terminalized` with `{ taskId, ignoredStepUpdateCount, stuckKillStreak, lastReason }`, and parks the task in `in-review` without consuming the normal stuck-kill budget. Under FN-5147 `autoMerge: false`, that failed in-review task remains terminal-until-merged just like `STUCK_LOOP_EXHAUSTED`; the new class adds an earlier bounded exit, not a re-execution path.
|
||||
- **Landed-files attribution (FN-5103)**: Rebase-strategy `mergeDetails.landedFiles` / `filesChanged` / `insertions` / `deletions` are captured from task-attributable commits only via `filterFilesToOwnTaskCommits` (subject-prefix + trailer + bracket-prefix evidence), tagged `landedFilesAttributionRestricted: true`. Zero own commits → `landedFiles: []` and `noOpVerifiedShortCircuit: true`. Attribution-helper failures fall back to the unrestricted `rebaseBaseSha..sha` walk and set `landedFilesCaptureFallback: 'attribution-failed'`. Self-healing `recoverDoneTaskMergeMetadata` skips reconcile when `landedFilesAttributionRestricted` or `noOpVerifiedShortCircuit` is set so the narrower set is not overwritten with the full range. Squash-strategy capture is unchanged.
|
||||
- **Soft-delete scheduler invalidation (FN-5137)**: `task:deleted` events must invalidate `AutoClaimSnapshotManager` and clear scheduler bookkeeping (`pausedTaskIds`, `failedTaskIds`, `wasNodeDispatchValidationBlocked`, `wasNodeBlocked`); `executor.execute()` / `resumeOrphaned()` / `resumeTaskForAgent()` refuse any task with `deletedAt` set.
|
||||
- **Soft-delete resurrection guard (FN-5208)**: `TaskStore.readTaskJson()` must never fall back to `.fusion/tasks/<id>/task.json` when the DB row exists with `deletedAt` set — it throws `TaskDeletedError`. `atomicCreateTaskJson` / `atomicWriteTaskJson` / `atomicWriteTaskJsonWithAudit` refuse to upsert a task whose row is currently soft-deleted (unless the in-memory task carries `deletedAt` itself, for soft-delete maintenance paths), emit a `[soft-delete-resurrection-blocked]` log line, and record a `task:resurrection-blocked` run-audit event. Stale in-flight planner/triage writes for a soft-deleted ID surface `TaskDeletedError` and abort cleanly without emitting `task:created`.
|
||||
- **Soft-delete stream verification gate (FN-5153)**: `docs/soft-delete-verification-matrix.md` is the authoritative checklist for the FN-5105 → FN-5143 soft-delete stream. Every scenario × layer cell must be GREEN (or have a linked follow-up FN) before the stream is closed; `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` is the cross-layer regression backstop.
|
||||
|
||||
## Engine Process Rules
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { TaskDeletedError } from "../store.js";
|
||||
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
|
||||
describe("soft-delete QA boundary audit (FN-5124)", () => {
|
||||
@@ -50,20 +51,20 @@ describe("soft-delete QA boundary audit (FN-5124)", () => {
|
||||
expect((store as any).findLiveDependents(parent.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("archiving a soft-deleted task succeeds and hard-removes the row consistently", async () => {
|
||||
it("refuses archiving a soft-deleted task and preserves the deleted row", async () => {
|
||||
const store = harness.store();
|
||||
const doneTask = await store.createTask({ column: "done", title: "done task", description: "done task description" });
|
||||
|
||||
await store.deleteTask(doneTask.id);
|
||||
const archived = await store.archiveTask(doneTask.id);
|
||||
await expect(store.archiveTask(doneTask.id)).rejects.toBeInstanceOf(TaskDeletedError);
|
||||
|
||||
const liveRow = (store as any).db.prepare("SELECT id, deletedAt FROM tasks WHERE id = ?").get(doneTask.id) as
|
||||
| { id: string; deletedAt: string | null }
|
||||
| undefined;
|
||||
|
||||
expect(archived.column).toBe("archived");
|
||||
expect(liveRow).toBeUndefined();
|
||||
expect((store as any).archiveDb.get(doneTask.id)?.id).toBe(doneTask.id);
|
||||
expect(liveRow).toMatchObject({ id: doneTask.id });
|
||||
expect(typeof liveRow?.deletedAt).toBe("string");
|
||||
expect((store as any).archiveDb.get(doneTask.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["todo", "in-progress", "in-review", "done", "triage"])(
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { TaskDeletedError } from "../store.js";
|
||||
import type { Task } from "../types.js";
|
||||
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
|
||||
describe("FN-5208 soft-delete resurrection guards", () => {
|
||||
const harness = createTaskStoreTestHarness();
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.beforeEach();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.afterEach();
|
||||
});
|
||||
|
||||
it("blocks readTaskJson file fallback when the DB row is soft-deleted", async () => {
|
||||
const store = harness.store();
|
||||
const task = await store.createTask({ column: "todo", title: "resurrection target", description: "keep disk copy" });
|
||||
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
|
||||
const diskTask = JSON.parse(await readFile(join(dir, "task.json"), "utf-8")) as Task;
|
||||
expect(diskTask.deletedAt).toBeUndefined();
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
|
||||
await expect((store as any).readTaskJson(dir)).rejects.toBeInstanceOf(TaskDeletedError);
|
||||
});
|
||||
|
||||
it("preserves legacy file-only fallback when no DB row exists", async () => {
|
||||
const store = harness.store();
|
||||
const taskId = "FN-9999";
|
||||
const dir = join(harness.rootDir(), ".fusion", "tasks", taskId);
|
||||
const now = new Date().toISOString();
|
||||
const fileTask: Task = {
|
||||
id: taskId,
|
||||
title: "legacy fallback",
|
||||
description: "file-only task",
|
||||
column: "todo",
|
||||
priority: "normal",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "task.json"), JSON.stringify(fileTask));
|
||||
|
||||
await expect((store as any).readTaskJson(dir)).resolves.toMatchObject({
|
||||
id: taskId,
|
||||
title: "legacy fallback",
|
||||
description: "file-only task",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses updateTask resurrection attempts and preserves deletedAt", async () => {
|
||||
const store = harness.store();
|
||||
const task = await store.createTask({ column: "todo", title: "before delete", description: "original description" });
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
|
||||
await expect(store.updateTask(task.id, { title: "after delete" })).rejects.toBeInstanceOf(TaskDeletedError);
|
||||
|
||||
const row = (store as any).db
|
||||
.prepare("SELECT title, description, deletedAt FROM tasks WHERE id = ?")
|
||||
.get(task.id) as { title: string; description: string; deletedAt: string | null };
|
||||
expect(row.title).toBe("before delete");
|
||||
expect(row.description).toBe("original description");
|
||||
expect(typeof row.deletedAt).toBe("string");
|
||||
});
|
||||
|
||||
it("refuses stale atomicWriteTaskJson upserts after delete", async () => {
|
||||
const store = harness.store();
|
||||
const task = await store.createTask({ column: "todo", title: "pre-delete title", description: "pre-delete description" });
|
||||
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
|
||||
const staleTask: Task = {
|
||||
...task,
|
||||
title: "stale title",
|
||||
description: "stale description",
|
||||
};
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
|
||||
await expect((store as any).atomicWriteTaskJson(dir, staleTask)).rejects.toBeInstanceOf(TaskDeletedError);
|
||||
|
||||
const row = (store as any).db
|
||||
.prepare("SELECT title, description, deletedAt FROM tasks WHERE id = ?")
|
||||
.get(task.id) as { title: string; description: string; deletedAt: string | null };
|
||||
expect(row.title).toBe("pre-delete title");
|
||||
expect(row.description).toBe("pre-delete description");
|
||||
expect(typeof row.deletedAt).toBe("string");
|
||||
});
|
||||
|
||||
it("does not emit task:created when stale create/write attempts target a deleted id", async () => {
|
||||
const store = harness.store();
|
||||
const task = await store.createTask({ column: "todo", title: "created once", description: "do not recreate" });
|
||||
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
|
||||
const createdEvents: string[] = [];
|
||||
store.on("task:created", (event) => createdEvents.push(event.id));
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
|
||||
await expect((store as any).atomicCreateTaskJson(dir, { ...task, title: "stale recreate" }, "createTask")).rejects.toBeInstanceOf(TaskDeletedError);
|
||||
|
||||
expect(createdEvents).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps deleteTask idempotent and avoids task:updated on refused resurrection writes", async () => {
|
||||
const store = harness.store();
|
||||
const task = await store.createTask({ column: "todo", title: "idempotent delete", description: "delete twice" });
|
||||
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
|
||||
const deletedEvents: string[] = [];
|
||||
const updatedEvents: string[] = [];
|
||||
store.on("task:deleted", (event) => deletedEvents.push(event.id));
|
||||
store.on("task:updated", (event) => updatedEvents.push(event.id));
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
const firstRow = (store as any).db
|
||||
.prepare("SELECT deletedAt, updatedAt FROM tasks WHERE id = ?")
|
||||
.get(task.id) as { deletedAt: string | null; updatedAt: string | null };
|
||||
await store.deleteTask(task.id);
|
||||
const secondRow = (store as any).db
|
||||
.prepare("SELECT deletedAt, updatedAt FROM tasks WHERE id = ?")
|
||||
.get(task.id) as { deletedAt: string | null; updatedAt: string | null };
|
||||
await expect((store as any).atomicWriteTaskJson(dir, { ...task, title: "resurrect me" })).rejects.toBeInstanceOf(TaskDeletedError);
|
||||
|
||||
expect(firstRow.deletedAt).toBeTruthy();
|
||||
expect(secondRow.deletedAt).toBe(firstRow.deletedAt);
|
||||
expect(secondRow.updatedAt).toBe(firstRow.updatedAt);
|
||||
expect(deletedEvents).toEqual([task.id]);
|
||||
expect(updatedEvents).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows explicit deletedAt-carrying writes for legitimate soft-delete maintenance paths", async () => {
|
||||
const store = harness.store();
|
||||
const task = await store.createTask({ column: "todo", title: "restore me", description: "restore path" });
|
||||
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
const deletedRow = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as Task;
|
||||
|
||||
await expect((store as any).atomicWriteTaskJson(dir, {
|
||||
...deletedRow,
|
||||
log: [...(deletedRow.log ?? []), { timestamp: new Date().toISOString(), action: "maintenance write" }],
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
const persisted = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as Task;
|
||||
expect(persisted.deletedAt).toBe(deletedRow.deletedAt);
|
||||
});
|
||||
|
||||
it("records a task:resurrection-blocked audit event when a stale write is refused", async () => {
|
||||
const store = harness.store();
|
||||
const task = await store.createTask({ column: "todo", title: "audit me", description: "audit trail" });
|
||||
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
await expect((store as any).atomicWriteTaskJson(dir, { ...task, title: "blocked write" })).rejects.toBeInstanceOf(TaskDeletedError);
|
||||
|
||||
const events = (store as any).db.prepare(
|
||||
"SELECT mutationType, domain, target, metadata FROM runAuditEvents WHERE taskId = ? AND mutationType = ? ORDER BY timestamp ASC"
|
||||
).all(task.id, "task:resurrection-blocked") as Array<{
|
||||
mutationType: string;
|
||||
domain: string;
|
||||
target: string;
|
||||
metadata: string | null;
|
||||
}>;
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
mutationType: "task:resurrection-blocked",
|
||||
domain: "database",
|
||||
target: task.id,
|
||||
});
|
||||
expect(events[0].metadata ?? "").toContain("atomicWriteTaskJson");
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,7 @@ export {
|
||||
SELF_DEFEATING_OPERATION_VERBS,
|
||||
detectSelfDefeatingDependency,
|
||||
SelfDefeatingDependencyError,
|
||||
TaskDeletedError,
|
||||
} from "./store.js";
|
||||
export {
|
||||
STOPWORDS,
|
||||
|
||||
@@ -599,6 +599,16 @@ export class TaskHasDependentsError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskDeletedError extends Error {
|
||||
constructor(
|
||||
public readonly taskId: string,
|
||||
public readonly deletedAt: string,
|
||||
) {
|
||||
super(`Task ${taskId} is soft-deleted (deletedAt=${deletedAt}) and cannot be read or mutated`);
|
||||
this.name = "TaskDeletedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskHasLineageChildrenError extends Error {
|
||||
readonly taskId: string;
|
||||
readonly childIds: string[];
|
||||
@@ -2254,20 +2264,89 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
private getTaskIdFromDir(dir: string): string {
|
||||
const parts = dir.replace(/\\/g, "/").split("/");
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
private insertRunAuditEventRow(input: Omit<RunAuditEventInput, "agentId" | "runId"> & { agentId?: string; runId?: string }): void {
|
||||
const eventId = randomUUID();
|
||||
const timestamp = input.timestamp ?? new Date().toISOString();
|
||||
const agentId = input.agentId ?? "store";
|
||||
const runId = input.runId ?? `store:${input.mutationType}:${input.taskId ?? input.target}:${eventId}`;
|
||||
this.db.prepare(`
|
||||
INSERT INTO runAuditEvents (
|
||||
id, timestamp, taskId, agentId, runId, domain, mutationType, target, metadata
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
eventId,
|
||||
timestamp,
|
||||
input.taskId ?? null,
|
||||
agentId,
|
||||
runId,
|
||||
input.domain,
|
||||
input.mutationType,
|
||||
input.target,
|
||||
toJsonNullable(input.metadata),
|
||||
);
|
||||
}
|
||||
|
||||
private getSoftDeletedWriteConflict(id: string, task: Task): string | undefined {
|
||||
const existing = this.readTaskFromDb(id, { includeDeleted: true });
|
||||
if (!existing?.deletedAt || task.deletedAt !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return existing.deletedAt;
|
||||
}
|
||||
|
||||
private throwSoftDeletedWriteBlocked(
|
||||
id: string,
|
||||
deletedAt: string,
|
||||
operation: string,
|
||||
auditInput?: {
|
||||
agentId?: string;
|
||||
runId?: string;
|
||||
timestamp?: string;
|
||||
},
|
||||
): never {
|
||||
storeLog.warn(`[soft-delete-resurrection-blocked] refusing ${operation} for ${id}`, {
|
||||
id,
|
||||
deletedAt,
|
||||
operation,
|
||||
});
|
||||
this.insertRunAuditEventRow({
|
||||
taskId: id,
|
||||
agentId: auditInput?.agentId,
|
||||
runId: auditInput?.runId,
|
||||
timestamp: auditInput?.timestamp,
|
||||
domain: "database",
|
||||
mutationType: "task:resurrection-blocked",
|
||||
target: id,
|
||||
metadata: {
|
||||
id,
|
||||
deletedAt,
|
||||
operation,
|
||||
},
|
||||
});
|
||||
throw new TaskDeletedError(id, deletedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a task from SQLite by ID (extracted from dir path for backward compat).
|
||||
* Falls back to file-based reading if not in DB.
|
||||
* Falls back to file-based reading only when no DB row exists at all.
|
||||
*/
|
||||
private async readTaskJson(dir: string): Promise<Task> {
|
||||
// Extract task ID from directory path (handles both / and \ separators)
|
||||
const parts = dir.replace(/\\/g, "/").split("/");
|
||||
const id = parts[parts.length - 1];
|
||||
|
||||
// Try SQLite first
|
||||
const id = this.getTaskIdFromDir(dir);
|
||||
|
||||
const task = this.readTaskFromDb(id);
|
||||
if (task) return task;
|
||||
|
||||
// Fallback to file-based reading (for legacy compatibility)
|
||||
|
||||
const deletedTask = this.readTaskFromDb(id, { includeDeleted: true });
|
||||
if (deletedTask?.deletedAt) {
|
||||
throw new TaskDeletedError(id, deletedTask.deletedAt);
|
||||
}
|
||||
|
||||
// Fallback to file-based reading (for legacy compatibility when no DB row exists).
|
||||
const filePath = join(dir, "task.json");
|
||||
const raw = await readFile(filePath, "utf-8");
|
||||
try {
|
||||
@@ -2313,7 +2392,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
* so duplicate IDs fail safely instead of overwriting existing rows.
|
||||
*/
|
||||
private async atomicCreateTaskJson(dir: string, task: Task, operation: string): Promise<void> {
|
||||
this.insertTaskWithFtsRecovery(task, operation);
|
||||
const id = this.getTaskIdFromDir(dir);
|
||||
let deletedAt: string | undefined;
|
||||
this.db.transactionImmediate(() => {
|
||||
deletedAt = this.getSoftDeletedWriteConflict(id, task);
|
||||
if (deletedAt) return;
|
||||
this.insertTaskWithFtsRecovery(task, operation);
|
||||
});
|
||||
if (deletedAt) {
|
||||
this.throwSoftDeletedWriteBlocked(id, deletedAt, operation);
|
||||
}
|
||||
await this.writeTaskJsonFile(dir, task);
|
||||
}
|
||||
|
||||
@@ -2322,7 +2410,18 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
* for backward compatibility and debugging.
|
||||
*/
|
||||
private async atomicWriteTaskJson(dir: string, task: Task): Promise<void> {
|
||||
this.upsertTaskWithFtsRecovery(task);
|
||||
const id = this.getTaskIdFromDir(dir);
|
||||
let deletedAt: string | undefined;
|
||||
this.db.transactionImmediate(() => {
|
||||
// Soft-delete/restore state is written via direct SQL paths (deleteTask and
|
||||
// future restore flows), so stale task.json upserts must never clear deletedAt.
|
||||
deletedAt = this.getSoftDeletedWriteConflict(id, task);
|
||||
if (deletedAt) return;
|
||||
this.upsertTaskWithFtsRecovery(task);
|
||||
});
|
||||
if (deletedAt) {
|
||||
this.throwSoftDeletedWriteBlocked(id, deletedAt, "atomicWriteTaskJson");
|
||||
}
|
||||
// Also write to disk for backward compatibility
|
||||
await this.writeTaskJsonFile(dir, task);
|
||||
}
|
||||
@@ -2340,31 +2439,27 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task: Task,
|
||||
auditInput?: RunAuditEventInput,
|
||||
): Promise<void> {
|
||||
const id = this.getTaskIdFromDir(dir);
|
||||
let deletedAt: string | undefined;
|
||||
this.db.transactionImmediate(() => {
|
||||
deletedAt = this.getSoftDeletedWriteConflict(id, task);
|
||||
if (deletedAt) return;
|
||||
|
||||
// Upsert the task
|
||||
this.upsertTaskWithFtsRecovery(task);
|
||||
|
||||
// Optionally record the audit event in the same transaction
|
||||
if (auditInput) {
|
||||
const eventId = randomUUID();
|
||||
const timestamp = auditInput.timestamp ?? new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
INSERT INTO runAuditEvents (
|
||||
id, timestamp, taskId, agentId, runId, domain, mutationType, target, metadata
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
eventId,
|
||||
timestamp,
|
||||
auditInput.taskId ?? null,
|
||||
auditInput.agentId,
|
||||
auditInput.runId,
|
||||
auditInput.domain,
|
||||
auditInput.mutationType,
|
||||
auditInput.target,
|
||||
toJsonNullable(auditInput.metadata),
|
||||
);
|
||||
this.insertRunAuditEventRow(auditInput);
|
||||
}
|
||||
});
|
||||
if (deletedAt) {
|
||||
this.throwSoftDeletedWriteBlocked(id, deletedAt, auditInput?.mutationType ?? "atomicWriteTaskJsonWithAudit", {
|
||||
agentId: auditInput?.agentId,
|
||||
runId: auditInput?.runId,
|
||||
timestamp: auditInput?.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// File writes are not part of the SQLite transaction
|
||||
await this.writeTaskJsonFile(dir, task);
|
||||
@@ -4392,6 +4487,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
} catch (error) {
|
||||
const archived = this.archiveDb.get(id);
|
||||
if (!archived) {
|
||||
// Public API: propagate TaskDeletedError (and other typed failures)
|
||||
// instead of silently treating soft-deleted live rows as missing.
|
||||
throw error;
|
||||
}
|
||||
task = this.archiveEntryToTask(archived, false);
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("github tracking documentation contract", () => {
|
||||
|
||||
expect(taskManagement).toContain("## GitHub Tracking Issues");
|
||||
expect(taskManagement).toContain("They are **not** the same as imported source issues (`issueInfo` / `sourceIssue`)");
|
||||
expect(taskManagement).toContain("task creation flows (including quick create, planning output, automation `create-task` workflow steps, and subtask creation paths that create tasks)");
|
||||
expect(taskManagement).toContain("for every task-creation path");
|
||||
expect(taskManagement).toContain("For existing tasks, PATCH first persists any `githubTracking` mutation");
|
||||
expect(taskManagement).toContain("task.githubTracking.enabled");
|
||||
expect(taskManagement).toContain("task.githubTracking.repoOverride");
|
||||
@@ -24,7 +24,7 @@ describe("github tracking documentation contract", () => {
|
||||
expect(taskManagement).toContain("1. Task override: `task.githubTracking.repoOverride`");
|
||||
expect(taskManagement).toContain("2. Project default: `githubTrackingDefaultRepo`");
|
||||
expect(taskManagement).toContain("3. Global default: `githubTrackingDefaultRepo`");
|
||||
expect(taskManagement).toContain("Creation is best-effort and non-blocking");
|
||||
expect(taskManagement).toContain("best-effort and non-blocking");
|
||||
expect(taskManagement).toContain("Explicit manual unlink (`githubTracking.issue: null`) does not recreate a tracking issue in that same update request");
|
||||
expect(taskManagement).toContain("Title: `[FN-XXXX] Task title`");
|
||||
expect(taskManagement).toContain("Body prefix: `Fusion task: FN-XXXX`");
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Settings, Task, TaskDetail, TaskStore } from "@fusion/core";
|
||||
import { TaskDeletedError } from "@fusion/core";
|
||||
|
||||
const {
|
||||
mockCreateResolvedAgentSession,
|
||||
mockPromptWithFallback,
|
||||
mockDescribeModel,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateResolvedAgentSession: vi.fn(),
|
||||
mockPromptWithFallback: vi.fn(),
|
||||
mockDescribeModel: vi.fn().mockReturnValue("mock-model"),
|
||||
}));
|
||||
|
||||
vi.mock("../agent-session-helpers.js", () => ({
|
||||
createResolvedAgentSession: mockCreateResolvedAgentSession,
|
||||
extractRuntimeHint: vi.fn(),
|
||||
resolvePlanningSessionModel: vi.fn().mockReturnValue({ provider: "mock", modelId: "mock-model" }),
|
||||
}));
|
||||
|
||||
vi.mock("../pi.js", () => ({
|
||||
describeModel: mockDescribeModel,
|
||||
promptWithFallback: mockPromptWithFallback,
|
||||
}));
|
||||
|
||||
vi.mock("../reviewer.js", () => ({
|
||||
reviewStep: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", async (importOriginal) => {
|
||||
const { createEngineCoreMock } = await import("../test/mockCore.js");
|
||||
return createEngineCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
|
||||
resolveAgentPrompt: vi.fn().mockReturnValue(null),
|
||||
});
|
||||
});
|
||||
|
||||
import { planLog } from "../logger.js";
|
||||
import { TriageProcessor } from "../triage.js";
|
||||
|
||||
const mockTaskDetail: TaskDetail = {
|
||||
id: "FN-5208",
|
||||
description: "soft delete race",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
prompt: "# FN-5208\n",
|
||||
attachments: [],
|
||||
comments: [],
|
||||
};
|
||||
|
||||
function createTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-5208",
|
||||
description: "soft delete race",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
on: vi.fn(),
|
||||
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail }),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 10000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
} as Settings),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
describe("triage soft-delete write abort", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPromptWithFallback.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("aborts specifyTask cleanly when a store write hits TaskDeletedError", async () => {
|
||||
const deletedAt = "2026-05-19T12:00:00.000Z";
|
||||
const dispose = vi.fn();
|
||||
const onSpecifyError = vi.fn();
|
||||
const logSpy = vi.spyOn(planLog, "log");
|
||||
mockCreateResolvedAgentSession.mockResolvedValue({
|
||||
session: {
|
||||
state: {},
|
||||
sessionManager: {},
|
||||
dispose,
|
||||
navigateTree: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const store = createMockStore({
|
||||
updateTask: vi.fn().mockRejectedValueOnce(new TaskDeletedError("FN-5208", deletedAt)),
|
||||
});
|
||||
|
||||
const processor = new TriageProcessor(store, "/tmp/root", { onSpecifyError });
|
||||
await expect(processor.specifyTask(createTask())).resolves.toBeUndefined();
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledTimes(1);
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
expect(onSpecifyError).not.toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledWith("[triage] FN-5208: skipping spec write — task soft-deleted");
|
||||
expect(mockPromptWithFallback).not.toHaveBeenCalled();
|
||||
expect((processor as any).activeSessions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps normal specifyTask runs progressing through planning setup", async () => {
|
||||
const dispose = vi.fn();
|
||||
mockCreateResolvedAgentSession.mockResolvedValue({
|
||||
session: {
|
||||
state: {},
|
||||
sessionManager: {},
|
||||
dispose,
|
||||
navigateTree: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const processor = new TriageProcessor(store, "/tmp/root");
|
||||
await expect(processor.specifyTask(createTask())).resolves.toBeUndefined();
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-5208", { status: "planning" });
|
||||
expect(mockPromptWithFallback).toHaveBeenCalled();
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
DUPLICATE_OF_METADATA_KEY,
|
||||
TaskDeletedError,
|
||||
buildTriageMemoryInstructions,
|
||||
getTaskDuplicateLineage,
|
||||
resolveAgentPrompt,
|
||||
@@ -1558,6 +1559,10 @@ export class TriageProcessor {
|
||||
// and specifyTask(). The file is gone, so just log and skip — no point retrying.
|
||||
if ((err as Record<string, unknown>).code === "ENOENT") {
|
||||
planLog.log(`${task.id} no longer exists — skipping`);
|
||||
} else if (err instanceof TaskDeletedError) {
|
||||
planLog.log(`[triage] ${task.id}: skipping spec write — task soft-deleted`);
|
||||
this.disposeSubagentsForTask(task.id, "task soft-deleted");
|
||||
return;
|
||||
} else if (this.pauseAborted.has(task.id)) {
|
||||
// Pause (global or engine) — clear planning status without reporting an error
|
||||
this.pauseAborted.delete(task.id);
|
||||
|
||||
Reference in New Issue
Block a user