From 0e3d2a2265e3b0ae2eb7545beac076e3c872478c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 26 Jul 2026 22:38:58 -0700 Subject: [PATCH] refactor: delete meta-task auto-archive and automated recovery follow-ups (#2461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) ## 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. Co-authored-by: Claude Opus 5 (1M context) --- ...delete-meta-task-and-recovery-followups.md | 7 + docs/architecture.md | 17 +- docs/settings-reference.md | 2 - .../core/src/builtin-workflow-settings.ts | 4 +- packages/core/src/settings-schema.ts | 2 - packages/core/src/types/settings-scope.ts | 15 +- .../settings-default-descriptions.test.tsx | 2 - .../src/__tests__/eval-followups.test.ts | 9 +- .../src/__tests__/pr-comment-handler.test.ts | 9 +- .../reliability-interactions/_helpers.ts | 4 - .../meta-archive-guard-composition.test.ts | 113 ------- .../meta-chain-auto-close.test.ts | 90 ------ .../self-healing-db-corruption.test.ts | 2 - .../self-healing-meta-archive-guards.test.ts | 208 ------------- packages/engine/src/eval-followups.ts | 88 ++++-- packages/engine/src/pr-comment-handler.ts | 51 ++-- packages/engine/src/project-engine.ts | 280 +++++------------ packages/engine/src/run-audit.ts | 31 +- packages/engine/src/self-healing.ts | 250 +-------------- .../engine/src/verification-followup-dedup.ts | 289 ------------------ 20 files changed, 229 insertions(+), 1244 deletions(-) create mode 100644 .changeset/delete-meta-task-and-recovery-followups.md delete mode 100644 packages/engine/src/__tests__/reliability-interactions/meta-archive-guard-composition.test.ts delete mode 100644 packages/engine/src/__tests__/reliability-interactions/meta-chain-auto-close.test.ts delete mode 100644 packages/engine/src/__tests__/self-healing-meta-archive-guards.test.ts delete mode 100644 packages/engine/src/verification-followup-dedup.ts diff --git a/.changeset/delete-meta-task-and-recovery-followups.md b/.changeset/delete-meta-task-and-recovery-followups.md new file mode 100644 index 0000000000..a8f329949d --- /dev/null +++ b/.changeset/delete-meta-task-and-recovery-followups.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index dfeef87b13..8479c606d0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 ..` is > 0, and `git diff --quiet ..` 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/` 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/` 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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 7698a60306..28e006c33d 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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`. | diff --git a/packages/core/src/builtin-workflow-settings.ts b/packages/core/src/builtin-workflow-settings.ts index 2704fe7f4c..1161cfcc72 100644 --- a/packages/core/src/builtin-workflow-settings.ts +++ b/packages/core/src/builtin-workflow-settings.ts @@ -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: diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index c8bcef3304..30b4711c2b 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -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. diff --git a/packages/core/src/types/settings-scope.ts b/packages/core/src/types/settings-scope.ts index ff55e8af84..8440aea565 100644 --- a/packages/core/src/types/settings-scope.ts +++ b/packages/core/src/types/settings-scope.ts @@ -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; diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 0435bf4d0b..8ef761a078 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -487,8 +487,6 @@ const NOT_SURFACED_ALLOWLIST: Record = { 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", diff --git a/packages/engine/src/__tests__/eval-followups.test.ts b/packages/engine/src/__tests__/eval-followups.test.ts index cf850f0392..7e2bff9069 100644 --- a/packages/engine/src/__tests__/eval-followups.test.ts +++ b/packages/engine/src/__tests__/eval-followups.test.ts @@ -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"); }); diff --git a/packages/engine/src/__tests__/pr-comment-handler.test.ts b/packages/engine/src/__tests__/pr-comment-handler.test.ts index ce479bf08f..863fe9c9c8 100644 --- a/packages/engine/src/__tests__/pr-comment-handler.test.ts +++ b/packages/engine/src/__tests__/pr-comment-handler.test.ts @@ -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"), - ); }); }); }); diff --git a/packages/engine/src/__tests__/reliability-interactions/_helpers.ts b/packages/engine/src/__tests__/reliability-interactions/_helpers.ts index b1b8088a6d..34c1073418 100644 --- a/packages/engine/src/__tests__/reliability-interactions/_helpers.ts +++ b/packages/engine/src/__tests__/reliability-interactions/_helpers.ts @@ -242,8 +242,6 @@ export type ReliabilityFixture = { recoverMisclassifiedFailures: () => Promise; clearStaleBlockedBy: () => Promise; autoReboundPausedScopeDecay: (opts?: { ignoreAgeGate?: boolean }) => Promise; - autoArchiveResolvedMetaTasks: () => Promise; - autoArchiveStalledMetaTasks: () => Promise; runBoardStallAutoRecoverySweep: () => Promise<{ holders: string[]; recovered: number; unrecovered: boolean }>; reconcileDoneTaskIntegrity: () => Promise; }; @@ -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(), }, diff --git a/packages/engine/src/__tests__/reliability-interactions/meta-archive-guard-composition.test.ts b/packages/engine/src/__tests__/reliability-interactions/meta-archive-guard-composition.test.ts deleted file mode 100644 index aee2e37539..0000000000 --- a/packages/engine/src/__tests__/reliability-interactions/meta-archive-guard-composition.test.ts +++ /dev/null @@ -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(); - } - }); -}); diff --git a/packages/engine/src/__tests__/reliability-interactions/meta-chain-auto-close.test.ts b/packages/engine/src/__tests__/reliability-interactions/meta-chain-auto-close.test.ts deleted file mode 100644 index 989a949527..0000000000 --- a/packages/engine/src/__tests__/reliability-interactions/meta-chain-auto-close.test.ts +++ /dev/null @@ -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(); - } - }); -}); diff --git a/packages/engine/src/__tests__/self-healing-db-corruption.test.ts b/packages/engine/src/__tests__/self-healing-db-corruption.test.ts index 156bfe8f9e..bee1be4ac5 100644 --- a/packages/engine/src/__tests__/self-healing-db-corruption.test.ts +++ b/packages/engine/src/__tests__/self-healing-db-corruption.test.ts @@ -82,8 +82,6 @@ const BATCH2_METHODS = [ "recoverDriftedAgentTaskLinks", "clearStaleBlockedBy", "autoReboundPausedScopeDecay", - "autoArchiveResolvedMetaTasks", - "autoArchiveStalledMetaTasks", "runBoardStallAutoRecoverySweep", "reconcileSelfDefeatingDependencies", "reclaimPrConflicts", diff --git a/packages/engine/src/__tests__/self-healing-meta-archive-guards.test.ts b/packages/engine/src/__tests__/self-healing-meta-archive-guards.test.ts deleted file mode 100644 index f01057e855..0000000000 --- a/packages/engine/src/__tests__/self-healing-meta-archive-guards.test.ts +++ /dev/null @@ -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 = {}) { - 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(); - } - }); -}); diff --git a/packages/engine/src/eval-followups.ts b/packages/engine/src/eval-followups.ts index b5116eaa6a..7e9e0fdf91 100644 --- a/packages/engine/src/eval-followups.ts +++ b/packages/engine/src/eval-followups.ts @@ -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 { + 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 { 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`, }, }); } diff --git a/packages/engine/src/pr-comment-handler.ts b/packages/engine/src/pr-comment-handler.ts index d1e2783f48..d8cd398dda 100644 --- a/packages/engine/src/pr-comment-handler.ts +++ b/packages/engine/src/pr-comment-handler.ts @@ -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); } diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 4a425988c6..1bacebac83 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -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 { 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)}`); } } }; diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index f0c18136b9..de8437985f 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -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 } */ diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 94759bc546..1ba21ec7d3 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -892,8 +892,6 @@ export class SelfHealingManager { private strandedHoldContinuationNoActionAudited = new Set(); /* 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(); - private metaStalledSkipAuditMemo = new Map(); private preservedQueuedOverlapLogged = new Map(); 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, 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, targetTaskId: string): number { - let depth = 0; - const visited = new Set(); - 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 { - 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, 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, 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): Promise { - 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 { - 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 diff --git a/packages/engine/src/verification-followup-dedup.ts b/packages/engine/src/verification-followup-dedup.ts deleted file mode 100644 index cdfd07913f..0000000000 --- a/packages/engine/src/verification-followup-dedup.ts +++ /dev/null @@ -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(); - 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 | 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 { - 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; - now?: number; - }, -): Promise { - 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; - createInput: TaskCreateInput; - auditor?: RunAuditor; - }, -): Promise< - | { outcome: "deduped"; existingTaskId: string; rateLimited: boolean } - | { outcome: "created"; task: Awaited>; 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, -};