refactor: delete meta-task auto-archive and automated recovery follow-ups (#2461)

Deletes two pieces of automated "meta" machinery that filed and
garbage-collected cards restating state already on the task that failed.
Net **-1015 lines**.

## Why

**Automated recovery follow-ups.** `createAutomatedFollowup` and its
dedup engine (289 lines of signature matching, 1h recurrence
rate-limiting, 24h supersedes windows) existed to file recovery cards
for verification-cap and merge-conflict give-ups. In both cases the
parent is *already* parked `failed` with a descriptive `error` and a log
entry carrying the failing command, branch, and output — the card was a
second copy of that.

**Meta-task auto-archive.** The sweeps that garbage-collected those
cards were worse than redundant: the regex classifier matched ordinary
feature work, and its positional fallback bound cards to unrelated
tasks, so **live work could be archived**.

They are removed together, because the auto-archive sweeps only existed
to clean up after the follow-up engine.

## What changed

### Deleted
- `packages/engine/src/verification-followup-dedup.ts` in full —
`createAutomatedFollowup`, `decideAutomatedFollowup`,
`AutomatedFollowupKind`, `computeVerificationFailureSignature`,
`extractFailingTestFiles`.
- `findActiveRecoveryFollowUp` — dead code, defined and never called
(`tsc` independently flagged it `6133 declared but its value is never
read`).
- The meta-task auto-archive sweeps `autoArchiveResolvedMetaTasks` /
`autoArchiveStalledMetaTasks` and helpers `classifyMetaTask` /
`resolveMetaTargetTaskId` / `computeMetaChainDepth` / `archiveMetaTask`
/ `evaluateMetaAutoArchiveGuards`, plus settings
`metaTaskStallAutoCloseMs` and `metaTaskActiveExecutionGraceMs`.
- Run-audit types `task:auto-archived-meta-resolved`,
`task:auto-archived-meta-stalled`,
`task:auto-archive-meta-resolved-skipped`,
`task:auto-archive-meta-stalled-skipped`,
`verification:followup-created`, `verification:followup-deduped`.

The two signature helpers were **deleted rather than relocated** — once
the three call sites went they were provably unreachable:
`buildVerificationFailureSignature` had exactly one caller, and it was
the only caller of `extractFailingTestFiles`.

### Call sites 1 and 2 — park kept, card dropped
Verification-cap and merge-conflict give-ups keep their park, audit
event, operator comment, and log entry. Site 1's `error` string was
reworded off `"See follow-up task for investigation."` (no follow-up
will exist) to carry the guidance itself. `autoResolveDisabled` was
**kept** — it still drives the outer park guard and the `reason` string;
only the inner branch that guarded card creation is gone.

### Call site 3 — autostash orphan, replaced not deleted
This one is a genuine data-loss guard, so it keeps a durable trail. A
`live`-classified orphan is a merger stash holding **real uncommitted
work**, and unlike sites 1–2 there is no parked parent — the parent may
already be `done` and merged, so nothing else on the board would ever
mention the stash.

The card is replaced by a `logEntry` **and** an `addTaskComment` on the
parent, preserving every fact the old description carried: the sha,
`record.label` (the handle `git stash` recovery needs),
`record.detectedByTaskId`, and `sourcePhase`. New truthful run-audit
event `task:autostash-orphan-live-detected` replaces the borrowed
`verification:followup-*` name, with ids/outcomes-only metadata per
AGENTS.md.

### Kept unchanged: the two real product features
Eval follow-ups (`eval-followups.ts`) and PR-comment follow-ups
(`pr-comment-handler.ts`) only borrowed the shared engine for its dedup
pass. Both keep their exact behavior, column, priority, `sourceType`,
and log lines, with dedup inlined as a `listTasks` scan on
`suggestionId` / `prNumber` respectively. Both fail open (create) if the
listing throws, matching the old engine.

## Test changes — read this one

Two tests asserted the *deleted* engine's rate-limited `"[verification
recurrence]"` logEntry. Those assertions were removed, **not loosened**:
both tests still assert no duplicate card is created, and the eval test
still asserts the existing id is reported back. No coverage of surviving
behavior was weakened. The three `meta-*` test files were deleted along
with the sweeps they covered.

## Verification

```
$ pnpm test:gate
 Test Files  2 passed (2)     Tests   10 passed (10)    # core
 Test Files  16 passed (16)   Tests  299 passed (299)   # engine-core
 Test Files  1 passed (1)     Tests   70 passed (70)    # ci-shape
GATE_EXIT=0

$ pnpm --filter @fusion/engine --filter @fusion/core exec tsc --noEmit -p tsconfig.json
TSC_EXIT=0   (no output)
```

Plus a file-scoped run over the touched surfaces (`eval-followups`,
`pr-comment-handler`, `merger-autostash-orphan-surface`,
`merger-autostash-cleanup`, `run-audit`, `run-audit-secret-taxonomy`,
`project-engine`, `project-engine-manager`): **213/213 passed**.

A repo-wide grep confirms no surviving references to any deleted symbol,
module, or audit event.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Failed tasks now retain recovery and verification details directly on
the original task instead of generating separate follow-up cards.
* Live autostash issues now preserve stash information in task comments
and activity logs.
* Existing evaluation and pull-request follow-ups continue to be reused
when appropriate.

* **Changes**
  * Removed automatic archival of meta-tasks.
  * Removed obsolete meta-task timing settings.

* **Documentation**
* Updated architecture and settings documentation to reflect these
workflow changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-26 22:38:58 -07:00
committed by GitHub
parent d19c82b33b
commit 0e3d2a2265
20 changed files with 229 additions and 1244 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Stop auto-archiving tasks and auto-filing recovery cards; failures now stay on the task that failed.
category: internal
dev: Deletes the meta-task auto-archive sweeps (`autoArchiveResolvedMetaTasks`/`autoArchiveStalledMetaTasks` and helpers `classifyMetaTask`/`resolveMetaTargetTaskId`/`computeMetaChainDepth`/`archiveMetaTask`/`evaluateMetaAutoArchiveGuards`) plus settings `metaTaskStallAutoCloseMs` and `metaTaskActiveExecutionGraceMs`; the regex classifier matched ordinary feature work and its positional fallback bound cards to unrelated tasks, so live work could be archived. Also deletes `verification-followup-dedup.ts` (`createAutomatedFollowup`/`decideAutomatedFollowup`), the dead `findActiveRecoveryFollowUp`, and the verification-failure and merge-conflict follow-up call sites — those parents already park `failed` with a descriptive `error` or log an auto-merge-gave-up entry. The autostash-orphan path is preserved as a `logEntry` + `addTaskComment` on the parent carrying sha, stash label, `detectedByTaskId`, and `sourcePhase`, with new run-audit event `task:autostash-orphan-live-detected`. Eval and PR-comment follow-ups are unchanged in behavior with dedup inlined on `suggestionId`/`prNumber`. Run-audit types `task:auto-archived-meta-resolved`, `task:auto-archived-meta-stalled`, `task:auto-archive-meta-resolved-skipped`, `task:auto-archive-meta-stalled-skipped`, `verification:followup-created`, and `verification:followup-deduped` are removed.

View File

@@ -2117,16 +2117,18 @@ The GitHub tracking state listener now attaches to every registered project stor
- Existing task-scoped surfacing remains: merger warnings still log to `mergerLog.warn` and `store.logEntry` for the active merge task.
- New global surfacing adds `merger:autostashOrphans` TaskStore events, engine helpers (`listAutostashOrphans`, `getAutostashDiff`, `applyAutostashBySha`, `dropAutostashBySha`), and dashboard API endpoints under `/api/stash-recovery/*`.
- `merger:autostashOrphans` records now include provenance fields (`sourcePhase`, `detectedByTaskId`, `detectedAt`) so operators can attribute leftovers to the merge phase and surfacing task/session.
- `ProjectEngine` consumes the orphan event stream and auto-creates deduplicated `sourceType: "recovery"` follow-up tasks for live leftovers, so repeated detections do not spam the board.
- `ProjectEngine` consumes the orphan event stream and, for a `live` leftover, writes a `store.logEntry` plus a `store.addTaskComment` on the parent task carrying the sha, `record.label` (the stash label needed to recover it), `detectedByTaskId`, and `sourcePhase`, and emits the `task:autostash-orphan-live-detected` run-audit event. It no longer files a follow-up task — see "Automated follow-up dedup (removed)" below. The parent may already be `done` and merged, so this log/comment pair is the only board-visible trace that stranded work exists; keep the stash label in the message.
- Dashboard operators inspect orphan counts, review diffs, apply stashes, and explicitly drop entries with confirmation from **Git Manager → Recovery**; the recovery controls are part of Git Manager rather than a standalone top-level dashboard view.
- Decision: recovery stays user-gated. Auto-apply was rejected because clean-tree checks are racy, stash placement is ambiguous after source task merge, and apply conflicts can produce hard-to-untangle state. `sweepAutostashOrphans` continues to auto-drop only subsumed entries while preserving live developer work.
#### Automated follow-up dedup (FN-5232)
- Engine-side automated follow-up creation now routes through `packages/engine/src/verification-followup-dedup.ts` instead of calling `TaskStore.createTask()` directly from recovery/eval/PR-comment paths.
- Verification-style follow-ups stamp `sourceMetadata.verificationFailureSignature`, a deterministic SHA-256 digest over `{ lane, sorted failing test basenames }` (or `lane|no-files` when no files can be parsed). Open matches reuse the existing task and append at most one `[verification recurrence]` log entry per hour; closed/done/archived matches within 24 hours create a fresh task with `sourceMetadata.supersedesTaskId` pointing at the prior task.
- Non-verification automated follow-ups can supply `extraMatchKeys` (for example eval `suggestionId` or PR `prNumber`) so dedup stays deterministic even when no test-file signature exists.
- This layer composes with FN-4892 same-agent intake dedup in `@fusion/core`: engine dedup prevents repeated automated recovery spam up front, while store-side same-agent intake flags newly-created near-duplicates in place by default; only explicit `autoArchiveDuplicateTasksEnabled: true` archives the new task.
- Run-audit emits `verification:followup-created` and `verification:followup-deduped` database events with hashed signature metadata only; no raw stdout/stderr or secret material is persisted in the audit payload.
#### Automated follow-up dedup (FN-5232 — REMOVED 2026-07-26)
`packages/engine/src/verification-followup-dedup.ts` and its `createAutomatedFollowup`/`decideAutomatedFollowup` engine are DELETED, together with the meta-task auto-archive sweeps that existed to garbage-collect the cards it filed. Do not reintroduce either.
- The engine filed a `sourceType: "recovery"` card whenever auto-merge gave up on verification or merge conflicts. That card mostly restated state already durable on the parent, which is parked `failed` with a descriptive `error` (verification) or carries an "Auto-merge gave up after conflict retries exhausted" `logEntry` (conflict). Those parent-side signals are the contract now; the card was redundant.
- Because the classifier that cleaned these cards up (`classifyMetaTask`) matched a regex over title+description, it also matched ordinary feature work — and `resolveMetaTargetTaskId` bound an unmatched card to an unrelated task by creation order. Auto-archiving live work was the failure mode that motivated deleting the whole layer rather than tuning it.
- The autostash-orphan path was the one caller carrying information found nowhere else, so it survives as a log entry + task comment (see the Stash Recovery bullets above) rather than a task.
- Eval follow-ups (`eval-followups.ts`) and PR-comment follow-ups (`pr-comment-handler.ts`) are product features, not recovery plumbing, and are unchanged in behavior. They borrowed this engine only for dedup; each now inlines the single rule it needs — no second card for the same `suggestionId`/`prNumber` under the same parent while one is open, with `done`/`archived` excluded so a legitimate re-run can file afresh.
- Run-audit no longer emits `verification:followup-created` or `verification:followup-deduped`; `task:autostash-orphan-live-detected` replaces them, with ids/counts/outcomes-only metadata.
### Conflict handling
`merger.ts` includes conflict classification and auto-resolution helpers:
@@ -2261,7 +2263,6 @@ This section preserves the detailed lifecycle/self-healing contracts that were f
- **Stale registration recovery (FN-5056)**: `NativeWorktreeBackend.create` and `executor.tryCreateWorktree` detect `missing but already registered worktree` failures, run `git worktree prune` (plus `remove --force` / `add -f` fallbacks) before retrying, and emit `worktree:stale-registration-{detected,recovered,recovery-failed}` audit events.
- **Bare branch-collision recovery (FN-8132)**: after stale lock/registration recovery, `NativeWorktreeBackend.create` classifies a `git worktree add -b` “branch already exists” error even when its requested target path is absent. It attaches an unregistered branch only when every unique commit is attributed to the requesting task, recreates merged/subsumed or no-unique-work branches from the caller-pinned start point, and refuses foreign, unattributed, or mixed unique history without moving its ref. A live foreign worktree remains a `BranchConflictError`; recovery dispositions emit `worktree:branch-collision-recovery`.
- **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. The corresponding `task:auto-archive-meta-resolved-skipped` and `task:auto-archive-meta-stalled-skipped` run-audit rows are transition-only per task+guard-reason signature: emit once on first skip, suppress repeated sweeps while the same reasons persist, clear when the skip no longer applies, and re-emit if a different reason later blocks archival.
- **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.
- **Scheduler overlap priority/age guard (FN-5325)**: with `groupOverlappingFiles=true`, scheduler now defers a lower-priority (or younger same-priority) candidate when an overlapping queued todo task exists, preserving priority→age→task-id order for overlap serialization without preempting in-progress work. If the inversion is against an already-running lower-priority blocker, scheduler still defers the candidate; the per-pairing audit event was removed in FN-6174 due to zero consumers and table bloat.
- **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). Amend detection tokenizes the parent process command line (`ps -o args=` with `/proc/$PPID/cmdline` fallback for Alpine/busybox) and stops at the first message-supplying flag (`-m`/`-F`/`--message`/`--file`) so a commit message containing the substring `--amend` cannot bypass the guard. In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count <mergeTarget>..<branch>` is > 0, and `git diff --quiet <mergeBase>..<branch>` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. The fast-path best-effort removes the stranded worktree (FN-4811 same-task/foreign-owner guard) and deletes the `fusion/<id>` branch so empty-own-diff residuals do not accumulate. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The explicit `cwd-integration-branch` mode is unchanged (`cwd-main` remains a deprecated alias normalized to it). `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/<id>` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two audit subtypes — `merge:reuse-fallback-pruned-stale-registration` and `merge:reuse-fallback-reused-existing-registration` — replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases.

View File

@@ -637,8 +637,6 @@ Default notes:
| `staleInReviewWarningMs` | `number` | `86400000` | Task-age staleness warning threshold in ms for `in-review` tasks (24 hours). `0` or `undefined` disables warning-level surfacing. |
| `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`. |

View File

@@ -542,9 +542,7 @@ export const BUILTIN_REVIEW_REVISION_SETTINGS: WorkflowSettingDefinition[] = [
* into bounded `inject_guidance` recovery. Default 2 hours (7,200,000ms): long
* enough that a healthy, actively-working step (the vast majority of which finish
* well under 2h) is never nagged, short enough to actually recover a task that has
* gone dark for "hours" (the FN-7732 symptom) — mirrors the existing 2-hour
* convention `metaTaskStallAutoCloseMs` already uses for a comparable stall
* judgment call elsewhere in this codebase.
* gone dark for "hours" (the FN-7732 symptom).
*/
/*
FNXC:WorkflowOptionalStepCycle 2026-07-26-19:38:

View File

@@ -628,8 +628,6 @@ export const DEFAULT_PROJECT_SETTINGS = {
inReviewStalledThresholdMs: 24 * 60 * 60_000,
stalePausedTodoThresholdMs: 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.

View File

@@ -1787,14 +1787,13 @@ export interface ProjectSettings {
* Self-healing rebounds qualifying holders to todo when this threshold is met.
* Default: 1800000 (30 minutes). Set to 0 to disable. */
pausedScopeDecayMs?: number;
/** Maximum age in milliseconds a meta-task may remain blocked without its target
* 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;
/*
* FNXC:Settings 2026-07-26-16:45:
* `metaTaskStallAutoCloseMs` / `metaTaskActiveExecutionGraceMs` are GONE with the meta-task
* auto-archive sweeps they tuned. The sweeps classified meta-tasks by title/description regex and
* archived live work bound to the wrong target, so the whole feature was deleted rather than
* retuned. Keys left in an existing settings row are inert and simply ignored; do not re-add them.
*/
/** Rolling window in milliseconds for board-stall auto-recovery evaluation.
* Default: 7200000 (2 hours). */
boardStallSweepWindowMs?: number;

View File

@@ -487,8 +487,6 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
inReviewStalledThresholdMs: "internal reliability tuning constant, no UI field",
stalePausedTodoThresholdMs: "internal reliability tuning constant, no UI field",
pausedScopeDecayMs: "internal reliability tuning constant, no UI field",
metaTaskStallAutoCloseMs: "internal reliability tuning constant, no UI field",
metaTaskActiveExecutionGraceMs: "internal reliability tuning constant, no UI field",
boardStallSweepWindowMs: "internal reliability tuning constant, no UI field",
boardStallBlockedGrowthThreshold: "internal reliability tuning constant, no UI field",
backlogPressureAlertEnabled: "internal reliability tuning constant, no UI field",

View File

@@ -157,11 +157,10 @@ describe("normalizeEvalFollowUps", () => {
});
expect(store.createTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-existing",
expect.stringContaining("[verification recurrence] signature=none"),
expect.stringContaining("kind=eval; parentTaskId=FN-parent"),
);
// FNXC:Evals 2026-07-26-00:00: the "[verification recurrence]" logEntry assertion here
// belonged to the deleted shared follow-up dedup engine (which rate-limited a recurrence
// note on the reused card). The inlined dedup only has to prove no duplicate card is
// filed and that the existing one is reported back, which is what remains asserted.
expect(created?.createdTaskId).toBe("FN-existing");
expect(created?.recommendation.reason).toContain("Reused existing follow-up FN-existing");
});

View File

@@ -317,12 +317,11 @@ describe("PrCommentHandler", () => {
},
]);
// FNXC:PullRequestReview 2026-07-26-00:00: the "[verification recurrence]" logEntry
// assertion here belonged to the deleted shared follow-up dedup engine (which wrote a
// rate-limited recurrence note onto the reused card). The inlined dedup only has to
// prove no duplicate card is filed for the same parent/prNumber, which is asserted above.
expect(mockStore.createTask).not.toHaveBeenCalled();
expect(mockStore.logEntry).toHaveBeenCalledWith(
"FN-existing",
expect.stringContaining("[verification recurrence] signature=none"),
expect.stringContaining("kind=pr-comment; parentTaskId=FN-001"),
);
});
});
});

View File

@@ -242,8 +242,6 @@ export type ReliabilityFixture = {
recoverMisclassifiedFailures: () => Promise<number>;
clearStaleBlockedBy: () => Promise<number>;
autoReboundPausedScopeDecay: (opts?: { ignoreAgeGate?: boolean }) => Promise<number>;
autoArchiveResolvedMetaTasks: () => Promise<number>;
autoArchiveStalledMetaTasks: () => Promise<number>;
runBoardStallAutoRecoverySweep: () => Promise<{ holders: string[]; recovered: number; unrecovered: boolean }>;
reconcileDoneTaskIntegrity: () => Promise<number>;
};
@@ -356,8 +354,6 @@ export async function makeReliabilityFixture(input: {
recoverMisclassifiedFailures: async () => manager.recoverMisclassifiedFailures(),
clearStaleBlockedBy: async () => manager.clearStaleBlockedBy(),
autoReboundPausedScopeDecay: async (opts) => manager.autoReboundPausedScopeDecay(opts),
autoArchiveResolvedMetaTasks: async () => manager.autoArchiveResolvedMetaTasks(),
autoArchiveStalledMetaTasks: async () => manager.autoArchiveStalledMetaTasks(),
runBoardStallAutoRecoverySweep: async () => manager.runBoardStallAutoRecoverySweep(),
reconcileDoneTaskIntegrity: async () => manager.reconcileDoneTaskIntegrity(),
},

View File

@@ -1,113 +0,0 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { activeSessionRegistry } from "../../active-session-registry.js";
// FNXC:SqliteRemoval 2026-07-14: hasPg guard added — makeReliabilityFixture requires PG after SQLite removal (VAL-REMOVAL-005).
import { git, hasGit, hasPg, makeReliabilityFixture } from "./_helpers.js";
const canRun = hasGit && hasPg;
(canRun ? describe : describe.skip)("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,
taskPrefix: "FN",
},
});
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, column: "todo" | "in-progress" = "todo") => fixture.store.createTask({
id,
title,
description: `meta guard test for ${target.id}`,
sourceParentTaskId: target.id,
column,
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}`);
// FNXC:MetaArchiveGuards 2026-07-16-11:55: Use the board transition API, then persist the activity timestamp, so the PostgreSQL task row models an active executor segment.
await fixture.store.moveTask(recentMeta.id, "in-progress");
await fixture.store.updateTask(recentMeta.id, {
executionStartedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
} as any);
const persistedRecentMeta = await fixture.store.getTask(recentMeta.id);
expect(persistedRecentMeta).toEqual(expect.objectContaining({
column: "in-progress",
executionStartedAt: expect.any(String),
}));
await fixture.store.updateTask(retryMeta.id, { taskDoneRetryCount: 1 } as any);
expect((await fixture.store.listTasks({ slim: false, includeArchived: true })).find((task) => task.id === recentMeta.id)).toEqual(expect.objectContaining({
column: "in-progress",
executionStartedAt: expect.any(String),
}));
expect(await (fixture.manager as any).evaluateMetaAutoArchiveGuards(await fixture.store.getTask(recentMeta.id))).toEqual({
block: true,
reasons: ["recent-executor-activity"],
});
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 {
// FNXC:MetaArchiveGuards 2026-07-16-11:55: Exercise the archive pass directly. Full maintenance includes independent recovery passes that may re-home an inactive fixture before this guard composition is evaluated.
await (fixture.manager as any).autoArchiveResolvedMetaTasks();
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 = await fixture.store.getRunAuditEventsAsync({ 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();
}
});
});

View File

@@ -1,90 +0,0 @@
import { describe, expect, it } from "vitest";
/*
FNXC:PgMigrationQuarantine 2026-07-18-04:10:
VAL-REMOVAL-005 reliability fixtures use PostgreSQL AsyncDataLayer storage. Read
run audits through getRunAuditEventsAsync so each assertion observes committed
backend events rather than the removed synchronous SQLite read surface.
*/
import { hasGit, hasPg, makeReliabilityFixture } from "./_helpers.js";
const canRun = hasGit && hasPg;
(canRun ? describe : describe.skip)("reliability interactions: meta chain auto-close", () => {
it("replays FN-4890 incident shape across two maintenance ticks", async () => {
const now = Date.now();
const fixture = await makeReliabilityFixture({
taskId: "FN-4890-FIXTURE",
task: { id: "FN-4890-FIXTURE", title: "Fixture anchor", column: "todo" },
settings: {
pausedScopeDecayMs: 1,
metaTaskStallAutoCloseMs: 2 * 60 * 60_000,
boardStallSweepWindowMs: 2 * 60 * 60_000,
boardStallBlockedGrowthThreshold: 1,
},
});
try {
const holder = await fixture.store.createTask({
id: "FN-4867",
title: "Target holder",
description: "paused holder",
column: "in-progress",
steps: [],
} as any);
await fixture.store.updateTask(holder.id, {
paused: true,
pausedReason: "waiting-for-review",
columnMovedAt: new Date(now - 3 * 60 * 60_000).toISOString(),
} as any);
expect((await fixture.store.getTask(holder.id))?.paused).toBe(true);
const meta1 = await fixture.store.createTask({ id: "FN-4872", title: `Recover ${holder.id}`, description: "meta", column: "todo", noCommitsExpected: true, steps: [] } as any);
const meta2 = await fixture.store.createTask({ id: "FN-4878", title: `Recover ${meta1.id}`, description: "meta", column: "todo", noCommitsExpected: true, steps: [] } as any);
const meta3 = await fixture.store.createTask({ id: "FN-4881", title: `Unblock ${meta2.id}`, description: "meta", column: "todo", noCommitsExpected: true, steps: [] } as any);
const meta4 = await fixture.store.createTask({ id: "FN-4883", title: `Finalize ${holder.id}`, description: "meta", column: "todo", noCommitsExpected: true, steps: [] } as any);
const metaTasks = [meta1, meta2, meta3, meta4];
const followerIds: string[] = [];
for (let idx = 1; idx <= 5; idx += 1) {
const follower = await fixture.store.createTask({
id: `FN-490${idx}`,
title: `Follower ${idx}`,
description: "blocked follower",
column: "todo",
steps: [],
} as any);
await fixture.store.updateTask(follower.id, { blockedBy: holder.id } as any);
followerIds.push(follower.id);
}
await (fixture.manager as any).runMaintenance();
await (fixture.manager as any).runMaintenance();
const taskMapAfterSecondTick = new Map(
(await fixture.store.listTasks({ includeArchived: true })).map((task) => [task.id, task]),
);
expect(taskMapAfterSecondTick.get(holder.id)?.column).toBe("todo");
const remainingFollowers = followerIds.filter(
(followerId) => taskMapAfterSecondTick.get(followerId)?.blockedBy === holder.id,
);
expect(remainingFollowers).toHaveLength(0);
const metaColumns = Object.fromEntries(
metaTasks.map((meta) => [meta.id, taskMapAfterSecondTick.get(meta.id)?.column]),
);
expect(metaColumns).toEqual({
[meta1.id]: "archived",
[meta2.id]: "todo",
[meta3.id]: "archived",
[meta4.id]: "archived",
});
const runAudits = await fixture.store.getRunAuditEventsAsync({ limit: 200 });
const decayAudits = runAudits.filter((event) => event.mutationType === "task:auto-rebound-paused-scope-decay");
const metaResolvedAudits = runAudits.filter((event) => event.mutationType === "task:auto-archived-meta-resolved");
expect(decayAudits.length).toBeGreaterThanOrEqual(1);
expect(metaResolvedAudits.length).toBeGreaterThanOrEqual(3);
} finally {
await fixture.cleanup();
}
});
});

View File

@@ -82,8 +82,6 @@ const BATCH2_METHODS = [
"recoverDriftedAgentTaskLinks",
"clearStaleBlockedBy",
"autoReboundPausedScopeDecay",
"autoArchiveResolvedMetaTasks",
"autoArchiveStalledMetaTasks",
"runBoardStallAutoRecoverySweep",
"reconcileSelfDefeatingDependencies",
"reclaimPrConflicts",

View File

@@ -1,208 +0,0 @@
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";
// FNXC:SqliteRemoval 2026-07-14: hasPg guard added — makeReliabilityFixture requires PG after SQLite removal (VAL-REMOVAL-005).
import { git, hasGit, hasPg, 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();
});
const canRun = hasGit && hasPg;
(canRun ? describe : describe.skip)("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 = (await fixture.store.getRunAuditEventsAsync({ 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 = (await fixture.store.getRunAuditEventsAsync({ 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 = (await fixture.store.getRunAuditEventsAsync({ 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("dedupes resolved skipped audits until the guard reason changes", async () => {
const { fixture, meta } = await createResolvedMetaPair();
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 1 } as any);
try {
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
let events = (await fixture.store.getRunAuditEventsAsync({ 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(["task-done-retry-pending"]));
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 0, status: "merging" } as any);
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
events = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).filter((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
expect(events).toHaveLength(2);
expect(events.some((event) => (event.metadata as any)?.blockedBy?.includes("merge-in-progress"))).toBe(true);
} 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 = (await fixture.store.getRunAuditEventsAsync({ 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 = (await fixture.store.getRunAuditEventsAsync({ 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 = (await fixture.store.getRunAuditEventsAsync({ 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 = await fixture.store.getRunAuditEventsAsync({ 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 () => {
// FNXC:PgMigrationQuarantine 2026-07-17-18:30: pin Date for stale-archive thresholds while retaining real timers so PostgreSQL fixture I/O cannot deadlock.
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 = (await fixture.store.getRunAuditEventsAsync({ 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();
}
});
it("dedupes stalled skipped audits until the guard reason changes", async () => {
// FNXC:PgMigrationQuarantine 2026-07-17-18:30: pin Date for stale-archive thresholds while retaining real timers so PostgreSQL fixture I/O cannot deadlock.
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 {
await fixture.selfHeal.autoArchiveStalledMetaTasks();
await fixture.selfHeal.autoArchiveStalledMetaTasks();
let events = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).filter((e) => e.mutationType === "task:auto-archive-meta-stalled-skipped");
expect(events).toHaveLength(1);
expect((events[0]?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["task-done-retry-pending"]));
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 0, status: "merging" } as any);
await fixture.selfHeal.autoArchiveStalledMetaTasks();
events = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).filter((e) => e.mutationType === "task:auto-archive-meta-stalled-skipped");
expect(events).toHaveLength(2);
expect(events.some((event) => (event.metadata as any)?.blockedBy?.includes("merge-in-progress"))).toBe(true);
} finally {
vi.useRealTimers();
await fixture.cleanup();
}
});
});

View File

@@ -7,9 +7,13 @@ import {
type FollowUpDraft,
type TaskStore,
} from "@fusion/core";
import { createAutomatedFollowup } from "./verification-followup-dedup.js";
const OPEN_COLUMNS = new Set(["triage", "todo", "in-progress", "in-review"]);
/*
FNXC:Evals 2026-07-26-00:00:
Eval follow-ups are a real product feature, but they used to borrow the shared automated-recovery follow-up engine (`createAutomatedFollowup` in verification-followup-dedup.ts) purely for its dedup pass. That engine was deleted along with the recovery follow-up cards it existed to file, so the one dedup rule this feature actually needs is inlined here: never create a second card for the same `suggestionId` under the same parent while one is still open. Closed columns (done/archived) are excluded so a re-run after the follow-up is finished can legitimately file a fresh card.
*/
const CLOSED_FOLLOWUP_COLUMNS = new Set(["done", "archived"]);
const GENERIC_TITLE_PATTERNS = [/^follow\s*-?up$/i, /^todo$/i, /^fix\s+issue$/i, /^improve\s+task$/i, /^investigate$/i];
export interface NormalizeEvalFollowUpsInput {
@@ -181,6 +185,29 @@ export async function normalizeEvalFollowUps(input: NormalizeEvalFollowUpsInput)
});
}
/**
* FNXC:Evals 2026-07-26-00:00:
* Inlined replacement for the deleted shared follow-up dedup engine. Returns the id of an
* already-open eval follow-up filed for the same `suggestionId` under the same parent, or
* undefined when this suggestion has no live card yet. Fails open (undefined) if the store
* listing throws, matching the old engine's fail-open-and-create behavior.
*/
async function findOpenEvalFollowUpTaskId(
store: TaskStore,
parentTaskId: string,
suggestionId: string,
): Promise<string | undefined> {
const tasks = await store.listTasks({ slim: true }).catch(() => []);
const match = tasks.find(
(task) =>
task.id !== parentTaskId &&
!CLOSED_FOLLOWUP_COLUMNS.has(task.column) &&
task.sourceParentTaskId === parentTaskId &&
task.sourceMetadata?.suggestionId === suggestionId,
);
return match?.id;
}
export async function materializeEvalFollowUps(input: MaterializeEvalFollowUpsInput): Promise<EvalFollowUpSuggestion[]> {
const { parentTaskId, runId, policyMode, overallScore, followUps, store } = input;
const created: EvalFollowUpSuggestion[] = [];
@@ -191,47 +218,42 @@ export async function materializeEvalFollowUps(input: MaterializeEvalFollowUpsIn
continue;
}
const result = await createAutomatedFollowup(store, {
kind: "eval",
parentTaskId,
extraMatchKeys: { suggestionId: followUp.suggestionId },
createInput: {
title: followUp.title,
description: [
`Follow-up generated from evaluation run ${runId} for ${parentTaskId}.`,
"",
`Problem summary: ${followUp.description}`,
"Expected outcome: Investigate and resolve the issue identified by evaluation findings.",
`Eval severity/score: ${followUp.severity} (${overallScore})`,
`Rationale: ${followUp.rationale}`,
`Evidence refs: ${followUp.evidenceRefs.map((ref) => ref.evidenceId).join(", ") || "none"}`,
].join("\n"),
column: "triage",
priority: followUp.priority,
source: {
sourceType: "automation",
sourceParentTaskId: parentTaskId,
sourceMetadata: {
type: "eval_follow_up",
runId,
suggestionId: followUp.suggestionId,
policyMode,
dedupeKey: followUp.dedupeKey,
},
const existingTaskId = await findOpenEvalFollowUpTaskId(store, parentTaskId, followUp.suggestionId);
const createdTaskId = existingTaskId ?? (await store.createTask({
title: followUp.title,
description: [
`Follow-up generated from evaluation run ${runId} for ${parentTaskId}.`,
"",
`Problem summary: ${followUp.description}`,
"Expected outcome: Investigate and resolve the issue identified by evaluation findings.",
`Eval severity/score: ${followUp.severity} (${overallScore})`,
`Rationale: ${followUp.rationale}`,
`Evidence refs: ${followUp.evidenceRefs.map((ref) => ref.evidenceId).join(", ") || "none"}`,
].join("\n"),
column: "triage",
priority: followUp.priority,
source: {
sourceType: "automation",
sourceParentTaskId: parentTaskId,
sourceMetadata: {
type: "eval_follow_up",
runId,
suggestionId: followUp.suggestionId,
policyMode,
dedupeKey: followUp.dedupeKey,
},
},
});
})).id;
const createdTaskId = result.outcome === "created" ? result.task.id : result.existingTaskId;
created.push({
...followUp,
state: "created",
createdTaskId,
recommendation: {
...followUp.recommendation,
reason: result.outcome === "created"
? `Created as ${createdTaskId} by follow-up policy`
: `Reused existing follow-up ${createdTaskId} by follow-up policy`,
reason: existingTaskId
? `Reused existing follow-up ${createdTaskId} by follow-up policy`
: `Created as ${createdTaskId} by follow-up policy`,
},
});
}

View File

@@ -1,7 +1,12 @@
import type { TaskStore } from "@fusion/core";
import type { PrInfo } from "@fusion/core";
import { prMonitorLog } from "./logger.js";
import { createAutomatedFollowup } from "./verification-followup-dedup.js";
/*
FNXC:PullRequestReview 2026-07-26-00:00:
The PR-feedback follow-up card is a real product feature, but it used to borrow the shared automated-recovery follow-up engine (`createAutomatedFollowup` in verification-followup-dedup.ts) purely for its dedup pass. That engine was deleted along with the recovery follow-up cards it existed to file, so the one dedup rule this feature needs is inlined below: never file a second card for the same PR number under the same parent while one is still open. Closed columns (done/archived) are excluded so a later close/reopen of the same PR can legitimately file a fresh card.
*/
const CLOSED_FOLLOWUP_COLUMNS = new Set(["done", "archived"]);
interface PrComment {
id: number;
@@ -229,28 +234,32 @@ ${summary}
Please review the PR comments and address any remaining issues.`;
try {
const result = await createAutomatedFollowup(this.store, {
kind: "pr-comment",
parentTaskId: originalTaskId,
extraMatchKeys: { prNumber: prInfo.number },
createInput: {
title: `Follow-up: Address PR #${prInfo.number} feedback`,
description,
column: "triage",
dependencies: [originalTaskId],
source: {
sourceType: "api",
sourceParentTaskId: originalTaskId,
sourceMetadata: { prNumber: prInfo.number, prUrl: prInfo.url },
},
const openTasks = await this.store.listTasks({ slim: true }).catch(() => []);
const existing = openTasks.find(
(task) =>
task.id !== originalTaskId &&
!CLOSED_FOLLOWUP_COLUMNS.has(task.column) &&
task.sourceParentTaskId === originalTaskId &&
task.sourceMetadata?.prNumber === prInfo.number,
);
if (existing) {
prMonitorLog.log(`Reused follow-up task ${existing.id} for PR #${prInfo.number}`);
return;
}
const task = await this.store.createTask({
title: `Follow-up: Address PR #${prInfo.number} feedback`,
description,
column: "triage",
dependencies: [originalTaskId],
source: {
sourceType: "api",
sourceParentTaskId: originalTaskId,
sourceMetadata: { prNumber: prInfo.number, prUrl: prInfo.url },
},
});
if (result.outcome === "created") {
prMonitorLog.log(`Created follow-up task ${result.task.id} for PR #${prInfo.number}`);
} else {
prMonitorLog.log(`Reused follow-up task ${result.existingTaskId} for PR #${prInfo.number}`);
}
prMonitorLog.log(`Created follow-up task ${task.id} for PR #${prInfo.number}`);
} catch (err) {
prMonitorLog.error(`Failed to create follow-up task:`, err);
}

View File

@@ -78,11 +78,6 @@ import { ResearchRunDispatcher } from "./research-dispatcher.js";
import { ResearchStepRunner } from "./research-step-runner.js";
import { ResearchProviderRegistry } from "./research/provider-registry.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import {
computeVerificationFailureSignature,
createAutomatedFollowup,
extractFailingTestFiles,
} from "./verification-followup-dedup.js";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
import { isTransientError } from "./transient-error-detector.js";
import { classifyTransientMergeError } from "./transient-merge-error-classifier.js";
@@ -251,21 +246,6 @@ async function verifyMergeConfirmedReachability(args: {
}
}
function buildVerificationFailureSignature(error: VerificationError): string {
const commandResult = error.verificationResult.testResult ?? error.verificationResult.buildResult;
const lane = commandResult?.command?.trim()
|| error.verificationResult.failedCommand?.trim()
|| "verification-failure";
const failingTestFiles = commandResult
? extractFailingTestFiles(commandResult.stdout, commandResult.stderr)
: [];
return computeVerificationFailureSignature({
lane,
failingTestFiles,
failedCommand: commandResult?.command ?? error.verificationResult.failedCommand ?? null,
}).signature;
}
export interface AutomationSubsystemHealth {
status: "not-initialized" | "initializing" | "ready" | "degraded";
message: string;
@@ -3194,32 +3174,6 @@ export class ProjectEngine {
}, intervalMs);
}
private async findActiveRecoveryFollowUp(
store: TaskStore,
parentTaskId: string,
branch?: string,
): Promise<{ task: Task; reason: "parent" | "branch" } | null> {
const tasks = await store.listTasks({ slim: true }).catch(() => [] as Task[]);
const activeRecoveryTasks = tasks.filter(
(task) =>
task.column !== "done" &&
task.column !== "archived" &&
task.sourceType === "recovery",
);
const sameParent = activeRecoveryTasks.find(
(task) => task.sourceParentTaskId === parentTaskId,
);
if (sameParent) return { task: sameParent, reason: "parent" };
if (branch) {
const sameBranch = activeRecoveryTasks.find((task) => task.branch === branch);
if (sameBranch) return { task: sameBranch, reason: "branch" };
}
return null;
}
private async drainMergeQueue(): Promise<void> {
if (this.mergeRunning) return;
this.mergeRunning = true;
@@ -4302,12 +4256,15 @@ export class ProjectEngine {
const cap = ProjectEngine.MAX_VERIFICATION_FAILURE_BOUNCES;
if (nextBounces >= cap) {
// Cap reached — stop bouncing the task and create a follow-up.
// The original task stays in in-review with status=failed so a
// human can inspect; the follow-up captures the failure context
// so a fresh agent can investigate (often a flaky test or an
// unrelated regression that won't be fixed by re-running this
// task's branch).
/*
FNXC:AutoMergeLifecycle 2026-07-26-00:00:
Cap reached — stop bouncing the task. The task stays in in-review with status=failed
and a descriptive `error` so a human can inspect. This used to also file an automated
recovery follow-up card; that machinery was deleted because the card only restated
context already on this task (the [verification] log entries carry the failing command
and output). The park + error + log entry ARE the surface now, so the error text must
stand on its own and must not point at a follow-up that will never exist.
*/
try {
const checkBeforeWrite = await store.getTask(taskId).catch(() => null);
if (checkBeforeWrite?.column === "done" && checkBeforeWrite.mergeDetails?.mergeConfirmed === true) {
@@ -4347,65 +4304,26 @@ export class ProjectEngine {
await store.updateTask(taskId, {
status: "failed",
verificationFailureCount: nextBounces,
error: `Deterministic ${failedKind} verification failed ${nextBounces}× — auto-merge giving up to avoid infinite retry loop. See follow-up task for investigation.`,
error: `Deterministic ${failedKind} verification failed ${nextBounces}× — auto-merge giving up to avoid infinite retry loop. Likely a flaky test or an unrelated regression rather than a fix this task can produce on its own; see the most recent [verification] log entries on this task for the failing command and output.`,
});
const followUpDescription =
`Investigate repeated ${failedKind} verification failure on ${taskId} (${taskOnErr.title || "untitled"}). ` +
`Auto-merge attempted to fix and re-verify ${nextBounces} times without success — likely a flaky test or unrelated regression rather than a fix this task can produce on its own. ` +
`Look at the most recent [verification] log entries on ${taskId} for the failing command and output, then either fix the underlying issue or quarantine the flake.`;
const verificationAuditor = createRunAuditor(store, {
runId: generateSyntheticRunId("auto-merge", taskId),
agentId: "auto-merge",
await store.addTaskComment(
taskId,
phase: "merge",
});
const followUpResult = await createAutomatedFollowup(store, {
kind: "verification-failure",
parentTaskId: taskId,
signature: err instanceof VerificationError ? buildVerificationFailureSignature(err) : undefined,
createInput: {
description: followUpDescription,
column: "triage",
priority: "high",
source: {
sourceType: "recovery",
sourceParentTaskId: taskId,
},
},
auditor: verificationAuditor,
});
if (followUpResult.outcome === "deduped") {
await store.addTaskComment(
taskId,
`Auto-merge giving up after ${nextBounces} verification-failure bounces. Reusing existing follow-up ${followUpResult.existingTaskId}.`,
"agent",
);
await store.logEntry(
taskId,
`Auto-merge gave up after ${nextBounces} verification-failure bounces — skipped creating duplicate follow-up (existing ${followUpResult.existingTaskId})`,
"VerificationError",
);
runtimeLog.warn(
`Auto-merge: ${taskId} hit verification-failure cap (${nextBounces}/${cap}) — skipped duplicate follow-up (existing ${followUpResult.existingTaskId})`,
);
} else {
await store.addTaskComment(
taskId,
`Auto-merge giving up after ${nextBounces} verification-failure bounces. Created follow-up ${followUpResult.task.id} to investigate.`,
"agent",
);
await store.logEntry(
taskId,
`Auto-merge gave up after ${nextBounces} verification-failure bounces — created follow-up ${followUpResult.task.id}`,
"VerificationError",
);
runtimeLog.warn(
`Auto-merge: ${taskId} hit verification-failure cap (${nextBounces}/${cap}) — failed task and created follow-up ${followUpResult.task.id}`,
);
}
} catch (followUpErr) {
`Auto-merge giving up after ${nextBounces} verification-failure bounces. ` +
`Review the most recent [verification] log entries on this task for the failing command and output, ` +
`then either fix the underlying issue or quarantine the flake.`,
"agent",
);
await store.logEntry(
taskId,
`Auto-merge gave up after ${nextBounces} verification-failure bounces — task parked for human intervention`,
"VerificationError",
);
runtimeLog.warn(
`Auto-merge: ${taskId} hit verification-failure cap (${nextBounces}/${cap}) — failed task and parked for human intervention`,
);
} catch (parkErr) {
runtimeLog.error(
`Auto-merge: failed to fail-and-followup ${taskId} after verification cap: ${followUpErr instanceof Error ? followUpErr.message : String(followUpErr)}`,
`Auto-merge: failed to park ${taskId} after verification cap: ${parkErr instanceof Error ? parkErr.message : String(parkErr)}`,
);
}
continue;
@@ -4518,8 +4436,17 @@ export class ProjectEngine {
// New behavior: bounce the task back to in-progress so the
// executor can rebase against the latest main and retry. Cap
// bounces at MAX_MERGE_CONFLICT_BOUNCES — past that, park in
// in-review with status=failed and create a follow-up task so
// a human can resolve the conflict manually.
// in-review with status=failed so a human can resolve the
// conflict manually.
//
/*
FNXC:AutoMergeLifecycle 2026-07-26-00:00:
The park used to also file an automated recovery follow-up card (only when we capped
on bounces, not when autoResolveConflicts was merely off). That machinery was deleted
because the card restated facts already on this task: the `error`, the operator
comment naming the branch to resolve, and the MergeConflictGiveUp log entry all carry
the branch, the reason, and the last merge error. The park itself is the surface now.
*/
const previousBounces = taskOnErr.mergeConflictBounceCount ?? 0;
const nextBounces = previousBounces + 1;
const bounceCap = ProjectEngine.MAX_MERGE_CONFLICT_BOUNCES;
@@ -4548,66 +4475,6 @@ export class ProjectEngine {
`Auto-merge gave up after conflict retries exhausted (${reason}); task parked for human intervention`,
"MergeConflictGiveUp",
);
if (!autoResolveDisabled) {
// Create a follow-up only when we capped on bounces; if
// auto-resolve is just disabled, the user is presumed to
// be handling merges manually and a follow-up is noise.
try {
const followUpResult = await createAutomatedFollowup(store, {
kind: "merge-conflict",
parentTaskId: taskId,
branch: taskOnErr.branch,
signature: computeVerificationFailureSignature({
lane: "merge-conflict",
failingTestFiles: [],
}).signature,
createInput: {
description:
`Resolve auto-merge conflict on ${taskId} (${taskOnErr.title || "untitled"}). ` +
`Auto-merge attempted to rebase + resolve ${nextBounces - 1} times against main and exhausted retries each pass. ` +
`Branch: \`${taskOnErr.branch ?? "?"}\`. Worktree: \`${taskOnErr.worktree ?? "?"}\`. ` +
`Last merge error: ${errorMsg}`,
column: "triage",
priority: "high",
source: {
sourceType: "recovery",
sourceParentTaskId: taskId,
},
},
auditor: createRunAuditor(store, {
runId: generateSyntheticRunId("auto-merge", taskId),
agentId: "auto-merge",
taskId,
phase: "merge",
}),
});
if (followUpResult.outcome === "deduped") {
await store.addTaskComment(
taskId,
`Auto-merge recovery follow-up already exists (${followUpResult.existingTaskId}). Skipping duplicate follow-up creation.`,
"agent",
);
await store.logEntry(
taskId,
`Auto-merge conflict recovery skipped duplicate follow-up (existing ${followUpResult.existingTaskId})`,
"MergeConflictGiveUp",
);
runtimeLog.warn(
`Auto-merge: ${taskId} conflict give-up skipped duplicate follow-up (existing ${followUpResult.existingTaskId})`,
);
} else {
await store.addTaskComment(
taskId,
`Created follow-up ${followUpResult.task.id} to track manual conflict resolution.`,
"agent",
);
}
} catch (followUpErr) {
runtimeLog.warn(
`Auto-merge: failed to create follow-up for ${taskId}: ${followUpErr instanceof Error ? followUpErr.message : String(followUpErr)}`,
);
}
}
} catch (recoveryErr) {
runtimeLog.error(
`Auto-merge: failed to park ${taskId} after conflict-bounce cap: ${recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr)}`,
@@ -4920,39 +4787,56 @@ export class ProjectEngine {
if (!parentTaskId) continue;
try {
const sourcePhase = record.sourcePhase ?? "unknown";
const followUpResult = await createAutomatedFollowup(store, {
kind: "autostash-orphan",
parentTaskId,
signature: computeVerificationFailureSignature({
lane: "autostash-orphan",
failingTestFiles: [],
}).signature,
createInput: {
description:
`Investigate preserved merger autostash leftover from ${parentTaskId} (${record.sha.slice(0, 7)}). ` +
`Detected by ${record.detectedByTaskId ?? "merge sweep"} during ${sourcePhase}; ` +
`stash label: ${record.label}. Recover from stash-recovery before dropping.`,
source: {
sourceType: "recovery",
sourceParentTaskId: parentTaskId,
},
},
auditor: createRunAuditor(store, {
runId: generateSyntheticRunId("auto-merge", parentTaskId),
agentId: "auto-merge",
taskId: parentTaskId,
phase: "merge",
}),
});
const shortSha = record.sha.slice(0, 7);
const detectedBy = record.detectedByTaskId ?? "merge sweep";
/*
FNXC:AutostashRecovery 2026-07-26-00:00:
A `live`-classified autostash orphan is a merger stash that still holds REAL UNCOMMITTED
WORK stranded by a merge pass. This used to file an automated recovery follow-up card via
the shared follow-up engine; that engine was deleted, but unlike the verification-cap and
merge-conflict paths this site has NO parked parent to carry the notice — the parent task
may already be `done` and merged, so if we say nothing here the stash becomes invisible and
the work is silently lost. So the card is replaced by a durable log entry AND an operator
comment on the parent, both of which must keep every fact the old description carried:
the sha, the detecting task, the source phase, and above all `record.label` — that stash
label is the handle `git stash` recovery needs, so it must never be dropped from the
message or truncated. A comment (not only a log entry) because the parent may be closed and
the log is not what an operator reads on a done card.
*/
await store.logEntry(
parentTaskId,
followUpResult.outcome === "deduped"
? `Auto-detected live autostash orphan ${record.sha.slice(0, 7)} — reused follow-up ${followUpResult.existingTaskId}`
: `Auto-created recovery follow-up ${followUpResult.task.id} for live autostash orphan ${record.sha.slice(0, 7)}`,
`Auto-detected live autostash orphan ${shortSha} holding uncommitted work — preserved for manual recovery (stash label: ${record.label})`,
`detectedBy=${record.detectedByTaskId ?? "unknown"}; phase=${sourcePhase}; stash=${record.label}`,
).catch(() => undefined);
await store.addTaskComment(
parentTaskId,
`Preserved merger autostash leftover from this task (${shortSha}) still holds uncommitted work. ` +
`Detected by ${detectedBy} during ${sourcePhase}. ` +
`Stash label: \`${record.label}\` — recover it via stash-recovery before dropping the stash.`,
"agent",
).catch(() => undefined);
const auditor = createRunAuditor(store, {
runId: generateSyntheticRunId("auto-merge", parentTaskId),
agentId: "auto-merge",
taskId: parentTaskId,
phase: "merge",
});
await auditor.database({
type: "task:autostash-orphan-live-detected",
target: parentTaskId,
metadata: {
taskId: parentTaskId,
sha: record.sha,
stashLabel: record.label,
detectedByTaskId: record.detectedByTaskId ?? null,
sourcePhase,
},
}).catch(() => undefined);
} catch (err: unknown) {
runtimeLog.warn(`Autostash orphan recovery follow-up failed for ${parentTaskId}: ${err instanceof Error ? err.message : String(err)}`);
runtimeLog.warn(`Autostash orphan recovery notice failed for ${parentTaskId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
};

View File

@@ -504,10 +504,18 @@ export type DatabaseMutationType =
| "task:auto-merge-skipped-already-done"
/** Metadata: { taskId, commitSha, failedCommand, exitCode, errorTail } */
| "task:post-finalize-verification-no-op"
/** Metadata: { kind, parentTaskId, existingTaskId, signature, rateLimited } */
| "verification:followup-deduped"
/** Metadata: { kind, parentTaskId, newTaskId, signature, supersedesTaskId } */
| "verification:followup-created"
/*
FNXC:RunAudit 2026-07-26-00:00:
Replaces the deleted `verification:followup-created`/`verification:followup-deduped` pair. Those
two existed only for the automated recovery follow-up engine (verification-followup-dedup.ts) and
had no other emitters or readers once it was removed. The autostash-orphan path is the one caller
whose signal had to survive: a `live`-classified orphan is a merger stash holding REAL UNCOMMITTED
WORK, and its parent task may already be `done` and merged, so nothing else on the board would
mention the stash. This event gets a truthful name rather than a borrowed "followup" one.
Metadata: { taskId, sha, stashLabel, detectedByTaskId, sourcePhase } — ids/outcomes only; the
stash label is an opaque recovery identifier, never description prose.
*/
| "task:autostash-orphan-live-detected"
| "mission:stranded-feature-triaged"
| "task:auto-recover-branch-misbound"
| "task:auto-recover-misrouted-foreign-commit"
@@ -647,14 +655,13 @@ export type DatabaseMutationType =
| "task:worktree-contamination-detected"
/** Metadata: { taskId, pausedAgeMs, blockedFollowerIds: string[], previousPausedReason: string | null } */
| "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"
/*
* FNXC:RunAudit 2026-07-26-16:50:
* The four `task:auto-archive*-meta-*` event types were removed with the meta-task auto-archive
* sweeps that emitted them (title-regex classification archived live cards). Historic rows may
* still exist in old databases; readers must tolerate unknown stored types rather than have these
* names reinstated in the union.
*/
/** Metadata: { holderIds: string[], followerCount: number, windowMs: number, blockedGrowth: number } */
| "task:auto-board-stall-broken"
/** Metadata: { holderIds: string[], followerCount: number, windowMs: number, ntfyDispatched: boolean } */

View File

@@ -892,8 +892,6 @@ export class SelfHealingManager {
private strandedHoldContinuationNoActionAudited = new Set<string>();
/* FNXC:SymbolLock 2026-07-30-14:20: idle symbol-lock sweeps emit one no-action audit until a stale lock re-arms the diagnostic. */
private symbolLockNoActionAudited = false;
private metaResolvedSkipAuditMemo = new Map<string, string>();
private metaStalledSkipAuditMemo = new Map<string, string>();
private preservedQueuedOverlapLogged = new Map<string, string>();
private maintenanceTickCounter = 0;
private readonly processBootStartedAt = Date.now();
@@ -1667,8 +1665,6 @@ export class SelfHealingManager {
this.finalizeUnprovenWarned.clear();
this.strandedCompletedFailureProvenanceWarned.clear();
this.metaResolvedSkipAuditMemo.clear();
this.metaStalledSkipAuditMemo.clear();
this.preservedQueuedOverlapLogged.clear();
log.debug("Stopped");
}
@@ -2964,8 +2960,17 @@ export class SelfHealingManager {
{ name: "reconcile-soft-delete-column-drift", fn: () => this.reconcileSoftDeletedColumnDrift() },
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy() },
{ name: "auto-rebound-paused-scope-decay", fn: () => this.autoReboundPausedScopeDecay() },
{ name: "auto-archive-meta-resolved", fn: () => this.autoArchiveResolvedMetaTasks() },
{ name: "auto-archive-meta-stalled", fn: () => this.autoArchiveStalledMetaTasks() },
/*
* FNXC:SelfHealing 2026-07-26-16:40:
* There is deliberately NO meta-task auto-archive sweep here. The removed FN-4890/FN-5064
* sweeps ("auto-archive-meta-resolved"/"auto-archive-meta-stalled") decided a card was a
* "meta-task" by regex over title+description (`/\b(recover|unblock|finalize|meta)\b/i`),
* so an ordinary feature card such as "Unblock queued dispatch" qualified; the target
* resolver then bound it to an unrelated card by creation order and self-healing archived
* live work. No guard set can make a title regex a safe basis for destructive archival, so
* the feature is deleted rather than tuned. Do not reintroduce a heuristic meta-task
* classifier — meta/parent relationships must be explicit task fields if ever needed again.
*/
{ name: "board-stall-auto-recovery", fn: () => this.runBoardStallAutoRecoverySweep() },
// #1401: periodically recover transitionPending markers stranded by a
// crash between the in-txn write and the post-commit clear (flag-ON
@@ -5397,140 +5402,10 @@ export class SelfHealingManager {
return { count: reboundedIds.length, reboundedIds };
}
private classifyMetaTask(task: Task): { isMeta: boolean; targetTaskId: string | null } {
const title = task.title ?? "";
const description = task.description ?? "";
const targetTaskId = task.sourceParentTaskId ?? title.match(/\bFN-\d+\b/i)?.[0] ?? description.match(/\bFN-\d+\b/i)?.[0] ?? null;
const isMeta = Boolean(task.noCommitsExpected) || /\b(recover|unblock|finalize|meta)\b/i.test(`${title} ${description}`);
return { isMeta, targetTaskId: targetTaskId?.toUpperCase() ?? null };
}
private resolveMetaTargetTaskId(byId: Map<string, Task>, task: Task): string | null {
const classified = this.classifyMetaTask(task);
if (classified.targetTaskId) return classified.targetTaskId;
if (!classified.isMeta) return null;
const ordered = [...byId.values()].sort((a, b) => {
const aTime = Date.parse(a.createdAt ?? "");
const bTime = Date.parse(b.createdAt ?? "");
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return aTime - bTime;
return a.id.localeCompare(b.id);
});
const metaTasks = ordered.filter((candidate) => this.classifyMetaTask(candidate).isMeta);
const nonMetaTasks = ordered.filter((candidate) => !this.classifyMetaTask(candidate).isMeta);
const currentIndex = metaTasks.findIndex((candidate) => candidate.id === task.id);
const previousMeta = currentIndex > 0 ? metaTasks[currentIndex - 1] : null;
const firstNonMeta = nonMetaTasks[0] ?? null;
const action = (task.title ?? "").trim().split(/\s+/, 1)[0]?.toLowerCase() ?? "";
if (action === "recover" || action === "unblock") {
return previousMeta?.id ?? firstNonMeta?.id ?? null;
}
if (action === "finalize") {
return firstNonMeta?.id ?? previousMeta?.id ?? null;
}
return previousMeta?.id ?? firstNonMeta?.id ?? null;
}
private computeMetaChainDepth(byId: Map<string, Task>, targetTaskId: string): number {
let depth = 0;
const visited = new Set<string>();
let currentId: string | null = targetTaskId.toUpperCase();
while (currentId && !visited.has(currentId)) {
visited.add(currentId);
const task = byId.get(currentId);
if (!task) break;
if (!this.classifyMetaTask(task).isMeta) break;
const nextTargetId = this.resolveMetaTargetTaskId(byId, task);
if (!nextTargetId) break;
depth += 1;
currentId = nextTargetId.toUpperCase();
}
return depth;
}
private async archiveMetaTask(taskId: string): Promise<void> {
const task = await this.store.getTask(taskId);
if (!task || task.column === "archived") return;
if (task.column === "triage" || task.column === "todo") {
await this.store.moveTask(taskId, "in-progress", { moveSource: "engine" });
}
const progressed = await this.store.getTask(taskId);
if (progressed && progressed.column === "in-progress") {
await this.store.moveTask(taskId, "done", { moveSource: "engine", skipMergeBlocker: true });
}
if (typeof this.store.archiveTaskAndCleanup === "function") {
await this.store.archiveTaskAndCleanup(taskId);
return;
}
if (typeof this.store.archiveTask === "function") {
await this.store.archiveTask(taskId, true);
}
}
private countBlockedDepth(tasks: Task[]): number {
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 ?? await resolveIntegrationBranch(this.options.rootDir, undefined));
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 };
}
private formatReasonSignature(reasons: string[]): string {
return reasons.join("|");
}
private shouldEmitReasonMemo(memo: Map<string, string>, taskId: string, reasons: string[]): boolean {
const signature = this.formatReasonSignature(reasons);
const previous = memo.get(taskId);
if (previous === signature) {
return false;
}
memo.set(taskId, signature);
return true;
}
private clearReasonMemo(memo: Map<string, string>, taskId: string): void {
memo.delete(taskId);
}
private shouldLogPreservedQueuedOverlap(taskId: string, overlapBlockedBy: string | null | undefined): overlapBlockedBy is string {
if (!overlapBlockedBy) return false;
const previous = this.preservedQueuedOverlapLogged.get(taskId);
@@ -5543,109 +5418,6 @@ export class SelfHealingManager {
this.preservedQueuedOverlapLogged.delete(taskId);
}
async autoArchiveResolvedMetaTasks(reboundedTargets?: Set<string>): Promise<number> {
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) {
if (task.column === "archived") continue;
const classified = this.classifyMetaTask(task);
const targetTaskId = this.resolveMetaTargetTaskId(byId, task);
if (!classified.isMeta || !targetTaskId) {
this.clearReasonMemo(this.metaResolvedSkipAuditMemo, task.id);
continue;
}
const chainDepth = this.computeMetaChainDepth(byId, targetTaskId);
const target = byId.get(targetTaskId.toUpperCase());
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) {
this.clearReasonMemo(this.metaResolvedSkipAuditMemo, task.id);
continue;
}
const guardResult = await this.evaluateMetaAutoArchiveGuards(task);
if (guardResult.block) {
if (this.shouldEmitReasonMemo(this.metaResolvedSkipAuditMemo, task.id, guardResult.reasons)) {
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.debug(`[self-healing] skipped meta-resolved auto-archive for ${task.id}: ${guardResult.reasons.join(",")}`);
continue;
}
this.clearReasonMemo(this.metaResolvedSkipAuditMemo, task.id);
try {
await this.store.logEntry(task.id, `Auto-archived meta-task (FN-4890): target ${targetTaskId} resolved/superseded.`);
await this.archiveMetaTask(task.id);
const auditor = createRunAuditor(this.store, { runId: generateSyntheticRunId("fn4890-meta", task.id), agentId: "self-healing", taskId: task.id, phase: "auto-archive-meta-resolved" });
await auditor.database({ type: "task:auto-archived-meta-resolved", target: task.id, metadata: { taskId: task.id, targetTaskId, targetColumn: target?.column ?? "unknown", chainDepth } });
archived++;
} catch (err: unknown) {
log.error(`autoArchiveResolvedMetaTasks failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return archived;
}
async autoArchiveStalledMetaTasks(): Promise<number> {
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: false, includeArchived: false });
const byId = new Map(tasks.map((task) => [task.id.toUpperCase(), task]));
let archived = 0;
const now = Date.now();
for (const task of tasks) {
if (task.column === "archived") continue;
const classified = this.classifyMetaTask(task);
const targetTaskId = this.resolveMetaTargetTaskId(byId, task);
if (!classified.isMeta || !targetTaskId) {
this.clearReasonMemo(this.metaStalledSkipAuditMemo, task.id);
continue;
}
const chainDepth = this.computeMetaChainDepth(byId, targetTaskId);
const ageMs = now - Date.parse(task.columnMovedAt ?? task.updatedAt);
if (chainDepth < 2 && (!Number.isFinite(ageMs) || ageMs < thresholdMs)) {
this.clearReasonMemo(this.metaStalledSkipAuditMemo, task.id);
continue;
}
const target = byId.get(targetTaskId.toUpperCase());
const targetMovedAtMs = Date.parse(target?.columnMovedAt ?? target?.updatedAt ?? "");
const targetStalled = !Number.isFinite(targetMovedAtMs) || (now - targetMovedAtMs >= thresholdMs);
if (chainDepth < 2 && !targetStalled) {
this.clearReasonMemo(this.metaStalledSkipAuditMemo, task.id);
continue;
}
const guardResult = await this.evaluateMetaAutoArchiveGuards(task);
if (guardResult.block) {
if (this.shouldEmitReasonMemo(this.metaStalledSkipAuditMemo, task.id, guardResult.reasons)) {
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.debug(`[self-healing] skipped meta-stalled auto-archive for ${task.id}: ${guardResult.reasons.join(",")}`);
continue;
}
this.clearReasonMemo(this.metaStalledSkipAuditMemo, task.id);
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);
const auditor = createRunAuditor(this.store, { runId: generateSyntheticRunId("fn4890-meta", task.id), agentId: "self-healing", taskId: task.id, phase: "auto-archive-meta-stalled" });
await auditor.database({ type: "task:auto-archived-meta-stalled", target: task.id, metadata: { taskId: task.id, targetTaskId, chainDepth, stalledMs: Math.max(ageMs, 0) } });
archived++;
} catch (err: unknown) {
log.error(`autoArchiveStalledMetaTasks failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return archived;
}
/**
* #1401: periodic transitionPending recovery sweep. Flag-ON only — when
* `workflowColumns` is OFF the legacy path never writes markers, so there is

View File

@@ -1,289 +0,0 @@
import type { Task, TaskCreateInput, TaskStore } from "@fusion/core";
import { basename } from "node:path";
import { createHash } from "node:crypto";
import { runtimeLog } from "./logger.js";
import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "./run-audit.js";
const RECURRENCE_LOG_TAG = "[verification recurrence]";
const RECURRENCE_RATE_LIMIT_MS = 60 * 60 * 1000;
const SUPERSEDES_WINDOW_MS = 24 * 60 * 60 * 1000;
const CLOSED_COLUMNS = new Set(["done", "archived"]);
export type VerificationFailureSignatureInput = {
lane: string;
failingTestFiles: string[];
failedCommand?: string | null;
};
export type AutomatedFollowupKind =
| "verification-failure"
| "merge-conflict"
| "autostash-orphan"
| "eval"
| "pr-comment"
| "scope-leak"
| "contamination";
export type FollowupDedupDecision =
| { action: "create-new"; supersedesTaskId?: string }
| { action: "append-log"; existingTaskId: string; rateLimited: boolean };
export function computeVerificationFailureSignature(input: VerificationFailureSignatureInput): {
signature: string;
failingBasenames: string[];
lane: string;
} {
const lane = input.lane.trim();
const failingBasenames = [...new Set(input.failingTestFiles.map((file) => basename(file.trim())).filter(Boolean))].sort();
const signatureSource = failingBasenames.length > 0
? JSON.stringify({ lane, files: failingBasenames })
: `${lane}|no-files`;
const signature = createHash("sha256").update(signatureSource).digest("hex");
return { signature, failingBasenames, lane };
}
export function extractFailingTestFiles(stdout: string, stderr: string): string[] {
const text = `${stdout}\n${stderr}`;
const files = new Set<string>();
const patterns = [
/^FAIL\s+(.+?)(?::\d+(?::\d+)?)?$/gm,
/^[\u00D7\u2716]\s+(.+?)(?::\d+(?::\d+)?)?$/gm,
/^Error in\s+(.+?)(?::\d+(?::\d+)?)?$/gm,
];
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
const candidate = normalizeMatchedPath(match[1]);
if (candidate) files.add(candidate);
}
}
return [...files].sort();
}
function normalizeMatchedPath(input: string | undefined): string | null {
if (!input) return null;
const trimmed = input.trim();
if (!trimmed) return null;
const firstToken = trimmed.split(/\s+/)[0] ?? "";
const withoutDecorators = firstToken
.replace(/^\(+/, "")
.replace(/\)+$/, "")
.replace(/^['"`]/, "")
.replace(/['"`:,;]+$/, "");
if (!withoutDecorators || !/[\\/]|\.[cm]?[jt]sx?$/.test(withoutDecorators)) {
return null;
}
return basename(withoutDecorators);
}
function metadataMatches(
task: Task,
parentTaskId: string,
extraMatchKeys: Record<string, string | number> | undefined,
): boolean {
if (!extraMatchKeys || Object.keys(extraMatchKeys).length === 0) return false;
if (task.sourceParentTaskId !== parentTaskId) return false;
const metadata = task.sourceMetadata;
if (!metadata) return false;
return Object.entries(extraMatchKeys).every(([key, value]) => metadata[key] === value);
}
function getVerificationSignature(task: Task): string | undefined {
const signature = task.sourceMetadata?.verificationFailureSignature;
return typeof signature === "string" && signature.trim().length > 0 ? signature : undefined;
}
function buildDefaultAuditor(store: TaskStore, parentTaskId: string): RunAuditor {
return createRunAuditor(store, {
runId: generateSyntheticRunId("followup-dedup", parentTaskId),
agentId: "automated-followup",
taskId: parentTaskId,
phase: "followup-dedup",
});
}
async function computeRateLimited(store: TaskStore, taskId: string, now: number): Promise<boolean> {
const fullTask = await store.getTask(taskId);
for (let index = fullTask.log.length - 1; index >= 0; index -= 1) {
const entry = fullTask.log[index];
if (!entry?.action?.startsWith(RECURRENCE_LOG_TAG)) continue;
const entryMs = Date.parse(entry.timestamp);
if (Number.isNaN(entryMs)) return false;
return entryMs > now - RECURRENCE_RATE_LIMIT_MS;
}
return false;
}
export async function decideAutomatedFollowup(
store: TaskStore,
params: {
kind: AutomatedFollowupKind;
parentTaskId: string;
signature?: string;
branch?: string | null;
extraMatchKeys?: Record<string, string | number>;
now?: number;
},
): Promise<FollowupDedupDecision> {
const now = params.now ?? Date.now();
const tasks = await store.listTasks({ slim: true, includeArchived: true });
const candidateTasks = tasks.filter((task) => task.id !== params.parentTaskId);
const openTasks = candidateTasks.filter((task) => !CLOSED_COLUMNS.has(task.column));
const signatureMatch = params.signature
? openTasks.find((task) => getVerificationSignature(task) === params.signature)
: undefined;
if (signatureMatch) {
return {
action: "append-log",
existingTaskId: signatureMatch.id,
rateLimited: await computeRateLimited(store, signatureMatch.id, now),
};
}
const extraMatch = openTasks.find((task) => metadataMatches(task, params.parentTaskId, params.extraMatchKeys));
if (extraMatch) {
return {
action: "append-log",
existingTaskId: extraMatch.id,
rateLimited: await computeRateLimited(store, extraMatch.id, now),
};
}
const legacyParentMatch = openTasks.find(
(task) => task.sourceType === "recovery" && task.sourceParentTaskId === params.parentTaskId,
);
if (legacyParentMatch) {
return {
action: "append-log",
existingTaskId: legacyParentMatch.id,
rateLimited: await computeRateLimited(store, legacyParentMatch.id, now),
};
}
if (params.branch) {
const branchMatch = openTasks.find((task) => task.branch === params.branch);
if (branchMatch) {
return {
action: "append-log",
existingTaskId: branchMatch.id,
rateLimited: await computeRateLimited(store, branchMatch.id, now),
};
}
}
if (params.signature) {
const recentClosedMatch = candidateTasks.find((task) => {
if (!CLOSED_COLUMNS.has(task.column)) return false;
if (getVerificationSignature(task) !== params.signature) return false;
const closedMs = Date.parse(task.updatedAt || task.createdAt);
return !Number.isNaN(closedMs) && closedMs > now - SUPERSEDES_WINDOW_MS;
});
if (recentClosedMatch) {
return { action: "create-new", supersedesTaskId: recentClosedMatch.id };
}
}
return { action: "create-new" };
}
export async function createAutomatedFollowup(
store: TaskStore,
params: {
kind: AutomatedFollowupKind;
parentTaskId: string;
signature?: string;
branch?: string | null;
extraMatchKeys?: Record<string, string | number>;
createInput: TaskCreateInput;
auditor?: RunAuditor;
},
): Promise<
| { outcome: "deduped"; existingTaskId: string; rateLimited: boolean }
| { outcome: "created"; task: Awaited<ReturnType<TaskStore["createTask"]>>; supersedesTaskId?: string }
> {
const auditor = params.auditor ?? buildDefaultAuditor(store, params.parentTaskId);
try {
const decision = await decideAutomatedFollowup(store, {
kind: params.kind,
parentTaskId: params.parentTaskId,
signature: params.signature,
branch: params.branch,
extraMatchKeys: params.extraMatchKeys,
});
if (decision.action === "append-log") {
if (!decision.rateLimited) {
await store.logEntry(
decision.existingTaskId,
`${RECURRENCE_LOG_TAG} signature=${params.signature ?? "none"}`,
`kind=${params.kind}; parentTaskId=${params.parentTaskId}`,
);
}
await auditor.database({
type: "verification:followup-deduped",
target: decision.existingTaskId,
metadata: {
kind: params.kind,
parentTaskId: params.parentTaskId,
existingTaskId: decision.existingTaskId,
signature: params.signature,
rateLimited: decision.rateLimited,
},
}).catch(() => undefined);
return {
outcome: "deduped",
existingTaskId: decision.existingTaskId,
rateLimited: decision.rateLimited,
};
}
const source = params.createInput.source
? {
...params.createInput.source,
sourceMetadata: {
...(params.createInput.source.sourceMetadata ?? {}),
...(params.signature ? { verificationFailureSignature: params.signature } : {}),
...(decision.supersedesTaskId ? { supersedesTaskId: decision.supersedesTaskId } : {}),
},
}
: undefined;
const task = await store.createTask({
...params.createInput,
source,
});
await auditor.database({
type: "verification:followup-created",
target: task.id,
metadata: {
kind: params.kind,
parentTaskId: params.parentTaskId,
newTaskId: task.id,
signature: params.signature,
supersedesTaskId: decision.supersedesTaskId,
},
}).catch(() => undefined);
return {
outcome: "created",
task,
...(decision.supersedesTaskId ? { supersedesTaskId: decision.supersedesTaskId } : {}),
};
} catch (error) {
runtimeLog.warn(
`Automated follow-up dedup failed open for ${params.parentTaskId} (${params.kind}): ${error instanceof Error ? error.message : String(error)}`,
);
const task = await store.createTask(params.createInput);
return { outcome: "created", task };
}
}
export const __testing__ = {
normalizeMatchedPath,
RECURRENCE_LOG_TAG,
RECURRENCE_RATE_LIMIT_MS,
SUPERSEDES_WINDOW_MS,
};