feat(FN-5064): merge fusion/fn-5064
This commit is contained in:
@@ -182,6 +182,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
|
||||
- **Post-finalize verification no-op (FN-4944)**: when auto-merge receives a delayed `VerificationError` after a task is already `done` with `mergeDetails.mergeConfirmed === true` (already-on-main fast-path), it must log one `[verification] ... no action` diagnostic and must not bounce the task back to `in-progress` / `merging-fix`. Defense-in-depth now re-checks the done+mergeConfirmed condition immediately before each verification-failure status write site, and emits `task:post-finalize-verification-no-op` database audit events with failure metadata for forensics.
|
||||
- **Worktree pool exclusivity (FN-4954)**: `WorktreePool.acquire(taskId)` / `release(path, taskId?)` track a `leased` map so every pooled path is either idle or leased, never both. Cross-task double-lease detection throws `PoolDoubleLeaseError` and emits `worktree:pool-double-lease-detected`; merger Step 8 now detaches HEAD and clears `task.worktree` / `task.branch` before releasing paths back to the pool.
|
||||
- **Raw worktree deletion must be paired with prune (FN-5058)**: any direct filesystem deletion of a worktree directory (`rm -rf` / `rmSync`) must be followed by best-effort `git worktree prune` via `pruneWorktreeAdminEntries` so `.git/worktrees/*` admin entries are not stranded in a missing-but-registered state (FN-5056 class).
|
||||
- **Meta-task auto-archive safety guards (FN-5064)**: `auto-archive-meta-resolved`/`auto-archive-meta-stalled` must skip archival (with `task:auto-archive-meta-*-skipped` audits) whenever guard checks detect substantive work signals such as unique branch commits, recent executor activity, pending `taskDoneRetryCount`, merge-in-progress state, or active worktree session.
|
||||
- **Scheduler fanout tiebreaker (FN-4969)**: within the same priority class, scheduler dispatch prefers runnable `todo` tasks with the highest active dependency-dependent fanout; `urgent` always outranks lower priorities regardless of fanout, and `overlapBlockedBy`/file-scope overlap blockers are excluded from unblock weight.
|
||||
|
||||
## Engine Process Rules
|
||||
|
||||
@@ -327,6 +327,7 @@ Default notes:
|
||||
| `staleInReviewCriticalMs` | `number` | `259200000` | Task-age staleness critical threshold in ms for `in-review` tasks (72 hours). `0` or `undefined` disables critical-level surfacing. |
|
||||
| `pausedScopeDecayMs` | `number` | `1800000` | Minimum pause age in ms before self-healing can rebound a paused `in-progress` scope-holder back to `todo` when it is actively blocking at least one follower via `blockedBy`/`overlapBlockedBy`. Uses `columnMovedAt ?? updatedAt` as the pause-age proxy. Set `0` to disable decay-based rebound. |
|
||||
| `metaTaskStallAutoCloseMs` | `number` | `7200000` | Maximum age in ms for blocked meta-task chains before self-healing auto-archives them as superseded. Set `0` to disable age-based stalled meta closure. |
|
||||
| `metaTaskActiveExecutionGraceMs` | `number` | `1800000` | Grace period in ms used by meta-task auto-archive guards to treat recently active/in-progress executor work as in-flight and skip destructive meta auto-archive. Set `0` to disable the activity guard. |
|
||||
| `boardStallSweepWindowMs` | `number` | `7200000` | Rolling board-health window in ms used by self-healing board-stall detection. Within each window, if blocked depth grows while no task exits `in-progress`, the stall sweep forces a paused-scope rebound and opens a verification tick. |
|
||||
| `boardStallBlockedGrowthThreshold` | `number` | `3` | Minimum blocked-depth growth (count of tasks with `blockedBy`) within the current board-stall window required to trigger the board-stall recovery sweep. |
|
||||
| `staleHighFanoutBlockerAgeThresholdMs` | `number` | `7200000` | Age threshold (ms) before high-fan-out blockers escalate in dashboard task cards/footer. Applies only to blockers currently in `in-progress`/`in-review`; age is computed from `columnMovedAt ?? updatedAt`. |
|
||||
|
||||
@@ -285,6 +285,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
stalePausedReviewThresholdMs: 24 * 60 * 60_000,
|
||||
pausedScopeDecayMs: 30 * 60_000,
|
||||
metaTaskStallAutoCloseMs: 2 * 60 * 60_000,
|
||||
metaTaskActiveExecutionGraceMs: 30 * 60_000,
|
||||
boardStallSweepWindowMs: 2 * 60 * 60_000,
|
||||
boardStallBlockedGrowthThreshold: 3,
|
||||
// Capacity risk warning default: only warn once todo is meaningfully backlogged.
|
||||
|
||||
@@ -2950,6 +2950,10 @@ export interface ProjectSettings {
|
||||
* advancing before self-healing auto-archives it as superseded.
|
||||
* Default: 7200000 (2 hours). Set to 0 to disable. */
|
||||
metaTaskStallAutoCloseMs?: number;
|
||||
/** Grace period in milliseconds used by meta-task auto-archive guards to treat
|
||||
* recent executor activity as in-flight and skip destructive auto-archive.
|
||||
* Default: 1800000 (30 minutes). Set to 0 to disable this guard. */
|
||||
metaTaskActiveExecutionGraceMs?: number;
|
||||
/** Rolling window in milliseconds for board-stall auto-recovery evaluation.
|
||||
* Default: 7200000 (2 hours). */
|
||||
boardStallSweepWindowMs?: number;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { activeSessionRegistry } from "../../active-session-registry.js";
|
||||
import { git, makeReliabilityFixture } from "./_helpers.js";
|
||||
|
||||
describe("reliability interactions: meta archive guard composition", () => {
|
||||
it("FN-5064: meta-archive guards refuse to destroy substantive work across composition with branch, executor retry, and active session", async () => {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5064-COMPOSITION",
|
||||
task: { id: "FN-5064-COMPOSITION", title: "anchor", column: "todo" },
|
||||
settings: {
|
||||
pausedScopeDecayMs: 1,
|
||||
metaTaskStallAutoCloseMs: 2 * 60 * 60_000,
|
||||
metaTaskActiveExecutionGraceMs: 30 * 60_000,
|
||||
boardStallSweepWindowMs: 2 * 60 * 60_000,
|
||||
},
|
||||
});
|
||||
|
||||
const target = await fixture.store.createTask({
|
||||
id: "FN-5064-TARGET-DONE",
|
||||
title: "target done",
|
||||
description: "target",
|
||||
column: "done",
|
||||
steps: [],
|
||||
} as any);
|
||||
|
||||
const mkMeta = async (id: string, title: string) => fixture.store.createTask({
|
||||
id,
|
||||
title,
|
||||
description: `meta guard test for ${target.id}`,
|
||||
sourceParentTaskId: target.id,
|
||||
column: "todo",
|
||||
noCommitsExpected: true,
|
||||
steps: [],
|
||||
} as any);
|
||||
|
||||
const branchMeta = await mkMeta("FN-5064-META-BRANCH", `Recover ${target.id}`);
|
||||
const recentMeta = await mkMeta("FN-5064-META-RECENT", `Recover ${target.id}`);
|
||||
const retryMeta = await mkMeta("FN-5064-META-RETRY", `Recover ${target.id}`);
|
||||
const activeWorktreePath = join(fixture.rootDir, "meta-active-worktree");
|
||||
await mkdir(activeWorktreePath, { recursive: true });
|
||||
const activeMeta = await fixture.store.createTask({
|
||||
id: "FN-5064-META-ACTIVE",
|
||||
title: `Recover ${target.id}`,
|
||||
description: `meta guard test for ${target.id}`,
|
||||
sourceParentTaskId: target.id,
|
||||
column: "todo",
|
||||
noCommitsExpected: true,
|
||||
steps: [],
|
||||
worktree: activeWorktreePath,
|
||||
} as any);
|
||||
await fixture.store.updateTask(activeMeta.id, { worktree: activeWorktreePath } as any);
|
||||
const controlMeta = await mkMeta("FN-5064-META-CONTROL", `Recover ${target.id}`);
|
||||
|
||||
await fixture.store.updateTask(recentMeta.id, {
|
||||
column: "in-progress",
|
||||
executionStartedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
} as any);
|
||||
await fixture.store.updateTask(retryMeta.id, { taskDoneRetryCount: 1 } as any);
|
||||
activeSessionRegistry.registerPath(activeWorktreePath, { taskId: activeMeta.id, kind: "executor", ownerKey: activeMeta.id });
|
||||
|
||||
const branchName = `fusion/${branchMeta.id.toLowerCase()}`;
|
||||
git(fixture.rootDir, `git checkout -b ${branchName}`);
|
||||
git(fixture.rootDir, "git commit --allow-empty -m \"feat: ahead branch meta\"");
|
||||
git(fixture.rootDir, "git checkout main");
|
||||
await fixture.store.updateTask(branchMeta.id, { branch: branchName } as any);
|
||||
|
||||
try {
|
||||
await (fixture.manager as any).runMaintenance();
|
||||
|
||||
const byId = new Map((await fixture.store.listTasks({ includeArchived: true })).map((task) => [task.id, task]));
|
||||
expect(byId.get(branchMeta.id)?.column).not.toBe("archived");
|
||||
expect(byId.get(recentMeta.id)?.column).not.toBe("archived");
|
||||
expect(byId.get(retryMeta.id)?.column).not.toBe("archived");
|
||||
expect(byId.get(activeMeta.id)?.column).not.toBe("archived");
|
||||
expect(byId.get(controlMeta.id)?.column).toBe("archived");
|
||||
|
||||
const events = fixture.store.getRunAuditEvents({ limit: 400 });
|
||||
const skipped = events.filter((event) => event.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
const archived = events.filter((event) => event.mutationType === "task:auto-archived-meta-resolved");
|
||||
expect(skipped).toHaveLength(4);
|
||||
const blockedByByTask = new Map(skipped.map((event) => [(event.metadata as any)?.taskId, (event.metadata as any)?.blockedBy ?? []]));
|
||||
expect(blockedByByTask.get(branchMeta.id)).toEqual(expect.arrayContaining(["branch-has-unique-commits"]));
|
||||
expect(blockedByByTask.get(recentMeta.id)).toEqual(expect.arrayContaining(["recent-executor-activity"]));
|
||||
expect(blockedByByTask.get(retryMeta.id)).toEqual(expect.arrayContaining(["task-done-retry-pending"]));
|
||||
expect(blockedByByTask.get(activeMeta.id)).toEqual(expect.arrayContaining(["active-session"]));
|
||||
expect(archived).toHaveLength(1);
|
||||
expect((archived[0]?.metadata as any)?.taskId).toBe(controlMeta.id);
|
||||
} finally {
|
||||
activeSessionRegistry.unregisterPath(activeWorktreePath);
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { activeSessionRegistry } from "../active-session-registry.js";
|
||||
import { git, makeReliabilityFixture } from "./reliability-interactions/_helpers.js";
|
||||
|
||||
async function createResolvedMetaPair(settingsOverrides: Record<string, unknown> = {}) {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5064-FIXTURE",
|
||||
task: { id: "FN-5064-FIXTURE", title: "anchor", column: "todo" },
|
||||
settings: { metaTaskActiveExecutionGraceMs: 30 * 60_000, ...settingsOverrides },
|
||||
});
|
||||
|
||||
const target = await fixture.store.createTask({
|
||||
id: "FN-5064-TARGET",
|
||||
title: "target",
|
||||
description: "target",
|
||||
column: "done",
|
||||
steps: [],
|
||||
} as any);
|
||||
const meta = await fixture.store.createTask({
|
||||
id: "FN-5064-META",
|
||||
title: "Recover target task",
|
||||
description: `meta wrapper for ${target.id}`,
|
||||
sourceParentTaskId: target.id,
|
||||
column: "todo",
|
||||
noCommitsExpected: true,
|
||||
steps: [],
|
||||
worktree: "/tmp/fn-5064-meta",
|
||||
} as any);
|
||||
|
||||
return { fixture, target, meta };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
activeSessionRegistry.clear();
|
||||
});
|
||||
|
||||
describe("SelfHealingManager meta auto-archive guards", () => {
|
||||
it("skips resolved auto-archive when branch has unique commits", async () => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
const branchName = `fusion/${meta.id.toLowerCase()}`;
|
||||
git(fixture.rootDir, `git checkout -b ${branchName}`);
|
||||
git(fixture.rootDir, "git commit --allow-empty -m \"feat: ahead commit\"");
|
||||
git(fixture.rootDir, "git checkout main");
|
||||
await fixture.store.updateTask(meta.id, { branch: branchName } as any);
|
||||
try {
|
||||
const archived = await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
expect(archived).toBe(0);
|
||||
expect((await fixture.store.getTask(meta.id))?.column).not.toBe("archived");
|
||||
const events = fixture.store.getRunAuditEvents({ limit: 200 }).filter((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0]?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["branch-has-unique-commits"]));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips resolved auto-archive when executor activity is recent", async () => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
await fixture.store.updateTask(meta.id, { column: "in-progress", executionStartedAt: new Date(Date.now() - 5 * 60_000).toISOString() } as any);
|
||||
try {
|
||||
const archived = await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
expect(archived).toBe(0);
|
||||
const event = fixture.store.getRunAuditEvents({ limit: 200 }).find((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect((event?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["recent-executor-activity"]));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips resolved auto-archive when taskDone retry is pending", async () => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 2 } as any);
|
||||
try {
|
||||
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
const event = fixture.store.getRunAuditEvents({ limit: 200 }).find((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect((event?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["task-done-retry-pending"]));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ updates: { mergeDetails: { commitSha: "abc123" } }, label: "merge commitSha exists" },
|
||||
{ updates: { status: "merging" }, label: "status merging" },
|
||||
{ updates: { status: "merging-pr" }, label: "status merging-pr" },
|
||||
])("skips resolved auto-archive when merge is in progress: $label", async ({ updates }) => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
await fixture.store.updateTask(meta.id, updates as any);
|
||||
try {
|
||||
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
const event = fixture.store.getRunAuditEvents({ limit: 200 }).find((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect((event?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["merge-in-progress"]));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips resolved auto-archive when worktree has active session", async () => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
const activePath = join(fixture.rootDir, "active-session-worktree");
|
||||
await mkdir(activePath, { recursive: true });
|
||||
await fixture.store.updateTask(meta.id, { worktree: activePath } as any);
|
||||
activeSessionRegistry.registerPath(activePath, { taskId: meta.id, kind: "executor", ownerKey: meta.id });
|
||||
try {
|
||||
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
const event = fixture.store.getRunAuditEvents({ limit: 200 }).find((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect((event?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["active-session"]));
|
||||
} finally {
|
||||
activeSessionRegistry.unregisterPath(activePath);
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("collects multiple guard reasons", async () => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 1, status: "merging" } as any);
|
||||
try {
|
||||
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
const event = fixture.store.getRunAuditEvents({ limit: 200 }).find((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect((event?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["task-done-retry-pending", "merge-in-progress"]));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps legitimate resolved meta auto-archive behavior", async () => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
try {
|
||||
const archived = await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
expect(archived).toBe(1);
|
||||
expect((await fixture.store.getTask(meta.id))?.column).toBe("archived");
|
||||
const audits = fixture.store.getRunAuditEvents({ limit: 200 });
|
||||
expect(audits.some((event) => event.mutationType === "task:auto-archived-meta-resolved")).toBe(true);
|
||||
expect(audits.some((event) => event.mutationType === "task:auto-archive-meta-resolved-skipped")).toBe(false);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits stalled skipped event when guards block stalled archive", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-05-18T12:00:00.000Z");
|
||||
vi.setSystemTime(now);
|
||||
const { fixture, meta } = await createResolvedMetaPair({ metaTaskStallAutoCloseMs: 60_000 });
|
||||
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 1 } as any);
|
||||
vi.setSystemTime(new Date(now.getTime() + 2 * 60 * 60_000));
|
||||
try {
|
||||
const archived = await fixture.selfHeal.autoArchiveStalledMetaTasks();
|
||||
expect(archived).toBe(0);
|
||||
const event = fixture.store.getRunAuditEvents({ limit: 200 }).find((e) => e.mutationType === "task:auto-archive-meta-stalled-skipped");
|
||||
expect(event).toBeTruthy();
|
||||
expect((event?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["task-done-retry-pending"]));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -231,8 +231,12 @@ export type DatabaseMutationType =
|
||||
| "task:auto-rebound-paused-scope-decay"
|
||||
/** Metadata: { taskId, targetTaskId, targetColumn, chainDepth: number } */
|
||||
| "task:auto-archived-meta-resolved"
|
||||
/** Metadata: { taskId, targetTaskId, targetColumn, chainDepth: number, blockedBy: string[] } */
|
||||
| "task:auto-archive-meta-resolved-skipped"
|
||||
/** Metadata: { taskId, targetTaskId, chainDepth: number, stalledMs: number } */
|
||||
| "task:auto-archived-meta-stalled"
|
||||
/** Metadata: { taskId, targetTaskId, chainDepth: number, stalledMs: number, blockedBy: string[] } */
|
||||
| "task:auto-archive-meta-stalled-skipped"
|
||||
/** Metadata: { holderIds: string[], followerCount: number, windowMs: number, blockedGrowth: number } */
|
||||
| "task:auto-board-stall-broken"
|
||||
/** Metadata: { holderIds: string[], followerCount: number, windowMs: number, ntfyDispatched: boolean } */
|
||||
|
||||
@@ -2685,8 +2685,49 @@ export class SelfHealingManager {
|
||||
return tasks.filter((task) => typeof task.blockedBy === "string" && task.blockedBy.trim().length > 0).length;
|
||||
}
|
||||
|
||||
private async evaluateMetaAutoArchiveGuards(task: Task): Promise<{ block: false } | { block: true; reasons: string[] }> {
|
||||
const reasons: string[] = [];
|
||||
|
||||
try {
|
||||
const ahead = await isBranchAheadOfBase(task, this.options.rootDir, task.baseBranch ?? task.mergeDetails?.mergeTargetBranch ?? "main");
|
||||
if (ahead && ahead.aheadCount > 0) reasons.push("branch-has-unique-commits");
|
||||
} catch (err: unknown) {
|
||||
log.warn(`Meta auto-archive branch probe failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
const settings = await this.store.getSettings();
|
||||
const graceMs = Number(settings.metaTaskActiveExecutionGraceMs ?? 30 * 60_000);
|
||||
if (graceMs > 0) {
|
||||
const now = Date.now();
|
||||
const activityMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt ?? task.updatedAt ?? "");
|
||||
const ageMs = now - activityMs;
|
||||
const columnMovedAtMs = Date.parse(task.columnMovedAt ?? "");
|
||||
const executionStartedAtMs = Date.parse(task.executionStartedAt ?? "");
|
||||
const transitionedRecentlyFromInProgress =
|
||||
task.column !== "in-progress" &&
|
||||
Number.isFinite(columnMovedAtMs) &&
|
||||
Number.isFinite(executionStartedAtMs) &&
|
||||
columnMovedAtMs >= executionStartedAtMs &&
|
||||
now - columnMovedAtMs < graceMs;
|
||||
const activeOrRecentlyInProgress = task.column === "in-progress" || transitionedRecentlyFromInProgress;
|
||||
if (Number.isFinite(ageMs) && ageMs < graceMs && activeOrRecentlyInProgress) {
|
||||
reasons.push("recent-executor-activity");
|
||||
}
|
||||
}
|
||||
|
||||
if ((task.taskDoneRetryCount ?? 0) > 0) reasons.push("task-done-retry-pending");
|
||||
|
||||
if (task.mergeDetails?.commitSha || task.status === "merging" || task.status === "merging-pr" || task.status === "failed") {
|
||||
reasons.push("merge-in-progress");
|
||||
}
|
||||
|
||||
if (task.worktree && activeSessionRegistry.isPathActive(task.worktree)) reasons.push("active-session");
|
||||
|
||||
return reasons.length > 0 ? { block: true, reasons } : { block: false };
|
||||
}
|
||||
|
||||
async autoArchiveResolvedMetaTasks(reboundedTargets?: Set<string>): Promise<number> {
|
||||
const tasks = await this.store.listTasks({ slim: true, includeArchived: true });
|
||||
const tasks = await this.store.listTasks({ slim: false, includeArchived: true });
|
||||
const byId = new Map(tasks.map((task) => [task.id.toUpperCase(), task]));
|
||||
let archived = 0;
|
||||
for (const task of tasks) {
|
||||
@@ -2699,6 +2740,17 @@ export class SelfHealingManager {
|
||||
const resolved = Boolean(target && !this.classifyMetaTask(target).isMeta && (target.column === "done" || target.column === "archived" || target.column === "todo"));
|
||||
const rebounded = Boolean(reboundedTargets?.has(targetTaskId));
|
||||
if (!resolved && !rebounded && chainDepth < 2) continue;
|
||||
const guardResult = await this.evaluateMetaAutoArchiveGuards(task);
|
||||
if (guardResult.block) {
|
||||
const auditor = createRunAuditor(this.store, { runId: generateSyntheticRunId("fn4890-meta", task.id), agentId: "self-healing", taskId: task.id, phase: "auto-archive-meta-resolved-skipped" });
|
||||
await auditor.database({
|
||||
type: "task:auto-archive-meta-resolved-skipped",
|
||||
target: task.id,
|
||||
metadata: { taskId: task.id, targetTaskId, targetColumn: target?.column ?? "unknown", chainDepth, blockedBy: guardResult.reasons },
|
||||
});
|
||||
log.log(`[self-healing] skipped meta-resolved auto-archive for ${task.id}: ${guardResult.reasons.join(",")}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.store.logEntry(task.id, `Auto-archived meta-task (FN-4890): target ${targetTaskId} resolved/superseded.`);
|
||||
await this.archiveMetaTask(task.id);
|
||||
@@ -2716,7 +2768,7 @@ export class SelfHealingManager {
|
||||
const settings = await this.store.getSettings();
|
||||
const thresholdMs = Number(settings.metaTaskStallAutoCloseMs ?? 2 * 60 * 60_000);
|
||||
if (thresholdMs === 0) return 0;
|
||||
const tasks = await this.store.listTasks({ slim: true, includeArchived: false });
|
||||
const tasks = await this.store.listTasks({ slim: false, includeArchived: false });
|
||||
const byId = new Map(tasks.map((task) => [task.id.toUpperCase(), task]));
|
||||
let archived = 0;
|
||||
const now = Date.now();
|
||||
@@ -2732,6 +2784,17 @@ export class SelfHealingManager {
|
||||
const targetMovedAtMs = Date.parse(target?.columnMovedAt ?? target?.updatedAt ?? "");
|
||||
const targetStalled = !Number.isFinite(targetMovedAtMs) || (now - targetMovedAtMs >= thresholdMs);
|
||||
if (chainDepth < 2 && !targetStalled) continue;
|
||||
const guardResult = await this.evaluateMetaAutoArchiveGuards(task);
|
||||
if (guardResult.block) {
|
||||
const auditor = createRunAuditor(this.store, { runId: generateSyntheticRunId("fn4890-meta", task.id), agentId: "self-healing", taskId: task.id, phase: "auto-archive-meta-stalled-skipped" });
|
||||
await auditor.database({
|
||||
type: "task:auto-archive-meta-stalled-skipped",
|
||||
target: task.id,
|
||||
metadata: { taskId: task.id, targetTaskId, chainDepth, stalledMs: Math.max(ageMs, 0), blockedBy: guardResult.reasons },
|
||||
});
|
||||
log.log(`[self-healing] skipped meta-stalled auto-archive for ${task.id}: ${guardResult.reasons.join(",")}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.store.logEntry(task.id, `Auto-archived meta-task (FN-4890): superseded — not spawning further meta; rely on self-heal on target ${targetTaskId}`);
|
||||
await this.archiveMetaTask(task.id);
|
||||
|
||||
Reference in New Issue
Block a user