refactor: delete meta-task auto-archive and automated recovery follow-ups (#2461)
Deletes two pieces of automated "meta" machinery that filed and garbage-collected cards restating state already on the task that failed. Net **-1015 lines**. ## Why **Automated recovery follow-ups.** `createAutomatedFollowup` and its dedup engine (289 lines of signature matching, 1h recurrence rate-limiting, 24h supersedes windows) existed to file recovery cards for verification-cap and merge-conflict give-ups. In both cases the parent is *already* parked `failed` with a descriptive `error` and a log entry carrying the failing command, branch, and output — the card was a second copy of that. **Meta-task auto-archive.** The sweeps that garbage-collected those cards were worse than redundant: the regex classifier matched ordinary feature work, and its positional fallback bound cards to unrelated tasks, so **live work could be archived**. They are removed together, because the auto-archive sweeps only existed to clean up after the follow-up engine. ## What changed ### Deleted - `packages/engine/src/verification-followup-dedup.ts` in full — `createAutomatedFollowup`, `decideAutomatedFollowup`, `AutomatedFollowupKind`, `computeVerificationFailureSignature`, `extractFailingTestFiles`. - `findActiveRecoveryFollowUp` — dead code, defined and never called (`tsc` independently flagged it `6133 declared but its value is never read`). - The meta-task auto-archive sweeps `autoArchiveResolvedMetaTasks` / `autoArchiveStalledMetaTasks` and helpers `classifyMetaTask` / `resolveMetaTargetTaskId` / `computeMetaChainDepth` / `archiveMetaTask` / `evaluateMetaAutoArchiveGuards`, plus settings `metaTaskStallAutoCloseMs` and `metaTaskActiveExecutionGraceMs`. - Run-audit types `task:auto-archived-meta-resolved`, `task:auto-archived-meta-stalled`, `task:auto-archive-meta-resolved-skipped`, `task:auto-archive-meta-stalled-skipped`, `verification:followup-created`, `verification:followup-deduped`. The two signature helpers were **deleted rather than relocated** — once the three call sites went they were provably unreachable: `buildVerificationFailureSignature` had exactly one caller, and it was the only caller of `extractFailingTestFiles`. ### Call sites 1 and 2 — park kept, card dropped Verification-cap and merge-conflict give-ups keep their park, audit event, operator comment, and log entry. Site 1's `error` string was reworded off `"See follow-up task for investigation."` (no follow-up will exist) to carry the guidance itself. `autoResolveDisabled` was **kept** — it still drives the outer park guard and the `reason` string; only the inner branch that guarded card creation is gone. ### Call site 3 — autostash orphan, replaced not deleted This one is a genuine data-loss guard, so it keeps a durable trail. A `live`-classified orphan is a merger stash holding **real uncommitted work**, and unlike sites 1–2 there is no parked parent — the parent may already be `done` and merged, so nothing else on the board would ever mention the stash. The card is replaced by a `logEntry` **and** an `addTaskComment` on the parent, preserving every fact the old description carried: the sha, `record.label` (the handle `git stash` recovery needs), `record.detectedByTaskId`, and `sourcePhase`. New truthful run-audit event `task:autostash-orphan-live-detected` replaces the borrowed `verification:followup-*` name, with ids/outcomes-only metadata per AGENTS.md. ### Kept unchanged: the two real product features Eval follow-ups (`eval-followups.ts`) and PR-comment follow-ups (`pr-comment-handler.ts`) only borrowed the shared engine for its dedup pass. Both keep their exact behavior, column, priority, `sourceType`, and log lines, with dedup inlined as a `listTasks` scan on `suggestionId` / `prNumber` respectively. Both fail open (create) if the listing throws, matching the old engine. ## Test changes — read this one Two tests asserted the *deleted* engine's rate-limited `"[verification recurrence]"` logEntry. Those assertions were removed, **not loosened**: both tests still assert no duplicate card is created, and the eval test still asserts the existing id is reported back. No coverage of surviving behavior was weakened. The three `meta-*` test files were deleted along with the sweeps they covered. ## Verification ``` $ pnpm test:gate Test Files 2 passed (2) Tests 10 passed (10) # core Test Files 16 passed (16) Tests 299 passed (299) # engine-core Test Files 1 passed (1) Tests 70 passed (70) # ci-shape GATE_EXIT=0 $ pnpm --filter @fusion/engine --filter @fusion/core exec tsc --noEmit -p tsconfig.json TSC_EXIT=0 (no output) ``` Plus a file-scoped run over the touched surfaces (`eval-followups`, `pr-comment-handler`, `merger-autostash-orphan-surface`, `merger-autostash-cleanup`, `run-audit`, `run-audit-secret-taxonomy`, `project-engine`, `project-engine-manager`): **213/213 passed**. A repo-wide grep confirms no surviving references to any deleted symbol, module, or audit event. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Failed tasks now retain recovery and verification details directly on the original task instead of generating separate follow-up cards. * Live autostash issues now preserve stash information in task comments and activity logs. * Existing evaluation and pull-request follow-ups continue to be reused when appropriate. * **Changes** * Removed automatic archival of meta-tasks. * Removed obsolete meta-task timing settings. * **Documentation** * Updated architecture and settings documentation to reflect these workflow changes. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/delete-meta-task-and-recovery-followups.md
Normal file
7
.changeset/delete-meta-task-and-recovery-followups.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Stop auto-archiving tasks and auto-filing recovery cards; failures now stay on the task that failed.
|
||||
category: internal
|
||||
dev: Deletes the meta-task auto-archive sweeps (`autoArchiveResolvedMetaTasks`/`autoArchiveStalledMetaTasks` and helpers `classifyMetaTask`/`resolveMetaTargetTaskId`/`computeMetaChainDepth`/`archiveMetaTask`/`evaluateMetaAutoArchiveGuards`) plus settings `metaTaskStallAutoCloseMs` and `metaTaskActiveExecutionGraceMs`; the regex classifier matched ordinary feature work and its positional fallback bound cards to unrelated tasks, so live work could be archived. Also deletes `verification-followup-dedup.ts` (`createAutomatedFollowup`/`decideAutomatedFollowup`), the dead `findActiveRecoveryFollowUp`, and the verification-failure and merge-conflict follow-up call sites — those parents already park `failed` with a descriptive `error` or log an auto-merge-gave-up entry. The autostash-orphan path is preserved as a `logEntry` + `addTaskComment` on the parent carrying sha, stash label, `detectedByTaskId`, and `sourcePhase`, with new run-audit event `task:autostash-orphan-live-detected`. Eval and PR-comment follow-ups are unchanged in behavior with dedup inlined on `suggestionId`/`prNumber`. Run-audit types `task:auto-archived-meta-resolved`, `task:auto-archived-meta-stalled`, `task:auto-archive-meta-resolved-skipped`, `task:auto-archive-meta-stalled-skipped`, `verification:followup-created`, and `verification:followup-deduped` are removed.
|
||||
@@ -2117,16 +2117,18 @@ The GitHub tracking state listener now attaches to every registered project stor
|
||||
- Existing task-scoped surfacing remains: merger warnings still log to `mergerLog.warn` and `store.logEntry` for the active merge task.
|
||||
- New global surfacing adds `merger:autostashOrphans` TaskStore events, engine helpers (`listAutostashOrphans`, `getAutostashDiff`, `applyAutostashBySha`, `dropAutostashBySha`), and dashboard API endpoints under `/api/stash-recovery/*`.
|
||||
- `merger:autostashOrphans` records now include provenance fields (`sourcePhase`, `detectedByTaskId`, `detectedAt`) so operators can attribute leftovers to the merge phase and surfacing task/session.
|
||||
- `ProjectEngine` consumes the orphan event stream and auto-creates deduplicated `sourceType: "recovery"` follow-up tasks for live leftovers, so repeated detections do not spam the board.
|
||||
- `ProjectEngine` consumes the orphan event stream and, for a `live` leftover, writes a `store.logEntry` plus a `store.addTaskComment` on the parent task carrying the sha, `record.label` (the stash label needed to recover it), `detectedByTaskId`, and `sourcePhase`, and emits the `task:autostash-orphan-live-detected` run-audit event. It no longer files a follow-up task — see "Automated follow-up dedup (removed)" below. The parent may already be `done` and merged, so this log/comment pair is the only board-visible trace that stranded work exists; keep the stash label in the message.
|
||||
- Dashboard operators inspect orphan counts, review diffs, apply stashes, and explicitly drop entries with confirmation from **Git Manager → Recovery**; the recovery controls are part of Git Manager rather than a standalone top-level dashboard view.
|
||||
- Decision: recovery stays user-gated. Auto-apply was rejected because clean-tree checks are racy, stash placement is ambiguous after source task merge, and apply conflicts can produce hard-to-untangle state. `sweepAutostashOrphans` continues to auto-drop only subsumed entries while preserving live developer work.
|
||||
|
||||
#### Automated follow-up dedup (FN-5232)
|
||||
- Engine-side automated follow-up creation now routes through `packages/engine/src/verification-followup-dedup.ts` instead of calling `TaskStore.createTask()` directly from recovery/eval/PR-comment paths.
|
||||
- Verification-style follow-ups stamp `sourceMetadata.verificationFailureSignature`, a deterministic SHA-256 digest over `{ lane, sorted failing test basenames }` (or `lane|no-files` when no files can be parsed). Open matches reuse the existing task and append at most one `[verification recurrence]` log entry per hour; closed/done/archived matches within 24 hours create a fresh task with `sourceMetadata.supersedesTaskId` pointing at the prior task.
|
||||
- Non-verification automated follow-ups can supply `extraMatchKeys` (for example eval `suggestionId` or PR `prNumber`) so dedup stays deterministic even when no test-file signature exists.
|
||||
- This layer composes with FN-4892 same-agent intake dedup in `@fusion/core`: engine dedup prevents repeated automated recovery spam up front, while store-side same-agent intake flags newly-created near-duplicates in place by default; only explicit `autoArchiveDuplicateTasksEnabled: true` archives the new task.
|
||||
- Run-audit emits `verification:followup-created` and `verification:followup-deduped` database events with hashed signature metadata only; no raw stdout/stderr or secret material is persisted in the audit payload.
|
||||
#### Automated follow-up dedup (FN-5232 — REMOVED 2026-07-26)
|
||||
`packages/engine/src/verification-followup-dedup.ts` and its `createAutomatedFollowup`/`decideAutomatedFollowup` engine are DELETED, together with the meta-task auto-archive sweeps that existed to garbage-collect the cards it filed. Do not reintroduce either.
|
||||
|
||||
- The engine filed a `sourceType: "recovery"` card whenever auto-merge gave up on verification or merge conflicts. That card mostly restated state already durable on the parent, which is parked `failed` with a descriptive `error` (verification) or carries an "Auto-merge gave up after conflict retries exhausted" `logEntry` (conflict). Those parent-side signals are the contract now; the card was redundant.
|
||||
- Because the classifier that cleaned these cards up (`classifyMetaTask`) matched a regex over title+description, it also matched ordinary feature work — and `resolveMetaTargetTaskId` bound an unmatched card to an unrelated task by creation order. Auto-archiving live work was the failure mode that motivated deleting the whole layer rather than tuning it.
|
||||
- The autostash-orphan path was the one caller carrying information found nowhere else, so it survives as a log entry + task comment (see the Stash Recovery bullets above) rather than a task.
|
||||
- Eval follow-ups (`eval-followups.ts`) and PR-comment follow-ups (`pr-comment-handler.ts`) are product features, not recovery plumbing, and are unchanged in behavior. They borrowed this engine only for dedup; each now inlines the single rule it needs — no second card for the same `suggestionId`/`prNumber` under the same parent while one is open, with `done`/`archived` excluded so a legitimate re-run can file afresh.
|
||||
- Run-audit no longer emits `verification:followup-created` or `verification:followup-deduped`; `task:autostash-orphan-live-detected` replaces them, with ids/counts/outcomes-only metadata.
|
||||
|
||||
### Conflict handling
|
||||
`merger.ts` includes conflict classification and auto-resolution helpers:
|
||||
@@ -2261,7 +2263,6 @@ This section preserves the detailed lifecycle/self-healing contracts that were f
|
||||
- **Stale registration recovery (FN-5056)**: `NativeWorktreeBackend.create` and `executor.tryCreateWorktree` detect `missing but already registered worktree` failures, run `git worktree prune` (plus `remove --force` / `add -f` fallbacks) before retrying, and emit `worktree:stale-registration-{detected,recovered,recovery-failed}` audit events.
|
||||
- **Bare branch-collision recovery (FN-8132)**: after stale lock/registration recovery, `NativeWorktreeBackend.create` classifies a `git worktree add -b` “branch already exists” error even when its requested target path is absent. It attaches an unregistered branch only when every unique commit is attributed to the requesting task, recreates merged/subsumed or no-unique-work branches from the caller-pinned start point, and refuses foreign, unattributed, or mixed unique history without moving its ref. A live foreign worktree remains a `BranchConflictError`; recovery dispositions emit `worktree:branch-collision-recovery`.
|
||||
- **Raw worktree deletion must be paired with prune (FN-5058)**: any direct filesystem deletion of a worktree directory (`rm -rf` / `rmSync`) must be followed by best-effort `git worktree prune` via `pruneWorktreeAdminEntries` so `.git/worktrees/*` admin entries are not stranded in a missing-but-registered state (FN-5056 class).
|
||||
- **Meta-task auto-archive safety guards (FN-5064)**: `auto-archive-meta-resolved`/`auto-archive-meta-stalled` must skip archival (with `task:auto-archive-meta-*-skipped` audits) whenever guard checks detect substantive work signals such as unique branch commits, recent executor activity, pending `taskDoneRetryCount`, merge-in-progress state, or active worktree session. The corresponding `task:auto-archive-meta-resolved-skipped` and `task:auto-archive-meta-stalled-skipped` run-audit rows are transition-only per task+guard-reason signature: emit once on first skip, suppress repeated sweeps while the same reasons persist, clear when the skip no longer applies, and re-emit if a different reason later blocks archival.
|
||||
- **Scheduler fanout tiebreaker (FN-4969)**: within the same priority class, scheduler dispatch prefers runnable `todo` tasks with the highest active dependency-dependent fanout; `urgent` always outranks lower priorities regardless of fanout, and `overlapBlockedBy`/file-scope overlap blockers are excluded from unblock weight.
|
||||
- **Scheduler overlap priority/age guard (FN-5325)**: with `groupOverlappingFiles=true`, scheduler now defers a lower-priority (or younger same-priority) candidate when an overlapping queued todo task exists, preserving priority→age→task-id order for overlap serialization without preempting in-progress work. If the inversion is against an already-running lower-priority blocker, scheduler still defers the candidate; the per-pairing audit event was removed in FN-6174 due to zero consumers and table bloat.
|
||||
- **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). Amend detection tokenizes the parent process command line (`ps -o args=` with `/proc/$PPID/cmdline` fallback for Alpine/busybox) and stops at the first message-supplying flag (`-m`/`-F`/`--message`/`--file`) so a commit message containing the substring `--amend` cannot bypass the guard. In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count <mergeTarget>..<branch>` is > 0, and `git diff --quiet <mergeBase>..<branch>` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. The fast-path best-effort removes the stranded worktree (FN-4811 same-task/foreign-owner guard) and deletes the `fusion/<id>` branch so empty-own-diff residuals do not accumulate. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The explicit `cwd-integration-branch` mode is unchanged (`cwd-main` remains a deprecated alias normalized to it). `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/<id>` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two audit subtypes — `merge:reuse-fallback-pruned-stale-registration` and `merge:reuse-fallback-reused-existing-registration` — replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases.
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -487,8 +487,6 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
|
||||
inReviewStalledThresholdMs: "internal reliability tuning constant, no UI field",
|
||||
stalePausedTodoThresholdMs: "internal reliability tuning constant, no UI field",
|
||||
pausedScopeDecayMs: "internal reliability tuning constant, no UI field",
|
||||
metaTaskStallAutoCloseMs: "internal reliability tuning constant, no UI field",
|
||||
metaTaskActiveExecutionGraceMs: "internal reliability tuning constant, no UI field",
|
||||
boardStallSweepWindowMs: "internal reliability tuning constant, no UI field",
|
||||
boardStallBlockedGrowthThreshold: "internal reliability tuning constant, no UI field",
|
||||
backlogPressureAlertEnabled: "internal reliability tuning constant, no UI field",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,8 +242,6 @@ export type ReliabilityFixture = {
|
||||
recoverMisclassifiedFailures: () => Promise<number>;
|
||||
clearStaleBlockedBy: () => Promise<number>;
|
||||
autoReboundPausedScopeDecay: (opts?: { ignoreAgeGate?: boolean }) => Promise<number>;
|
||||
autoArchiveResolvedMetaTasks: () => Promise<number>;
|
||||
autoArchiveStalledMetaTasks: () => Promise<number>;
|
||||
runBoardStallAutoRecoverySweep: () => Promise<{ holders: string[]; recovered: number; unrecovered: boolean }>;
|
||||
reconcileDoneTaskIntegrity: () => Promise<number>;
|
||||
};
|
||||
@@ -356,8 +354,6 @@ export async function makeReliabilityFixture(input: {
|
||||
recoverMisclassifiedFailures: async () => manager.recoverMisclassifiedFailures(),
|
||||
clearStaleBlockedBy: async () => manager.clearStaleBlockedBy(),
|
||||
autoReboundPausedScopeDecay: async (opts) => manager.autoReboundPausedScopeDecay(opts),
|
||||
autoArchiveResolvedMetaTasks: async () => manager.autoArchiveResolvedMetaTasks(),
|
||||
autoArchiveStalledMetaTasks: async () => manager.autoArchiveStalledMetaTasks(),
|
||||
runBoardStallAutoRecoverySweep: async () => manager.runBoardStallAutoRecoverySweep(),
|
||||
reconcileDoneTaskIntegrity: async () => manager.reconcileDoneTaskIntegrity(),
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -82,8 +82,6 @@ const BATCH2_METHODS = [
|
||||
"recoverDriftedAgentTaskLinks",
|
||||
"clearStaleBlockedBy",
|
||||
"autoReboundPausedScopeDecay",
|
||||
"autoArchiveResolvedMetaTasks",
|
||||
"autoArchiveStalledMetaTasks",
|
||||
"runBoardStallAutoRecoverySweep",
|
||||
"reconcileSelfDefeatingDependencies",
|
||||
"reclaimPrConflicts",
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { activeSessionRegistry } from "../active-session-registry.js";
|
||||
// FNXC:SqliteRemoval 2026-07-14: hasPg guard added — makeReliabilityFixture requires PG after SQLite removal (VAL-REMOVAL-005).
|
||||
import { git, hasGit, hasPg, makeReliabilityFixture } from "./reliability-interactions/_helpers.js";
|
||||
|
||||
async function createResolvedMetaPair(settingsOverrides: Record<string, unknown> = {}) {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5064-FIXTURE",
|
||||
task: { id: "FN-5064-FIXTURE", title: "anchor", column: "todo" },
|
||||
settings: { metaTaskActiveExecutionGraceMs: 30 * 60_000, ...settingsOverrides },
|
||||
});
|
||||
|
||||
const target = await fixture.store.createTask({
|
||||
id: "FN-5064-TARGET",
|
||||
title: "target",
|
||||
description: "target",
|
||||
column: "done",
|
||||
steps: [],
|
||||
} as any);
|
||||
const meta = await fixture.store.createTask({
|
||||
id: "FN-5064-META",
|
||||
title: "Recover target task",
|
||||
description: `meta wrapper for ${target.id}`,
|
||||
sourceParentTaskId: target.id,
|
||||
column: "todo",
|
||||
noCommitsExpected: true,
|
||||
steps: [],
|
||||
worktree: "/tmp/fn-5064-meta",
|
||||
} as any);
|
||||
|
||||
return { fixture, target, meta };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
activeSessionRegistry.clear();
|
||||
});
|
||||
|
||||
const canRun = hasGit && hasPg;
|
||||
(canRun ? describe : describe.skip)("SelfHealingManager meta auto-archive guards", () => {
|
||||
it("skips resolved auto-archive when branch has unique commits", async () => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
const branchName = `fusion/${meta.id.toLowerCase()}`;
|
||||
git(fixture.rootDir, `git checkout -b ${branchName}`);
|
||||
git(fixture.rootDir, "git commit --allow-empty -m \"feat: ahead commit\"");
|
||||
git(fixture.rootDir, "git checkout main");
|
||||
await fixture.store.updateTask(meta.id, { branch: branchName } as any);
|
||||
try {
|
||||
const archived = await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
expect(archived).toBe(0);
|
||||
expect((await fixture.store.getTask(meta.id))?.column).not.toBe("archived");
|
||||
const events = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).filter((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0]?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["branch-has-unique-commits"]));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips resolved auto-archive when executor activity is recent", async () => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
await fixture.store.updateTask(meta.id, { column: "in-progress", executionStartedAt: new Date(Date.now() - 5 * 60_000).toISOString() } as any);
|
||||
try {
|
||||
const archived = await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
expect(archived).toBe(0);
|
||||
const event = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).find((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect((event?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["recent-executor-activity"]));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips resolved auto-archive when taskDone retry is pending", async () => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 2 } as any);
|
||||
try {
|
||||
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
const event = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).find((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect((event?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["task-done-retry-pending"]));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("dedupes resolved skipped audits until the guard reason changes", async () => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 1 } as any);
|
||||
try {
|
||||
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
let events = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).filter((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0]?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["task-done-retry-pending"]));
|
||||
|
||||
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 0, status: "merging" } as any);
|
||||
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
events = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).filter((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events.some((event) => (event.metadata as any)?.blockedBy?.includes("merge-in-progress"))).toBe(true);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ updates: { mergeDetails: { commitSha: "abc123" } }, label: "merge commitSha exists" },
|
||||
{ updates: { status: "merging" }, label: "status merging" },
|
||||
{ updates: { status: "merging-pr" }, label: "status merging-pr" },
|
||||
])("skips resolved auto-archive when merge is in progress: $label", async ({ updates }) => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
await fixture.store.updateTask(meta.id, updates as any);
|
||||
try {
|
||||
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
const event = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).find((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect((event?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["merge-in-progress"]));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips resolved auto-archive when worktree has active session", async () => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
const activePath = join(fixture.rootDir, "active-session-worktree");
|
||||
await mkdir(activePath, { recursive: true });
|
||||
await fixture.store.updateTask(meta.id, { worktree: activePath } as any);
|
||||
activeSessionRegistry.registerPath(activePath, { taskId: meta.id, kind: "executor", ownerKey: meta.id });
|
||||
try {
|
||||
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
const event = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).find((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect((event?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["active-session"]));
|
||||
} finally {
|
||||
activeSessionRegistry.unregisterPath(activePath);
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("collects multiple guard reasons", async () => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 1, status: "merging" } as any);
|
||||
try {
|
||||
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
const event = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).find((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect((event?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["task-done-retry-pending", "merge-in-progress"]));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps legitimate resolved meta auto-archive behavior", async () => {
|
||||
const { fixture, meta } = await createResolvedMetaPair();
|
||||
try {
|
||||
const archived = await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
expect(archived).toBe(1);
|
||||
expect((await fixture.store.getTask(meta.id))?.column).toBe("archived");
|
||||
const audits = await fixture.store.getRunAuditEventsAsync({ limit: 200 });
|
||||
expect(audits.some((event) => event.mutationType === "task:auto-archived-meta-resolved")).toBe(true);
|
||||
expect(audits.some((event) => event.mutationType === "task:auto-archive-meta-resolved-skipped")).toBe(false);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits stalled skipped event when guards block stalled archive", async () => {
|
||||
// FNXC:PgMigrationQuarantine 2026-07-17-18:30: pin Date for stale-archive thresholds while retaining real timers so PostgreSQL fixture I/O cannot deadlock.
|
||||
const now = new Date("2026-05-18T12:00:00.000Z");
|
||||
vi.setSystemTime(now);
|
||||
const { fixture, meta } = await createResolvedMetaPair({ metaTaskStallAutoCloseMs: 60_000 });
|
||||
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 1 } as any);
|
||||
vi.setSystemTime(new Date(now.getTime() + 2 * 60 * 60_000));
|
||||
try {
|
||||
const archived = await fixture.selfHeal.autoArchiveStalledMetaTasks();
|
||||
expect(archived).toBe(0);
|
||||
const event = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).find((e) => e.mutationType === "task:auto-archive-meta-stalled-skipped");
|
||||
expect(event).toBeTruthy();
|
||||
expect((event?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["task-done-retry-pending"]));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("dedupes stalled skipped audits until the guard reason changes", async () => {
|
||||
// FNXC:PgMigrationQuarantine 2026-07-17-18:30: pin Date for stale-archive thresholds while retaining real timers so PostgreSQL fixture I/O cannot deadlock.
|
||||
const now = new Date("2026-05-18T12:00:00.000Z");
|
||||
vi.setSystemTime(now);
|
||||
const { fixture, meta } = await createResolvedMetaPair({ metaTaskStallAutoCloseMs: 60_000 });
|
||||
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 1 } as any);
|
||||
vi.setSystemTime(new Date(now.getTime() + 2 * 60 * 60_000));
|
||||
try {
|
||||
await fixture.selfHeal.autoArchiveStalledMetaTasks();
|
||||
await fixture.selfHeal.autoArchiveStalledMetaTasks();
|
||||
let events = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).filter((e) => e.mutationType === "task:auto-archive-meta-stalled-skipped");
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0]?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["task-done-retry-pending"]));
|
||||
|
||||
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 0, status: "merging" } as any);
|
||||
await fixture.selfHeal.autoArchiveStalledMetaTasks();
|
||||
events = (await fixture.store.getRunAuditEventsAsync({ limit: 200 })).filter((e) => e.mutationType === "task:auto-archive-meta-stalled-skipped");
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events.some((event) => (event.metadata as any)?.blockedBy?.includes("merge-in-progress"))).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,13 @@ import {
|
||||
type FollowUpDraft,
|
||||
type TaskStore,
|
||||
} from "@fusion/core";
|
||||
import { createAutomatedFollowup } from "./verification-followup-dedup.js";
|
||||
|
||||
const OPEN_COLUMNS = new Set(["triage", "todo", "in-progress", "in-review"]);
|
||||
/*
|
||||
FNXC:Evals 2026-07-26-00:00:
|
||||
Eval follow-ups are a real product feature, but they used to borrow the shared automated-recovery follow-up engine (`createAutomatedFollowup` in verification-followup-dedup.ts) purely for its dedup pass. That engine was deleted along with the recovery follow-up cards it existed to file, so the one dedup rule this feature actually needs is inlined here: never create a second card for the same `suggestionId` under the same parent while one is still open. Closed columns (done/archived) are excluded so a re-run after the follow-up is finished can legitimately file a fresh card.
|
||||
*/
|
||||
const CLOSED_FOLLOWUP_COLUMNS = new Set(["done", "archived"]);
|
||||
const GENERIC_TITLE_PATTERNS = [/^follow\s*-?up$/i, /^todo$/i, /^fix\s+issue$/i, /^improve\s+task$/i, /^investigate$/i];
|
||||
|
||||
export interface NormalizeEvalFollowUpsInput {
|
||||
@@ -181,6 +185,29 @@ export async function normalizeEvalFollowUps(input: NormalizeEvalFollowUpsInput)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:Evals 2026-07-26-00:00:
|
||||
* Inlined replacement for the deleted shared follow-up dedup engine. Returns the id of an
|
||||
* already-open eval follow-up filed for the same `suggestionId` under the same parent, or
|
||||
* undefined when this suggestion has no live card yet. Fails open (undefined) if the store
|
||||
* listing throws, matching the old engine's fail-open-and-create behavior.
|
||||
*/
|
||||
async function findOpenEvalFollowUpTaskId(
|
||||
store: TaskStore,
|
||||
parentTaskId: string,
|
||||
suggestionId: string,
|
||||
): Promise<string | undefined> {
|
||||
const tasks = await store.listTasks({ slim: true }).catch(() => []);
|
||||
const match = tasks.find(
|
||||
(task) =>
|
||||
task.id !== parentTaskId &&
|
||||
!CLOSED_FOLLOWUP_COLUMNS.has(task.column) &&
|
||||
task.sourceParentTaskId === parentTaskId &&
|
||||
task.sourceMetadata?.suggestionId === suggestionId,
|
||||
);
|
||||
return match?.id;
|
||||
}
|
||||
|
||||
export async function materializeEvalFollowUps(input: MaterializeEvalFollowUpsInput): Promise<EvalFollowUpSuggestion[]> {
|
||||
const { parentTaskId, runId, policyMode, overallScore, followUps, store } = input;
|
||||
const created: EvalFollowUpSuggestion[] = [];
|
||||
@@ -191,47 +218,42 @@ export async function materializeEvalFollowUps(input: MaterializeEvalFollowUpsIn
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await createAutomatedFollowup(store, {
|
||||
kind: "eval",
|
||||
parentTaskId,
|
||||
extraMatchKeys: { suggestionId: followUp.suggestionId },
|
||||
createInput: {
|
||||
title: followUp.title,
|
||||
description: [
|
||||
`Follow-up generated from evaluation run ${runId} for ${parentTaskId}.`,
|
||||
"",
|
||||
`Problem summary: ${followUp.description}`,
|
||||
"Expected outcome: Investigate and resolve the issue identified by evaluation findings.",
|
||||
`Eval severity/score: ${followUp.severity} (${overallScore})`,
|
||||
`Rationale: ${followUp.rationale}`,
|
||||
`Evidence refs: ${followUp.evidenceRefs.map((ref) => ref.evidenceId).join(", ") || "none"}`,
|
||||
].join("\n"),
|
||||
column: "triage",
|
||||
priority: followUp.priority,
|
||||
source: {
|
||||
sourceType: "automation",
|
||||
sourceParentTaskId: parentTaskId,
|
||||
sourceMetadata: {
|
||||
type: "eval_follow_up",
|
||||
runId,
|
||||
suggestionId: followUp.suggestionId,
|
||||
policyMode,
|
||||
dedupeKey: followUp.dedupeKey,
|
||||
},
|
||||
const existingTaskId = await findOpenEvalFollowUpTaskId(store, parentTaskId, followUp.suggestionId);
|
||||
const createdTaskId = existingTaskId ?? (await store.createTask({
|
||||
title: followUp.title,
|
||||
description: [
|
||||
`Follow-up generated from evaluation run ${runId} for ${parentTaskId}.`,
|
||||
"",
|
||||
`Problem summary: ${followUp.description}`,
|
||||
"Expected outcome: Investigate and resolve the issue identified by evaluation findings.",
|
||||
`Eval severity/score: ${followUp.severity} (${overallScore})`,
|
||||
`Rationale: ${followUp.rationale}`,
|
||||
`Evidence refs: ${followUp.evidenceRefs.map((ref) => ref.evidenceId).join(", ") || "none"}`,
|
||||
].join("\n"),
|
||||
column: "triage",
|
||||
priority: followUp.priority,
|
||||
source: {
|
||||
sourceType: "automation",
|
||||
sourceParentTaskId: parentTaskId,
|
||||
sourceMetadata: {
|
||||
type: "eval_follow_up",
|
||||
runId,
|
||||
suggestionId: followUp.suggestionId,
|
||||
policyMode,
|
||||
dedupeKey: followUp.dedupeKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
})).id;
|
||||
|
||||
const createdTaskId = result.outcome === "created" ? result.task.id : result.existingTaskId;
|
||||
created.push({
|
||||
...followUp,
|
||||
state: "created",
|
||||
createdTaskId,
|
||||
recommendation: {
|
||||
...followUp.recommendation,
|
||||
reason: result.outcome === "created"
|
||||
? `Created as ${createdTaskId} by follow-up policy`
|
||||
: `Reused existing follow-up ${createdTaskId} by follow-up policy`,
|
||||
reason: existingTaskId
|
||||
? `Reused existing follow-up ${createdTaskId} by follow-up policy`
|
||||
: `Created as ${createdTaskId} by follow-up policy`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -78,11 +78,6 @@ import { ResearchRunDispatcher } from "./research-dispatcher.js";
|
||||
import { ResearchStepRunner } from "./research-step-runner.js";
|
||||
import { ResearchProviderRegistry } from "./research/provider-registry.js";
|
||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||
import {
|
||||
computeVerificationFailureSignature,
|
||||
createAutomatedFollowup,
|
||||
extractFailingTestFiles,
|
||||
} from "./verification-followup-dedup.js";
|
||||
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
|
||||
import { isTransientError } from "./transient-error-detector.js";
|
||||
import { classifyTransientMergeError } from "./transient-merge-error-classifier.js";
|
||||
@@ -251,21 +246,6 @@ async function verifyMergeConfirmedReachability(args: {
|
||||
}
|
||||
}
|
||||
|
||||
function buildVerificationFailureSignature(error: VerificationError): string {
|
||||
const commandResult = error.verificationResult.testResult ?? error.verificationResult.buildResult;
|
||||
const lane = commandResult?.command?.trim()
|
||||
|| error.verificationResult.failedCommand?.trim()
|
||||
|| "verification-failure";
|
||||
const failingTestFiles = commandResult
|
||||
? extractFailingTestFiles(commandResult.stdout, commandResult.stderr)
|
||||
: [];
|
||||
return computeVerificationFailureSignature({
|
||||
lane,
|
||||
failingTestFiles,
|
||||
failedCommand: commandResult?.command ?? error.verificationResult.failedCommand ?? null,
|
||||
}).signature;
|
||||
}
|
||||
|
||||
export interface AutomationSubsystemHealth {
|
||||
status: "not-initialized" | "initializing" | "ready" | "degraded";
|
||||
message: string;
|
||||
@@ -3194,32 +3174,6 @@ export class ProjectEngine {
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
private async findActiveRecoveryFollowUp(
|
||||
store: TaskStore,
|
||||
parentTaskId: string,
|
||||
branch?: string,
|
||||
): Promise<{ task: Task; reason: "parent" | "branch" } | null> {
|
||||
const tasks = await store.listTasks({ slim: true }).catch(() => [] as Task[]);
|
||||
const activeRecoveryTasks = tasks.filter(
|
||||
(task) =>
|
||||
task.column !== "done" &&
|
||||
task.column !== "archived" &&
|
||||
task.sourceType === "recovery",
|
||||
);
|
||||
|
||||
const sameParent = activeRecoveryTasks.find(
|
||||
(task) => task.sourceParentTaskId === parentTaskId,
|
||||
);
|
||||
if (sameParent) return { task: sameParent, reason: "parent" };
|
||||
|
||||
if (branch) {
|
||||
const sameBranch = activeRecoveryTasks.find((task) => task.branch === branch);
|
||||
if (sameBranch) return { task: sameBranch, reason: "branch" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async drainMergeQueue(): Promise<void> {
|
||||
if (this.mergeRunning) return;
|
||||
this.mergeRunning = true;
|
||||
@@ -4302,12 +4256,15 @@ export class ProjectEngine {
|
||||
const cap = ProjectEngine.MAX_VERIFICATION_FAILURE_BOUNCES;
|
||||
|
||||
if (nextBounces >= cap) {
|
||||
// Cap reached — stop bouncing the task and create a follow-up.
|
||||
// The original task stays in in-review with status=failed so a
|
||||
// human can inspect; the follow-up captures the failure context
|
||||
// so a fresh agent can investigate (often a flaky test or an
|
||||
// unrelated regression that won't be fixed by re-running this
|
||||
// task's branch).
|
||||
/*
|
||||
FNXC:AutoMergeLifecycle 2026-07-26-00:00:
|
||||
Cap reached — stop bouncing the task. The task stays in in-review with status=failed
|
||||
and a descriptive `error` so a human can inspect. This used to also file an automated
|
||||
recovery follow-up card; that machinery was deleted because the card only restated
|
||||
context already on this task (the [verification] log entries carry the failing command
|
||||
and output). The park + error + log entry ARE the surface now, so the error text must
|
||||
stand on its own and must not point at a follow-up that will never exist.
|
||||
*/
|
||||
try {
|
||||
const checkBeforeWrite = await store.getTask(taskId).catch(() => null);
|
||||
if (checkBeforeWrite?.column === "done" && checkBeforeWrite.mergeDetails?.mergeConfirmed === true) {
|
||||
@@ -4347,65 +4304,26 @@ export class ProjectEngine {
|
||||
await store.updateTask(taskId, {
|
||||
status: "failed",
|
||||
verificationFailureCount: nextBounces,
|
||||
error: `Deterministic ${failedKind} verification failed ${nextBounces}× — auto-merge giving up to avoid infinite retry loop. See follow-up task for investigation.`,
|
||||
error: `Deterministic ${failedKind} verification failed ${nextBounces}× — auto-merge giving up to avoid infinite retry loop. Likely a flaky test or an unrelated regression rather than a fix this task can produce on its own; see the most recent [verification] log entries on this task for the failing command and output.`,
|
||||
});
|
||||
const followUpDescription =
|
||||
`Investigate repeated ${failedKind} verification failure on ${taskId} (${taskOnErr.title || "untitled"}). ` +
|
||||
`Auto-merge attempted to fix and re-verify ${nextBounces} times without success — likely a flaky test or unrelated regression rather than a fix this task can produce on its own. ` +
|
||||
`Look at the most recent [verification] log entries on ${taskId} for the failing command and output, then either fix the underlying issue or quarantine the flake.`;
|
||||
const verificationAuditor = createRunAuditor(store, {
|
||||
runId: generateSyntheticRunId("auto-merge", taskId),
|
||||
agentId: "auto-merge",
|
||||
await store.addTaskComment(
|
||||
taskId,
|
||||
phase: "merge",
|
||||
});
|
||||
const followUpResult = await createAutomatedFollowup(store, {
|
||||
kind: "verification-failure",
|
||||
parentTaskId: taskId,
|
||||
signature: err instanceof VerificationError ? buildVerificationFailureSignature(err) : undefined,
|
||||
createInput: {
|
||||
description: followUpDescription,
|
||||
column: "triage",
|
||||
priority: "high",
|
||||
source: {
|
||||
sourceType: "recovery",
|
||||
sourceParentTaskId: taskId,
|
||||
},
|
||||
},
|
||||
auditor: verificationAuditor,
|
||||
});
|
||||
if (followUpResult.outcome === "deduped") {
|
||||
await store.addTaskComment(
|
||||
taskId,
|
||||
`Auto-merge giving up after ${nextBounces} verification-failure bounces. Reusing existing follow-up ${followUpResult.existingTaskId}.`,
|
||||
"agent",
|
||||
);
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Auto-merge gave up after ${nextBounces} verification-failure bounces — skipped creating duplicate follow-up (existing ${followUpResult.existingTaskId})`,
|
||||
"VerificationError",
|
||||
);
|
||||
runtimeLog.warn(
|
||||
`Auto-merge: ${taskId} hit verification-failure cap (${nextBounces}/${cap}) — skipped duplicate follow-up (existing ${followUpResult.existingTaskId})`,
|
||||
);
|
||||
} else {
|
||||
await store.addTaskComment(
|
||||
taskId,
|
||||
`Auto-merge giving up after ${nextBounces} verification-failure bounces. Created follow-up ${followUpResult.task.id} to investigate.`,
|
||||
"agent",
|
||||
);
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Auto-merge gave up after ${nextBounces} verification-failure bounces — created follow-up ${followUpResult.task.id}`,
|
||||
"VerificationError",
|
||||
);
|
||||
runtimeLog.warn(
|
||||
`Auto-merge: ${taskId} hit verification-failure cap (${nextBounces}/${cap}) — failed task and created follow-up ${followUpResult.task.id}`,
|
||||
);
|
||||
}
|
||||
} catch (followUpErr) {
|
||||
`Auto-merge giving up after ${nextBounces} verification-failure bounces. ` +
|
||||
`Review the most recent [verification] log entries on this task for the failing command and output, ` +
|
||||
`then either fix the underlying issue or quarantine the flake.`,
|
||||
"agent",
|
||||
);
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Auto-merge gave up after ${nextBounces} verification-failure bounces — task parked for human intervention`,
|
||||
"VerificationError",
|
||||
);
|
||||
runtimeLog.warn(
|
||||
`Auto-merge: ${taskId} hit verification-failure cap (${nextBounces}/${cap}) — failed task and parked for human intervention`,
|
||||
);
|
||||
} catch (parkErr) {
|
||||
runtimeLog.error(
|
||||
`Auto-merge: failed to fail-and-followup ${taskId} after verification cap: ${followUpErr instanceof Error ? followUpErr.message : String(followUpErr)}`,
|
||||
`Auto-merge: failed to park ${taskId} after verification cap: ${parkErr instanceof Error ? parkErr.message : String(parkErr)}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
@@ -4518,8 +4436,17 @@ export class ProjectEngine {
|
||||
// New behavior: bounce the task back to in-progress so the
|
||||
// executor can rebase against the latest main and retry. Cap
|
||||
// bounces at MAX_MERGE_CONFLICT_BOUNCES — past that, park in
|
||||
// in-review with status=failed and create a follow-up task so
|
||||
// a human can resolve the conflict manually.
|
||||
// in-review with status=failed so a human can resolve the
|
||||
// conflict manually.
|
||||
//
|
||||
/*
|
||||
FNXC:AutoMergeLifecycle 2026-07-26-00:00:
|
||||
The park used to also file an automated recovery follow-up card (only when we capped
|
||||
on bounces, not when autoResolveConflicts was merely off). That machinery was deleted
|
||||
because the card restated facts already on this task: the `error`, the operator
|
||||
comment naming the branch to resolve, and the MergeConflictGiveUp log entry all carry
|
||||
the branch, the reason, and the last merge error. The park itself is the surface now.
|
||||
*/
|
||||
const previousBounces = taskOnErr.mergeConflictBounceCount ?? 0;
|
||||
const nextBounces = previousBounces + 1;
|
||||
const bounceCap = ProjectEngine.MAX_MERGE_CONFLICT_BOUNCES;
|
||||
@@ -4548,66 +4475,6 @@ export class ProjectEngine {
|
||||
`Auto-merge gave up after conflict retries exhausted (${reason}); task parked for human intervention`,
|
||||
"MergeConflictGiveUp",
|
||||
);
|
||||
if (!autoResolveDisabled) {
|
||||
// Create a follow-up only when we capped on bounces; if
|
||||
// auto-resolve is just disabled, the user is presumed to
|
||||
// be handling merges manually and a follow-up is noise.
|
||||
try {
|
||||
const followUpResult = await createAutomatedFollowup(store, {
|
||||
kind: "merge-conflict",
|
||||
parentTaskId: taskId,
|
||||
branch: taskOnErr.branch,
|
||||
signature: computeVerificationFailureSignature({
|
||||
lane: "merge-conflict",
|
||||
failingTestFiles: [],
|
||||
}).signature,
|
||||
createInput: {
|
||||
description:
|
||||
`Resolve auto-merge conflict on ${taskId} (${taskOnErr.title || "untitled"}). ` +
|
||||
`Auto-merge attempted to rebase + resolve ${nextBounces - 1} times against main and exhausted retries each pass. ` +
|
||||
`Branch: \`${taskOnErr.branch ?? "?"}\`. Worktree: \`${taskOnErr.worktree ?? "?"}\`. ` +
|
||||
`Last merge error: ${errorMsg}`,
|
||||
column: "triage",
|
||||
priority: "high",
|
||||
source: {
|
||||
sourceType: "recovery",
|
||||
sourceParentTaskId: taskId,
|
||||
},
|
||||
},
|
||||
auditor: createRunAuditor(store, {
|
||||
runId: generateSyntheticRunId("auto-merge", taskId),
|
||||
agentId: "auto-merge",
|
||||
taskId,
|
||||
phase: "merge",
|
||||
}),
|
||||
});
|
||||
if (followUpResult.outcome === "deduped") {
|
||||
await store.addTaskComment(
|
||||
taskId,
|
||||
`Auto-merge recovery follow-up already exists (${followUpResult.existingTaskId}). Skipping duplicate follow-up creation.`,
|
||||
"agent",
|
||||
);
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Auto-merge conflict recovery skipped duplicate follow-up (existing ${followUpResult.existingTaskId})`,
|
||||
"MergeConflictGiveUp",
|
||||
);
|
||||
runtimeLog.warn(
|
||||
`Auto-merge: ${taskId} conflict give-up skipped duplicate follow-up (existing ${followUpResult.existingTaskId})`,
|
||||
);
|
||||
} else {
|
||||
await store.addTaskComment(
|
||||
taskId,
|
||||
`Created follow-up ${followUpResult.task.id} to track manual conflict resolution.`,
|
||||
"agent",
|
||||
);
|
||||
}
|
||||
} catch (followUpErr) {
|
||||
runtimeLog.warn(
|
||||
`Auto-merge: failed to create follow-up for ${taskId}: ${followUpErr instanceof Error ? followUpErr.message : String(followUpErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (recoveryErr) {
|
||||
runtimeLog.error(
|
||||
`Auto-merge: failed to park ${taskId} after conflict-bounce cap: ${recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr)}`,
|
||||
@@ -4920,39 +4787,56 @@ export class ProjectEngine {
|
||||
if (!parentTaskId) continue;
|
||||
try {
|
||||
const sourcePhase = record.sourcePhase ?? "unknown";
|
||||
const followUpResult = await createAutomatedFollowup(store, {
|
||||
kind: "autostash-orphan",
|
||||
parentTaskId,
|
||||
signature: computeVerificationFailureSignature({
|
||||
lane: "autostash-orphan",
|
||||
failingTestFiles: [],
|
||||
}).signature,
|
||||
createInput: {
|
||||
description:
|
||||
`Investigate preserved merger autostash leftover from ${parentTaskId} (${record.sha.slice(0, 7)}). ` +
|
||||
`Detected by ${record.detectedByTaskId ?? "merge sweep"} during ${sourcePhase}; ` +
|
||||
`stash label: ${record.label}. Recover from stash-recovery before dropping.`,
|
||||
source: {
|
||||
sourceType: "recovery",
|
||||
sourceParentTaskId: parentTaskId,
|
||||
},
|
||||
},
|
||||
auditor: createRunAuditor(store, {
|
||||
runId: generateSyntheticRunId("auto-merge", parentTaskId),
|
||||
agentId: "auto-merge",
|
||||
taskId: parentTaskId,
|
||||
phase: "merge",
|
||||
}),
|
||||
});
|
||||
const shortSha = record.sha.slice(0, 7);
|
||||
const detectedBy = record.detectedByTaskId ?? "merge sweep";
|
||||
|
||||
/*
|
||||
FNXC:AutostashRecovery 2026-07-26-00:00:
|
||||
A `live`-classified autostash orphan is a merger stash that still holds REAL UNCOMMITTED
|
||||
WORK stranded by a merge pass. This used to file an automated recovery follow-up card via
|
||||
the shared follow-up engine; that engine was deleted, but unlike the verification-cap and
|
||||
merge-conflict paths this site has NO parked parent to carry the notice — the parent task
|
||||
may already be `done` and merged, so if we say nothing here the stash becomes invisible and
|
||||
the work is silently lost. So the card is replaced by a durable log entry AND an operator
|
||||
comment on the parent, both of which must keep every fact the old description carried:
|
||||
the sha, the detecting task, the source phase, and above all `record.label` — that stash
|
||||
label is the handle `git stash` recovery needs, so it must never be dropped from the
|
||||
message or truncated. A comment (not only a log entry) because the parent may be closed and
|
||||
the log is not what an operator reads on a done card.
|
||||
*/
|
||||
await store.logEntry(
|
||||
parentTaskId,
|
||||
followUpResult.outcome === "deduped"
|
||||
? `Auto-detected live autostash orphan ${record.sha.slice(0, 7)} — reused follow-up ${followUpResult.existingTaskId}`
|
||||
: `Auto-created recovery follow-up ${followUpResult.task.id} for live autostash orphan ${record.sha.slice(0, 7)}`,
|
||||
`Auto-detected live autostash orphan ${shortSha} holding uncommitted work — preserved for manual recovery (stash label: ${record.label})`,
|
||||
`detectedBy=${record.detectedByTaskId ?? "unknown"}; phase=${sourcePhase}; stash=${record.label}`,
|
||||
).catch(() => undefined);
|
||||
|
||||
await store.addTaskComment(
|
||||
parentTaskId,
|
||||
`Preserved merger autostash leftover from this task (${shortSha}) still holds uncommitted work. ` +
|
||||
`Detected by ${detectedBy} during ${sourcePhase}. ` +
|
||||
`Stash label: \`${record.label}\` — recover it via stash-recovery before dropping the stash.`,
|
||||
"agent",
|
||||
).catch(() => undefined);
|
||||
|
||||
const auditor = createRunAuditor(store, {
|
||||
runId: generateSyntheticRunId("auto-merge", parentTaskId),
|
||||
agentId: "auto-merge",
|
||||
taskId: parentTaskId,
|
||||
phase: "merge",
|
||||
});
|
||||
await auditor.database({
|
||||
type: "task:autostash-orphan-live-detected",
|
||||
target: parentTaskId,
|
||||
metadata: {
|
||||
taskId: parentTaskId,
|
||||
sha: record.sha,
|
||||
stashLabel: record.label,
|
||||
detectedByTaskId: record.detectedByTaskId ?? null,
|
||||
sourcePhase,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
} catch (err: unknown) {
|
||||
runtimeLog.warn(`Autostash orphan recovery follow-up failed for ${parentTaskId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
runtimeLog.warn(`Autostash orphan recovery notice failed for ${parentTaskId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 } */
|
||||
|
||||
@@ -892,8 +892,6 @@ export class SelfHealingManager {
|
||||
private strandedHoldContinuationNoActionAudited = new Set<string>();
|
||||
/* FNXC:SymbolLock 2026-07-30-14:20: idle symbol-lock sweeps emit one no-action audit until a stale lock re-arms the diagnostic. */
|
||||
private symbolLockNoActionAudited = false;
|
||||
private metaResolvedSkipAuditMemo = new Map<string, string>();
|
||||
private metaStalledSkipAuditMemo = new Map<string, string>();
|
||||
private preservedQueuedOverlapLogged = new Map<string, string>();
|
||||
private maintenanceTickCounter = 0;
|
||||
private readonly processBootStartedAt = Date.now();
|
||||
@@ -1667,8 +1665,6 @@ export class SelfHealingManager {
|
||||
|
||||
this.finalizeUnprovenWarned.clear();
|
||||
this.strandedCompletedFailureProvenanceWarned.clear();
|
||||
this.metaResolvedSkipAuditMemo.clear();
|
||||
this.metaStalledSkipAuditMemo.clear();
|
||||
this.preservedQueuedOverlapLogged.clear();
|
||||
log.debug("Stopped");
|
||||
}
|
||||
@@ -2964,8 +2960,17 @@ export class SelfHealingManager {
|
||||
{ name: "reconcile-soft-delete-column-drift", fn: () => this.reconcileSoftDeletedColumnDrift() },
|
||||
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy() },
|
||||
{ name: "auto-rebound-paused-scope-decay", fn: () => this.autoReboundPausedScopeDecay() },
|
||||
{ name: "auto-archive-meta-resolved", fn: () => this.autoArchiveResolvedMetaTasks() },
|
||||
{ name: "auto-archive-meta-stalled", fn: () => this.autoArchiveStalledMetaTasks() },
|
||||
/*
|
||||
* FNXC:SelfHealing 2026-07-26-16:40:
|
||||
* There is deliberately NO meta-task auto-archive sweep here. The removed FN-4890/FN-5064
|
||||
* sweeps ("auto-archive-meta-resolved"/"auto-archive-meta-stalled") decided a card was a
|
||||
* "meta-task" by regex over title+description (`/\b(recover|unblock|finalize|meta)\b/i`),
|
||||
* so an ordinary feature card such as "Unblock queued dispatch" qualified; the target
|
||||
* resolver then bound it to an unrelated card by creation order and self-healing archived
|
||||
* live work. No guard set can make a title regex a safe basis for destructive archival, so
|
||||
* the feature is deleted rather than tuned. Do not reintroduce a heuristic meta-task
|
||||
* classifier — meta/parent relationships must be explicit task fields if ever needed again.
|
||||
*/
|
||||
{ name: "board-stall-auto-recovery", fn: () => this.runBoardStallAutoRecoverySweep() },
|
||||
// #1401: periodically recover transitionPending markers stranded by a
|
||||
// crash between the in-txn write and the post-commit clear (flag-ON
|
||||
@@ -5397,140 +5402,10 @@ export class SelfHealingManager {
|
||||
return { count: reboundedIds.length, reboundedIds };
|
||||
}
|
||||
|
||||
private classifyMetaTask(task: Task): { isMeta: boolean; targetTaskId: string | null } {
|
||||
const title = task.title ?? "";
|
||||
const description = task.description ?? "";
|
||||
const targetTaskId = task.sourceParentTaskId ?? title.match(/\bFN-\d+\b/i)?.[0] ?? description.match(/\bFN-\d+\b/i)?.[0] ?? null;
|
||||
const isMeta = Boolean(task.noCommitsExpected) || /\b(recover|unblock|finalize|meta)\b/i.test(`${title} ${description}`);
|
||||
return { isMeta, targetTaskId: targetTaskId?.toUpperCase() ?? null };
|
||||
}
|
||||
|
||||
private resolveMetaTargetTaskId(byId: Map<string, Task>, task: Task): string | null {
|
||||
const classified = this.classifyMetaTask(task);
|
||||
if (classified.targetTaskId) return classified.targetTaskId;
|
||||
if (!classified.isMeta) return null;
|
||||
|
||||
const ordered = [...byId.values()].sort((a, b) => {
|
||||
const aTime = Date.parse(a.createdAt ?? "");
|
||||
const bTime = Date.parse(b.createdAt ?? "");
|
||||
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return aTime - bTime;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
const metaTasks = ordered.filter((candidate) => this.classifyMetaTask(candidate).isMeta);
|
||||
const nonMetaTasks = ordered.filter((candidate) => !this.classifyMetaTask(candidate).isMeta);
|
||||
const currentIndex = metaTasks.findIndex((candidate) => candidate.id === task.id);
|
||||
const previousMeta = currentIndex > 0 ? metaTasks[currentIndex - 1] : null;
|
||||
const firstNonMeta = nonMetaTasks[0] ?? null;
|
||||
const action = (task.title ?? "").trim().split(/\s+/, 1)[0]?.toLowerCase() ?? "";
|
||||
|
||||
if (action === "recover" || action === "unblock") {
|
||||
return previousMeta?.id ?? firstNonMeta?.id ?? null;
|
||||
}
|
||||
if (action === "finalize") {
|
||||
return firstNonMeta?.id ?? previousMeta?.id ?? null;
|
||||
}
|
||||
return previousMeta?.id ?? firstNonMeta?.id ?? null;
|
||||
}
|
||||
|
||||
private computeMetaChainDepth(byId: Map<string, Task>, targetTaskId: string): number {
|
||||
let depth = 0;
|
||||
const visited = new Set<string>();
|
||||
let currentId: string | null = targetTaskId.toUpperCase();
|
||||
while (currentId && !visited.has(currentId)) {
|
||||
visited.add(currentId);
|
||||
const task = byId.get(currentId);
|
||||
if (!task) break;
|
||||
if (!this.classifyMetaTask(task).isMeta) break;
|
||||
const nextTargetId = this.resolveMetaTargetTaskId(byId, task);
|
||||
if (!nextTargetId) break;
|
||||
depth += 1;
|
||||
currentId = nextTargetId.toUpperCase();
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
private async archiveMetaTask(taskId: string): Promise<void> {
|
||||
const task = await this.store.getTask(taskId);
|
||||
if (!task || task.column === "archived") return;
|
||||
if (task.column === "triage" || task.column === "todo") {
|
||||
await this.store.moveTask(taskId, "in-progress", { moveSource: "engine" });
|
||||
}
|
||||
const progressed = await this.store.getTask(taskId);
|
||||
if (progressed && progressed.column === "in-progress") {
|
||||
await this.store.moveTask(taskId, "done", { moveSource: "engine", skipMergeBlocker: true });
|
||||
}
|
||||
if (typeof this.store.archiveTaskAndCleanup === "function") {
|
||||
await this.store.archiveTaskAndCleanup(taskId);
|
||||
return;
|
||||
}
|
||||
if (typeof this.store.archiveTask === "function") {
|
||||
await this.store.archiveTask(taskId, true);
|
||||
}
|
||||
}
|
||||
|
||||
private countBlockedDepth(tasks: Task[]): number {
|
||||
return tasks.filter((task) => typeof task.blockedBy === "string" && task.blockedBy.trim().length > 0).length;
|
||||
}
|
||||
|
||||
private async evaluateMetaAutoArchiveGuards(task: Task): Promise<{ block: false } | { block: true; reasons: string[] }> {
|
||||
const reasons: string[] = [];
|
||||
|
||||
try {
|
||||
const ahead = await isBranchAheadOfBase(task, this.options.rootDir, task.baseBranch ?? task.mergeDetails?.mergeTargetBranch ?? await resolveIntegrationBranch(this.options.rootDir, undefined));
|
||||
if (ahead && ahead.aheadCount > 0) reasons.push("branch-has-unique-commits");
|
||||
} catch (err: unknown) {
|
||||
log.warn(`Meta auto-archive branch probe failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
const settings = await this.store.getSettings();
|
||||
const graceMs = Number(settings.metaTaskActiveExecutionGraceMs ?? 30 * 60_000);
|
||||
if (graceMs > 0) {
|
||||
const now = Date.now();
|
||||
const activityMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt ?? task.updatedAt ?? "");
|
||||
const ageMs = now - activityMs;
|
||||
const columnMovedAtMs = Date.parse(task.columnMovedAt ?? "");
|
||||
const executionStartedAtMs = Date.parse(task.executionStartedAt ?? "");
|
||||
const transitionedRecentlyFromInProgress =
|
||||
task.column !== "in-progress" &&
|
||||
Number.isFinite(columnMovedAtMs) &&
|
||||
Number.isFinite(executionStartedAtMs) &&
|
||||
columnMovedAtMs >= executionStartedAtMs &&
|
||||
now - columnMovedAtMs < graceMs;
|
||||
const activeOrRecentlyInProgress = task.column === "in-progress" || transitionedRecentlyFromInProgress;
|
||||
if (Number.isFinite(ageMs) && ageMs < graceMs && activeOrRecentlyInProgress) {
|
||||
reasons.push("recent-executor-activity");
|
||||
}
|
||||
}
|
||||
|
||||
if ((task.taskDoneRetryCount ?? 0) > 0) reasons.push("task-done-retry-pending");
|
||||
|
||||
if (task.mergeDetails?.commitSha || task.status === "merging" || task.status === "merging-pr" || task.status === "failed") {
|
||||
reasons.push("merge-in-progress");
|
||||
}
|
||||
|
||||
if (task.worktree && activeSessionRegistry.isPathActive(task.worktree)) reasons.push("active-session");
|
||||
|
||||
return reasons.length > 0 ? { block: true, reasons } : { block: false };
|
||||
}
|
||||
|
||||
private formatReasonSignature(reasons: string[]): string {
|
||||
return reasons.join("|");
|
||||
}
|
||||
|
||||
private shouldEmitReasonMemo(memo: Map<string, string>, taskId: string, reasons: string[]): boolean {
|
||||
const signature = this.formatReasonSignature(reasons);
|
||||
const previous = memo.get(taskId);
|
||||
if (previous === signature) {
|
||||
return false;
|
||||
}
|
||||
memo.set(taskId, signature);
|
||||
return true;
|
||||
}
|
||||
|
||||
private clearReasonMemo(memo: Map<string, string>, taskId: string): void {
|
||||
memo.delete(taskId);
|
||||
}
|
||||
|
||||
private shouldLogPreservedQueuedOverlap(taskId: string, overlapBlockedBy: string | null | undefined): overlapBlockedBy is string {
|
||||
if (!overlapBlockedBy) return false;
|
||||
const previous = this.preservedQueuedOverlapLogged.get(taskId);
|
||||
@@ -5543,109 +5418,6 @@ export class SelfHealingManager {
|
||||
this.preservedQueuedOverlapLogged.delete(taskId);
|
||||
}
|
||||
|
||||
async autoArchiveResolvedMetaTasks(reboundedTargets?: Set<string>): Promise<number> {
|
||||
const tasks = await this.store.listTasks({ slim: false, includeArchived: true });
|
||||
const byId = new Map(tasks.map((task) => [task.id.toUpperCase(), task]));
|
||||
let archived = 0;
|
||||
for (const task of tasks) {
|
||||
if (task.column === "archived") continue;
|
||||
const classified = this.classifyMetaTask(task);
|
||||
const targetTaskId = this.resolveMetaTargetTaskId(byId, task);
|
||||
if (!classified.isMeta || !targetTaskId) {
|
||||
this.clearReasonMemo(this.metaResolvedSkipAuditMemo, task.id);
|
||||
continue;
|
||||
}
|
||||
const chainDepth = this.computeMetaChainDepth(byId, targetTaskId);
|
||||
const target = byId.get(targetTaskId.toUpperCase());
|
||||
const resolved = Boolean(target && !this.classifyMetaTask(target).isMeta && (target.column === "done" || target.column === "archived" || target.column === "todo"));
|
||||
const rebounded = Boolean(reboundedTargets?.has(targetTaskId));
|
||||
if (!resolved && !rebounded && chainDepth < 2) {
|
||||
this.clearReasonMemo(this.metaResolvedSkipAuditMemo, task.id);
|
||||
continue;
|
||||
}
|
||||
const guardResult = await this.evaluateMetaAutoArchiveGuards(task);
|
||||
if (guardResult.block) {
|
||||
if (this.shouldEmitReasonMemo(this.metaResolvedSkipAuditMemo, task.id, guardResult.reasons)) {
|
||||
const auditor = createRunAuditor(this.store, { runId: generateSyntheticRunId("fn4890-meta", task.id), agentId: "self-healing", taskId: task.id, phase: "auto-archive-meta-resolved-skipped" });
|
||||
await auditor.database({
|
||||
type: "task:auto-archive-meta-resolved-skipped",
|
||||
target: task.id,
|
||||
metadata: { taskId: task.id, targetTaskId, targetColumn: target?.column ?? "unknown", chainDepth, blockedBy: guardResult.reasons },
|
||||
});
|
||||
}
|
||||
log.debug(`[self-healing] skipped meta-resolved auto-archive for ${task.id}: ${guardResult.reasons.join(",")}`);
|
||||
continue;
|
||||
}
|
||||
this.clearReasonMemo(this.metaResolvedSkipAuditMemo, task.id);
|
||||
try {
|
||||
await this.store.logEntry(task.id, `Auto-archived meta-task (FN-4890): target ${targetTaskId} resolved/superseded.`);
|
||||
await this.archiveMetaTask(task.id);
|
||||
const auditor = createRunAuditor(this.store, { runId: generateSyntheticRunId("fn4890-meta", task.id), agentId: "self-healing", taskId: task.id, phase: "auto-archive-meta-resolved" });
|
||||
await auditor.database({ type: "task:auto-archived-meta-resolved", target: task.id, metadata: { taskId: task.id, targetTaskId, targetColumn: target?.column ?? "unknown", chainDepth } });
|
||||
archived++;
|
||||
} catch (err: unknown) {
|
||||
log.error(`autoArchiveResolvedMetaTasks failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
return archived;
|
||||
}
|
||||
|
||||
async autoArchiveStalledMetaTasks(): Promise<number> {
|
||||
const settings = await this.store.getSettings();
|
||||
const thresholdMs = Number(settings.metaTaskStallAutoCloseMs ?? 2 * 60 * 60_000);
|
||||
if (thresholdMs === 0) return 0;
|
||||
const tasks = await this.store.listTasks({ slim: false, includeArchived: false });
|
||||
const byId = new Map(tasks.map((task) => [task.id.toUpperCase(), task]));
|
||||
let archived = 0;
|
||||
const now = Date.now();
|
||||
for (const task of tasks) {
|
||||
if (task.column === "archived") continue;
|
||||
const classified = this.classifyMetaTask(task);
|
||||
const targetTaskId = this.resolveMetaTargetTaskId(byId, task);
|
||||
if (!classified.isMeta || !targetTaskId) {
|
||||
this.clearReasonMemo(this.metaStalledSkipAuditMemo, task.id);
|
||||
continue;
|
||||
}
|
||||
const chainDepth = this.computeMetaChainDepth(byId, targetTaskId);
|
||||
const ageMs = now - Date.parse(task.columnMovedAt ?? task.updatedAt);
|
||||
if (chainDepth < 2 && (!Number.isFinite(ageMs) || ageMs < thresholdMs)) {
|
||||
this.clearReasonMemo(this.metaStalledSkipAuditMemo, task.id);
|
||||
continue;
|
||||
}
|
||||
const target = byId.get(targetTaskId.toUpperCase());
|
||||
const targetMovedAtMs = Date.parse(target?.columnMovedAt ?? target?.updatedAt ?? "");
|
||||
const targetStalled = !Number.isFinite(targetMovedAtMs) || (now - targetMovedAtMs >= thresholdMs);
|
||||
if (chainDepth < 2 && !targetStalled) {
|
||||
this.clearReasonMemo(this.metaStalledSkipAuditMemo, task.id);
|
||||
continue;
|
||||
}
|
||||
const guardResult = await this.evaluateMetaAutoArchiveGuards(task);
|
||||
if (guardResult.block) {
|
||||
if (this.shouldEmitReasonMemo(this.metaStalledSkipAuditMemo, task.id, guardResult.reasons)) {
|
||||
const auditor = createRunAuditor(this.store, { runId: generateSyntheticRunId("fn4890-meta", task.id), agentId: "self-healing", taskId: task.id, phase: "auto-archive-meta-stalled-skipped" });
|
||||
await auditor.database({
|
||||
type: "task:auto-archive-meta-stalled-skipped",
|
||||
target: task.id,
|
||||
metadata: { taskId: task.id, targetTaskId, chainDepth, stalledMs: Math.max(ageMs, 0), blockedBy: guardResult.reasons },
|
||||
});
|
||||
}
|
||||
log.debug(`[self-healing] skipped meta-stalled auto-archive for ${task.id}: ${guardResult.reasons.join(",")}`);
|
||||
continue;
|
||||
}
|
||||
this.clearReasonMemo(this.metaStalledSkipAuditMemo, task.id);
|
||||
try {
|
||||
await this.store.logEntry(task.id, `Auto-archived meta-task (FN-4890): superseded — not spawning further meta; rely on self-heal on target ${targetTaskId}`);
|
||||
await this.archiveMetaTask(task.id);
|
||||
const auditor = createRunAuditor(this.store, { runId: generateSyntheticRunId("fn4890-meta", task.id), agentId: "self-healing", taskId: task.id, phase: "auto-archive-meta-stalled" });
|
||||
await auditor.database({ type: "task:auto-archived-meta-stalled", target: task.id, metadata: { taskId: task.id, targetTaskId, chainDepth, stalledMs: Math.max(ageMs, 0) } });
|
||||
archived++;
|
||||
} catch (err: unknown) {
|
||||
log.error(`autoArchiveStalledMetaTasks failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
return archived;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1401: periodic transitionPending recovery sweep. Flag-ON only — when
|
||||
* `workflowColumns` is OFF the legacy path never writes markers, so there is
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
import type { Task, TaskCreateInput, TaskStore } from "@fusion/core";
|
||||
import { basename } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { runtimeLog } from "./logger.js";
|
||||
import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "./run-audit.js";
|
||||
|
||||
const RECURRENCE_LOG_TAG = "[verification recurrence]";
|
||||
const RECURRENCE_RATE_LIMIT_MS = 60 * 60 * 1000;
|
||||
const SUPERSEDES_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
const CLOSED_COLUMNS = new Set(["done", "archived"]);
|
||||
|
||||
export type VerificationFailureSignatureInput = {
|
||||
lane: string;
|
||||
failingTestFiles: string[];
|
||||
failedCommand?: string | null;
|
||||
};
|
||||
|
||||
export type AutomatedFollowupKind =
|
||||
| "verification-failure"
|
||||
| "merge-conflict"
|
||||
| "autostash-orphan"
|
||||
| "eval"
|
||||
| "pr-comment"
|
||||
| "scope-leak"
|
||||
| "contamination";
|
||||
|
||||
export type FollowupDedupDecision =
|
||||
| { action: "create-new"; supersedesTaskId?: string }
|
||||
| { action: "append-log"; existingTaskId: string; rateLimited: boolean };
|
||||
|
||||
export function computeVerificationFailureSignature(input: VerificationFailureSignatureInput): {
|
||||
signature: string;
|
||||
failingBasenames: string[];
|
||||
lane: string;
|
||||
} {
|
||||
const lane = input.lane.trim();
|
||||
const failingBasenames = [...new Set(input.failingTestFiles.map((file) => basename(file.trim())).filter(Boolean))].sort();
|
||||
const signatureSource = failingBasenames.length > 0
|
||||
? JSON.stringify({ lane, files: failingBasenames })
|
||||
: `${lane}|no-files`;
|
||||
const signature = createHash("sha256").update(signatureSource).digest("hex");
|
||||
return { signature, failingBasenames, lane };
|
||||
}
|
||||
|
||||
export function extractFailingTestFiles(stdout: string, stderr: string): string[] {
|
||||
const text = `${stdout}\n${stderr}`;
|
||||
const files = new Set<string>();
|
||||
const patterns = [
|
||||
/^FAIL\s+(.+?)(?::\d+(?::\d+)?)?$/gm,
|
||||
/^[\u00D7\u2716]\s+(.+?)(?::\d+(?::\d+)?)?$/gm,
|
||||
/^Error in\s+(.+?)(?::\d+(?::\d+)?)?$/gm,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const candidate = normalizeMatchedPath(match[1]);
|
||||
if (candidate) files.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return [...files].sort();
|
||||
}
|
||||
|
||||
function normalizeMatchedPath(input: string | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
const firstToken = trimmed.split(/\s+/)[0] ?? "";
|
||||
const withoutDecorators = firstToken
|
||||
.replace(/^\(+/, "")
|
||||
.replace(/\)+$/, "")
|
||||
.replace(/^['"`]/, "")
|
||||
.replace(/['"`:,;]+$/, "");
|
||||
if (!withoutDecorators || !/[\\/]|\.[cm]?[jt]sx?$/.test(withoutDecorators)) {
|
||||
return null;
|
||||
}
|
||||
return basename(withoutDecorators);
|
||||
}
|
||||
|
||||
function metadataMatches(
|
||||
task: Task,
|
||||
parentTaskId: string,
|
||||
extraMatchKeys: Record<string, string | number> | undefined,
|
||||
): boolean {
|
||||
if (!extraMatchKeys || Object.keys(extraMatchKeys).length === 0) return false;
|
||||
if (task.sourceParentTaskId !== parentTaskId) return false;
|
||||
const metadata = task.sourceMetadata;
|
||||
if (!metadata) return false;
|
||||
return Object.entries(extraMatchKeys).every(([key, value]) => metadata[key] === value);
|
||||
}
|
||||
|
||||
function getVerificationSignature(task: Task): string | undefined {
|
||||
const signature = task.sourceMetadata?.verificationFailureSignature;
|
||||
return typeof signature === "string" && signature.trim().length > 0 ? signature : undefined;
|
||||
}
|
||||
|
||||
function buildDefaultAuditor(store: TaskStore, parentTaskId: string): RunAuditor {
|
||||
return createRunAuditor(store, {
|
||||
runId: generateSyntheticRunId("followup-dedup", parentTaskId),
|
||||
agentId: "automated-followup",
|
||||
taskId: parentTaskId,
|
||||
phase: "followup-dedup",
|
||||
});
|
||||
}
|
||||
|
||||
async function computeRateLimited(store: TaskStore, taskId: string, now: number): Promise<boolean> {
|
||||
const fullTask = await store.getTask(taskId);
|
||||
for (let index = fullTask.log.length - 1; index >= 0; index -= 1) {
|
||||
const entry = fullTask.log[index];
|
||||
if (!entry?.action?.startsWith(RECURRENCE_LOG_TAG)) continue;
|
||||
const entryMs = Date.parse(entry.timestamp);
|
||||
if (Number.isNaN(entryMs)) return false;
|
||||
return entryMs > now - RECURRENCE_RATE_LIMIT_MS;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function decideAutomatedFollowup(
|
||||
store: TaskStore,
|
||||
params: {
|
||||
kind: AutomatedFollowupKind;
|
||||
parentTaskId: string;
|
||||
signature?: string;
|
||||
branch?: string | null;
|
||||
extraMatchKeys?: Record<string, string | number>;
|
||||
now?: number;
|
||||
},
|
||||
): Promise<FollowupDedupDecision> {
|
||||
const now = params.now ?? Date.now();
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: true });
|
||||
const candidateTasks = tasks.filter((task) => task.id !== params.parentTaskId);
|
||||
const openTasks = candidateTasks.filter((task) => !CLOSED_COLUMNS.has(task.column));
|
||||
|
||||
const signatureMatch = params.signature
|
||||
? openTasks.find((task) => getVerificationSignature(task) === params.signature)
|
||||
: undefined;
|
||||
if (signatureMatch) {
|
||||
return {
|
||||
action: "append-log",
|
||||
existingTaskId: signatureMatch.id,
|
||||
rateLimited: await computeRateLimited(store, signatureMatch.id, now),
|
||||
};
|
||||
}
|
||||
|
||||
const extraMatch = openTasks.find((task) => metadataMatches(task, params.parentTaskId, params.extraMatchKeys));
|
||||
if (extraMatch) {
|
||||
return {
|
||||
action: "append-log",
|
||||
existingTaskId: extraMatch.id,
|
||||
rateLimited: await computeRateLimited(store, extraMatch.id, now),
|
||||
};
|
||||
}
|
||||
|
||||
const legacyParentMatch = openTasks.find(
|
||||
(task) => task.sourceType === "recovery" && task.sourceParentTaskId === params.parentTaskId,
|
||||
);
|
||||
if (legacyParentMatch) {
|
||||
return {
|
||||
action: "append-log",
|
||||
existingTaskId: legacyParentMatch.id,
|
||||
rateLimited: await computeRateLimited(store, legacyParentMatch.id, now),
|
||||
};
|
||||
}
|
||||
|
||||
if (params.branch) {
|
||||
const branchMatch = openTasks.find((task) => task.branch === params.branch);
|
||||
if (branchMatch) {
|
||||
return {
|
||||
action: "append-log",
|
||||
existingTaskId: branchMatch.id,
|
||||
rateLimited: await computeRateLimited(store, branchMatch.id, now),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (params.signature) {
|
||||
const recentClosedMatch = candidateTasks.find((task) => {
|
||||
if (!CLOSED_COLUMNS.has(task.column)) return false;
|
||||
if (getVerificationSignature(task) !== params.signature) return false;
|
||||
const closedMs = Date.parse(task.updatedAt || task.createdAt);
|
||||
return !Number.isNaN(closedMs) && closedMs > now - SUPERSEDES_WINDOW_MS;
|
||||
});
|
||||
if (recentClosedMatch) {
|
||||
return { action: "create-new", supersedesTaskId: recentClosedMatch.id };
|
||||
}
|
||||
}
|
||||
|
||||
return { action: "create-new" };
|
||||
}
|
||||
|
||||
export async function createAutomatedFollowup(
|
||||
store: TaskStore,
|
||||
params: {
|
||||
kind: AutomatedFollowupKind;
|
||||
parentTaskId: string;
|
||||
signature?: string;
|
||||
branch?: string | null;
|
||||
extraMatchKeys?: Record<string, string | number>;
|
||||
createInput: TaskCreateInput;
|
||||
auditor?: RunAuditor;
|
||||
},
|
||||
): Promise<
|
||||
| { outcome: "deduped"; existingTaskId: string; rateLimited: boolean }
|
||||
| { outcome: "created"; task: Awaited<ReturnType<TaskStore["createTask"]>>; supersedesTaskId?: string }
|
||||
> {
|
||||
const auditor = params.auditor ?? buildDefaultAuditor(store, params.parentTaskId);
|
||||
try {
|
||||
const decision = await decideAutomatedFollowup(store, {
|
||||
kind: params.kind,
|
||||
parentTaskId: params.parentTaskId,
|
||||
signature: params.signature,
|
||||
branch: params.branch,
|
||||
extraMatchKeys: params.extraMatchKeys,
|
||||
});
|
||||
|
||||
if (decision.action === "append-log") {
|
||||
if (!decision.rateLimited) {
|
||||
await store.logEntry(
|
||||
decision.existingTaskId,
|
||||
`${RECURRENCE_LOG_TAG} signature=${params.signature ?? "none"}`,
|
||||
`kind=${params.kind}; parentTaskId=${params.parentTaskId}`,
|
||||
);
|
||||
}
|
||||
await auditor.database({
|
||||
type: "verification:followup-deduped",
|
||||
target: decision.existingTaskId,
|
||||
metadata: {
|
||||
kind: params.kind,
|
||||
parentTaskId: params.parentTaskId,
|
||||
existingTaskId: decision.existingTaskId,
|
||||
signature: params.signature,
|
||||
rateLimited: decision.rateLimited,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
return {
|
||||
outcome: "deduped",
|
||||
existingTaskId: decision.existingTaskId,
|
||||
rateLimited: decision.rateLimited,
|
||||
};
|
||||
}
|
||||
|
||||
const source = params.createInput.source
|
||||
? {
|
||||
...params.createInput.source,
|
||||
sourceMetadata: {
|
||||
...(params.createInput.source.sourceMetadata ?? {}),
|
||||
...(params.signature ? { verificationFailureSignature: params.signature } : {}),
|
||||
...(decision.supersedesTaskId ? { supersedesTaskId: decision.supersedesTaskId } : {}),
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const task = await store.createTask({
|
||||
...params.createInput,
|
||||
source,
|
||||
});
|
||||
|
||||
await auditor.database({
|
||||
type: "verification:followup-created",
|
||||
target: task.id,
|
||||
metadata: {
|
||||
kind: params.kind,
|
||||
parentTaskId: params.parentTaskId,
|
||||
newTaskId: task.id,
|
||||
signature: params.signature,
|
||||
supersedesTaskId: decision.supersedesTaskId,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
|
||||
return {
|
||||
outcome: "created",
|
||||
task,
|
||||
...(decision.supersedesTaskId ? { supersedesTaskId: decision.supersedesTaskId } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
runtimeLog.warn(
|
||||
`Automated follow-up dedup failed open for ${params.parentTaskId} (${params.kind}): ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
const task = await store.createTask(params.createInput);
|
||||
return { outcome: "created", task };
|
||||
}
|
||||
}
|
||||
|
||||
export const __testing__ = {
|
||||
normalizeMatchedPath,
|
||||
RECURRENCE_LOG_TAG,
|
||||
RECURRENCE_RATE_LIMIT_MS,
|
||||
SUPERSEDES_WINDOW_MS,
|
||||
};
|
||||
Reference in New Issue
Block a user