FN-5944: stop redundant run-audit re-emission
Deduplicate unchanged scheduler and self-healing audit emissions while preserving transition visibility. - suppress repeated queued-concurrency audits until the limiting gate signature changes or clears - suppress repeated overlap-priority inversion audits until the blocker changes or overlap returns - suppress repeated meta auto-archive skip and integrity-warning audits until their persisted reasons change - update reliability tests and architecture/settings docs for the new transition-only audit cadence Files changed: docs/architecture.md | 12 +-- docs/settings-reference.md | 2 +- .../integrity-warning-persisted-dedup.test.ts | 12 ++- .../scheduler-overlap-priority-inversion.test.ts | 60 +++++++++++++- packages/engine/src/__tests__/scheduler.test.ts | 13 ++- .../self-healing-meta-archive-guards.test.ts | 45 +++++++++++ packages/engine/src/scheduler.ts | 59 +++++++++++--- packages/engine/src/self-healing.ts | 94 +++++++++++++++++----- 8 files changed, 243 insertions(+), 54 deletions(-) Fusion-Task-Id: FN-5944 Fusion-Task-Lineage: f8fe6adf-1501-49b7-b04c-ff38376957f0
This commit is contained in:
@@ -1059,7 +1059,7 @@ Mesh configuration and post-provision managed-node operations are registered sep
|
||||
|
||||
### Run Audit API
|
||||
The run-audit system records every mutation performed by the engine across four domains:
|
||||
- **Database** — task:create, task:update, task:move, etc. Node handoff/recovery emits structured events: `node:handoff:parked` (handoff denied/parked), `node:handoff:reassign-local` (local takeover approved), `node:handoff:reassign-any` (any-healthy takeover approved), and `node:lease:recovered` (abandoned lease cleared and task requeued). Scheduler dispatch contention also emits `scheduler:dispatch-queued-concurrency` (debounced per task+reason): metadata includes `bindingGates` (`maxConcurrent`/`maxWorktrees`/`semaphore`), per-gate `{ used, limit, slack }`, `holders`, and computed `available`.
|
||||
- **Database** — task:create, task:update, task:move, etc. Node handoff/recovery emits structured events: `node:handoff:parked` (handoff denied/parked), `node:handoff:reassign-local` (local takeover approved), `node:handoff:reassign-any` (any-healthy takeover approved), and `node:lease:recovered` (abandoned lease cleared and task requeued). Scheduler dispatch contention also emits `scheduler:dispatch-queued-concurrency` with transition-only cadence: emit once when a task first hits a stable limiter signature (`bindingGates`), suppress while unchanged, clear the memo when the task leaves that queued-concurrency state, and emit again only after the limiter signature changes or reappears. Metadata includes `bindingGates` (`maxConcurrent`/`maxWorktrees`/`semaphore`), per-gate `{ used, limit, slack }`, `holders`, and computed `available`.
|
||||
- **Git** — worktree:create, commit:create, merge:resolve, merge:audit-failure, `worktree:reanchored`, and worktrunk lifecycle events (`worktree:worktrunk-install|create|sync|prune|remove`, plus `worktree:worktrunk-fallback`, `worktree:worktrunk-failure`, and `worktree:worktrunk-fallback-native`). Worktrunk events share metadata `{ op, binaryPath?, worktreePath?, durationMs?, exitCode?, stderrPreview?, installSource?, prunedCount? }` with `installSource` (`"release-binary" | "cargo"`) limited to successful `worktree:worktrunk-install` events and `prunedCount` limited to successful prune events when known. `worktree:worktrunk-install` is emitted only for true install actions; cache hits, configured `worktrunk.binaryPath` overrides, and `$PATH` resolutions intentionally remain silent. Dirty post-merge audit outcomes emit `merge:audit-failure` with metadata `{ mode, strategy, action, reason, issueCount, duplicateSubjectCount, touchedFileOverlapCount, verificationPassed, auditTargetLabel }`. FN-5279 adds `merge:reuse-handoff-acquired`, `merge:reuse-handoff-refused`, `merge:reuse-handoff-released`, and `merge:reuse-handoff-deferred-to-worktrunk` for task-worktree auto-merge handoff visibility. FN-5351 adds `merge:integration-worktree-state` (pre-handoff checkout/dirty snapshot for resolved integration branch), `merge:cwd-integration-fallback-refused` (terminal refusal park event), and `merge:integration-ref-advance` (integration ref advance outcome telemetry).
|
||||
- **Git / `merge:file-scope-violation`** — emitted by the merger when `FileScopeViolationError` aborts a squash. `target` is the task ID; metadata includes `stagedFiles`, `declaredScope`, `resetLabel`, `stagedFileCount`, and `declaredScopeCount`. Consumed by `fileScopeInvariantFailuresPerDay` in `GET /api/health/reliability` (FN-4360).
|
||||
- **Git / `merge:no-op-attribution-mismatch`** — emitted by the rebase landed-files attribution guard (FN-5304) when `<rebaseBaseSha>..HEAD` has zero attributable own commits but the source `fusion/<id>` tip still carries attributable own commits. `target` is the task ID; metadata includes `recordedSha`, `rebaseMergeBaseSha`, `sourceBranchRef`, `sourceBranchOwnCommitCount`, and `sourceBranchOwnCommitShas`.
|
||||
@@ -1074,7 +1074,7 @@ The run-audit system records every mutation performed by the engine across four
|
||||
|
||||
Events are tied to specific run IDs for end-to-end traceability.
|
||||
|
||||
For scheduler concurrency diagnostics, the queued reason now names the active limiter(s) and usage (for example `gate=maxConcurrent ...`). Read `metadata.bindingGates` first to identify the limiter. `holders.maxConcurrent` and `holders.maxWorktrees` are current `in-progress` task IDs; `holders.semaphore` mirrors that set but semaphore slots can also be consumed by triage/merge agents outside `in-progress`. So if `semaphore.used` exceeds the visible holder list, that usually indicates non-execution agents are legitimately consuming shared capacity (not stale accounting). Identical task+reason states are deduped; a newly emitted line/event indicates limiter identity or usage changed.
|
||||
For scheduler concurrency diagnostics, the queued reason now names the active limiter(s) and usage (for example `gate=maxConcurrent ...`). Read `metadata.bindingGates` first to identify the limiter. `holders.maxConcurrent` and `holders.maxWorktrees` are current `in-progress` task IDs; `holders.semaphore` mirrors that set but semaphore slots can also be consumed by triage/merge agents outside `in-progress`. So if `semaphore.used` exceeds the visible holder list, that usually indicates non-execution agents are legitimately consuming shared capacity (not stale accounting). These events are transition-only: a newly emitted line/event indicates the limiter signature changed or the condition cleared and later reappeared, not that a poll loop simply observed the same blocked state again.
|
||||
|
||||
**Run audit endpoints:**
|
||||
- `GET /api/agents/:id/runs/:runId/audit` — Returns audit trail for a specific agent run
|
||||
@@ -1588,7 +1588,7 @@ The GitHub tracking state listener now attaches to every registered project stor
|
||||
- Finalize-to-done now runs an ownership classifier with three outcomes: `owned-commit` (task trailer/subject commit proven landed on merge target), `proven-no-op` (zero-ahead branch plus start point reachable from target), and `unproven` (missing ownership evidence, including foreign start-point inheritance).
|
||||
- `owned-commit` and `proven-no-op` can finalize. `proven-no-op` explicitly reconciles metadata by clearing stale `task.modifiedFiles` and stamping `mergeDetails.noOpMerge=true` with `landedFiles: []`.
|
||||
- `unproven` no longer silently completes as done; merger/self-healing emit `task:finalize-unproven-blocked` audit events and auto-retry by requeuing to `todo` for a fresh execution pass.
|
||||
- Historical cleanup is additive: `reconcileDoneTaskIntegrity()` scans done tasks missing `mergeDetails.commitSha` but still carrying `modifiedFiles`, then either recovers owned commit metadata, clears no-op stale files, or emits `task:integrity-warning` without regressing done tasks back to review.
|
||||
- Historical cleanup is additive: `reconcileDoneTaskIntegrity()` scans done tasks missing `mergeDetails.commitSha` but still carrying `modifiedFiles`, then either recovers owned commit metadata, clears no-op stale files, or emits `task:integrity-warning` without regressing done tasks back to review. `task:integrity-warning` is transition-only on the persisted warning reason: first warning emits once, repeated sweeps with the same `mergeDetails.integrityWarning.reason` stay silent, and a new warning reason emits again.
|
||||
- This integrity gate complements FN-4646 landed-file capture (metadata truth source) and FN-4647 dashboard labeling (UI presentation); gate enforcement is in merger/self-healing, while display semantics remain UI-owned.
|
||||
|
||||
#### Autostash lifecycle
|
||||
@@ -1733,9 +1733,9 @@ This section preserves the detailed lifecycle/self-healing contracts that were f
|
||||
- **Worktree pool exclusivity (FN-4954)**: `WorktreePool.acquire(taskId)` / `release(path, taskId?)` track a `leased` map so every pooled path is either idle or leased, never both. Cross-task double-lease detection throws `PoolDoubleLeaseError` and emits `worktree:pool-double-lease-detected`; merger Step 8 now detaches HEAD and clears `task.worktree` / `task.branch` before releasing paths back to the pool.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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 and emits `scheduler:overlap-priority-inversion` once per (candidate, blocker, pass).
|
||||
- **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 and emits `scheduler:overlap-priority-inversion` on state transition only: once per `(candidate, blocker)` pairing while blocked, silent on repeated polls with the same blocker, cleared when the overlap condition resolves, and emitted again if a different blocker takes over or the same blocker reappears later.
|
||||
- **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.
|
||||
- **In-review branch-binding self-heal (FN-5083)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/<id>` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`.
|
||||
- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run.
|
||||
@@ -1785,7 +1785,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in
|
||||
- FN-5743 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-cancel-on-hard-cancel.test.ts` and `packages/core/src/__tests__/merge-request-record.test.ts` guard Phase-3 cutover invariants: transient merge retries mutate merge-request state (no column rebound), user hard-cancel after accepted handoff cancels pending merge requests, and non-user rebounds preserve legacy fail-soft semantics.
|
||||
- FN-5337 backstop: `packages/engine/src/__tests__/reliability-interactions/orphan-detected-no-requeue.test.ts` locks observation-only orphan detection across FN-5279 repro metadata desync, worktree-present and worktree-missing candidates, FN-5219 ordering, FN-5147 in-review isolation, FN-5083 branch-cleared composition, lease-manager non-invocation, and per-sweep idempotent audit emission.
|
||||
- FN-5256 backstop: `packages/engine/src/__tests__/reliability-interactions/dependency-cycle-reconcile.test.ts` covers persisted dependency-cycle detection via `reconcileDependencyCycles`, bounded umbrella-back-edge auto-repair, ambiguous-cycle observe-only behavior, composition ordering with `reconcileSelfDefeatingDependencies`, and the post-sweep write-time guard invariant. Core write-boundary regressions (FN-5240/5241/5242 signature, indirect cycle, umbrella back-edge rejection) live in `packages/core/src/__tests__/store-dependency-cycle.test.ts`.
|
||||
- FN-5325 backstop: `packages/engine/src/__tests__/reliability-interactions/scheduler-overlap-priority-inversion.test.ts` covers queued-overlap priority/age deferral, equal-priority age ordering, FN-4969 fanout composition, and one-shot per-pass `scheduler:overlap-priority-inversion` audit surfacing against running lower-priority blockers.
|
||||
- FN-5325 backstop: `packages/engine/src/__tests__/reliability-interactions/scheduler-overlap-priority-inversion.test.ts` covers queued-overlap priority/age deferral, equal-priority age ordering, FN-4969 fanout composition, and transition-only `scheduler:overlap-priority-inversion` audit surfacing across unchanged, changed-blocker, and clear→reappear states.
|
||||
- FN-5223 backstop: `packages/engine/src/__tests__/reliability-interactions/engine-active-since-floor.test.ts` covers engine-activation floor + grace composition across startup, pause/unpause, global-pause gating, and StuckTaskDetector lifecycle interactions.
|
||||
|
||||
The auto-recovery dispatcher at `packages/engine/src/auto-recovery.ts` (FN-4533) composes on top of existing layers (FN-4500 fast-path, FN-4508 deterministic branch-conflict, FN-4499 bootstrap-misbinding, FN-4428 contamination, `mergeAuditAutoRecovery` Stages 1–5, self-healing) to handle six residual classes: file-scope violation at squash, branch misbinding / ghost worktree, verification-fix scope leak, contamination, `branch-conflict-unrecoverable` residuals, and room-post/message-send failures. Invocation is additive — no existing layer's behavior changes.
|
||||
|
||||
@@ -431,7 +431,7 @@ Default notes:
|
||||
| `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button (chat remains available via More menu). |
|
||||
| `chatAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-cleanup retention window for idle chat sessions and chat rooms. `0` is off (default). When enabled, periodic self-healing maintenance deletes rows with `updatedAt` older than the configured day window. |
|
||||
| `mailAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-prune retention window for inbox/outbox mail messages. `0` is off (default). When enabled, periodic self-healing maintenance deletes `messages` rows where `updatedAt < cutoff` for the configured day window. Suggested setting: `7`. |
|
||||
| `operationalLogRetentionDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `30` | Retention window for SQLite operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`), terminal `agentRuns` rows (by `endedAt`), and `agentConfigRevisions` (by `createdAt`). `0` is off. Lower values mean Reliability metrics/charts and the Activity feed will not show history older than the configured window; per-task task detail history is unaffected. Periodic maintenance prunes rows older than this many days while always preserving in-flight `agentRuns` (`endedAt IS NULL`) and the most-recent `agentConfigRevisions` row per agent. |
|
||||
| `operationalLogRetentionDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `30` | Retention window for SQLite operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`), terminal `agentRuns` rows (by `endedAt`), and `agentConfigRevisions` (by `createdAt`). `0` is off. Lower values mean Reliability metrics/charts and the Activity feed will not show history older than the configured window; per-task task detail history is unaffected. Periodic maintenance prunes timestamped operational-log rows older than this many days while always preserving in-flight `agentRuns` (`endedAt IS NULL`) and the most-recent `agentConfigRevisions` row per agent. |
|
||||
| `agentLogFileRetentionDays` | `number` | `0` | Retention window for per-task `.fusion/tasks/{ID}/agent-log.jsonl` files after a task is soft-deleted or archived. Periodic maintenance removes JSONL entries older than this many days; active tasks are never pruned. Set `0` to disable pruning. |
|
||||
| `chatRoomRecentVerbatimMessages` | `number` | `25` | Number of newest chat-room messages kept verbatim in responder context before older entries are compacted (about 2× prior default history). |
|
||||
| `chatRoomCompactionFetchLimit` | `number` | `200` | Upper bound on room messages fetched for transcript compaction per responder turn (raised to support larger retained context windows). |
|
||||
|
||||
@@ -112,7 +112,8 @@ describe("FN-4811 follow-up: integrity warning dedup persists across restarts",
|
||||
setupForeignOnlyRepo(dir, taskId);
|
||||
const task = makeUnprovenDoneTask(taskId);
|
||||
|
||||
const store = makeStore(task);
|
||||
const events: unknown[] = [];
|
||||
const store = makeStore(task, events);
|
||||
const manager = new SelfHealingManager(store, { rootDir: dir });
|
||||
|
||||
await manager.reconcileDoneTaskIntegrity();
|
||||
@@ -127,6 +128,7 @@ describe("FN-4811 follow-up: integrity warning dedup persists across restarts",
|
||||
expect(task.mergeDetails?.integrityWarning).toBeDefined();
|
||||
expect(task.mergeDetails?.integrityWarning?.reason).toBe("no-owned-commit-foreign-deltas");
|
||||
expect(typeof task.mergeDetails?.integrityWarning?.warnedAt).toBe("string");
|
||||
expect(events.filter((event: any) => event?.mutationType === "task:integrity-warning")).toHaveLength(1);
|
||||
|
||||
manager.stop();
|
||||
} finally {
|
||||
@@ -141,7 +143,8 @@ describe("FN-4811 follow-up: integrity warning dedup persists across restarts",
|
||||
setupForeignOnlyRepo(dir, taskId);
|
||||
const task = makeUnprovenDoneTask(taskId);
|
||||
|
||||
const store = makeStore(task);
|
||||
const events: unknown[] = [];
|
||||
const store = makeStore(task, events);
|
||||
const manager = new SelfHealingManager(store, { rootDir: dir });
|
||||
|
||||
await manager.reconcileDoneTaskIntegrity();
|
||||
@@ -153,6 +156,7 @@ describe("FN-4811 follow-up: integrity warning dedup persists across restarts",
|
||||
/Integrity warning: done-task finalize evidence is unproven/.test(String(c[1] ?? "")),
|
||||
);
|
||||
expect(reWarned).toBe(false);
|
||||
expect(events.filter((event: any) => event?.mutationType === "task:integrity-warning")).toHaveLength(1);
|
||||
|
||||
manager.stop();
|
||||
} finally {
|
||||
@@ -206,7 +210,8 @@ describe("FN-4811 follow-up: integrity warning dedup persists across restarts",
|
||||
},
|
||||
};
|
||||
|
||||
const store = makeStore(task);
|
||||
const events: unknown[] = [];
|
||||
const store = makeStore(task, events);
|
||||
const manager = new SelfHealingManager(store, { rootDir: dir });
|
||||
|
||||
await manager.reconcileDoneTaskIntegrity();
|
||||
@@ -218,6 +223,7 @@ describe("FN-4811 follow-up: integrity warning dedup persists across restarts",
|
||||
expect(warned).toBe(true);
|
||||
// Persisted record updated to the new reason.
|
||||
expect(task.mergeDetails?.integrityWarning?.reason).toBe("no-owned-commit-foreign-deltas");
|
||||
expect(events.filter((event: any) => event?.mutationType === "task:integrity-warning")).toHaveLength(1);
|
||||
|
||||
manager.stop();
|
||||
} finally {
|
||||
|
||||
@@ -129,9 +129,9 @@ describe("reliability interactions: FN-5325 scheduler overlap priority inversion
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-2", expect.objectContaining({ overlapBlockedBy: "FN-1" }));
|
||||
});
|
||||
|
||||
it("emits one inversion audit event per pass for running lower-priority blocker", async () => {
|
||||
it("emits one inversion audit row across repeated unchanged polls", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-1", column: "in-progress", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" }),
|
||||
makeTask({ id: "FN-1", column: "in-progress", priority: undefined, createdAt: "2026-01-01T00:01:00.000Z" }),
|
||||
makeTask({ id: "FN-2", priority: "urgent", status: "queued", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||
];
|
||||
const { store } = createStore(tasks, { "FN-1": ["src/a.ts"], "FN-2": ["src/a.ts"] });
|
||||
@@ -139,6 +139,8 @@ describe("reliability interactions: FN-5325 scheduler overlap priority inversion
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
await scheduler.schedule();
|
||||
await scheduler.schedule();
|
||||
|
||||
const calls = (store.recordRunAuditEvent as any).mock.calls.filter(
|
||||
(call: any[]) => call[0]?.mutationType === "scheduler:overlap-priority-inversion",
|
||||
@@ -150,9 +152,61 @@ describe("reliability interactions: FN-5325 scheduler overlap priority inversion
|
||||
candidateId: "FN-2",
|
||||
blockerId: "FN-1",
|
||||
candidatePriority: "urgent",
|
||||
blockerPriority: "normal",
|
||||
blockerPriority: null,
|
||||
blockerColumn: "in-progress",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("re-emits inversion when the blocker changes", async () => {
|
||||
const firstBlocker = makeTask({ id: "FN-1", column: "in-progress", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" });
|
||||
const secondBlocker = makeTask({ id: "FN-3", column: "todo", priority: "low", createdAt: "2026-01-01T00:02:00.000Z" });
|
||||
const candidate = makeTask({ id: "FN-2", priority: "urgent", status: "queued", createdAt: "2026-01-01T00:00:00.000Z" });
|
||||
const tasks = [firstBlocker, secondBlocker, candidate];
|
||||
const { store } = createStore(tasks, { "FN-1": ["src/a.ts"], "FN-2": ["src/a.ts"], "FN-3": ["src/a.ts"] });
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
firstBlocker.column = "done";
|
||||
secondBlocker.column = "in-progress";
|
||||
await scheduler.schedule();
|
||||
|
||||
const calls = (store.recordRunAuditEvent as any).mock.calls.filter(
|
||||
(call: any[]) => call[0]?.mutationType === "scheduler:overlap-priority-inversion",
|
||||
);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0][0]?.metadata?.blockerId).toBe("FN-1");
|
||||
expect(calls[1][0]?.metadata?.blockerId).toBe("FN-3");
|
||||
});
|
||||
|
||||
it("re-emits inversion after overlap clears and later returns", async () => {
|
||||
const blocker = makeTask({ id: "FN-1", column: "in-progress", priority: "normal", createdAt: "2026-01-01T00:01:00.000Z" });
|
||||
const candidate = makeTask({ id: "FN-2", priority: "urgent", status: "queued", createdAt: "2026-01-01T00:00:00.000Z" });
|
||||
const tasks = [blocker, candidate];
|
||||
const scopes: Record<string, string[]> = { "FN-1": ["src/a.ts"], "FN-2": ["src/a.ts"] };
|
||||
const { store } = createStore(tasks, scopes);
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
blocker.column = "done";
|
||||
candidate.overlapBlockedBy = undefined;
|
||||
scopes["FN-2"] = ["src/b.ts"];
|
||||
await scheduler.schedule();
|
||||
|
||||
candidate.column = "todo";
|
||||
candidate.status = "queued";
|
||||
blocker.column = "in-progress";
|
||||
scopes["FN-2"] = ["src/a.ts"];
|
||||
await scheduler.schedule();
|
||||
|
||||
const calls = (store.recordRunAuditEvent as any).mock.calls.filter(
|
||||
(call: any[]) => call[0]?.mutationType === "scheduler:overlap-priority-inversion",
|
||||
);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls.every((call: any[]) => call[0]?.metadata?.blockerId === "FN-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1599,7 +1599,7 @@ describe("Scheduler", () => {
|
||||
expect(auditCalls[0]?.[0]?.metadata?.semaphore).toEqual({ used: 1, limit: 2, slack: 1 });
|
||||
});
|
||||
|
||||
it("re-logs and re-audits when binding holder identity changes", async () => {
|
||||
it("suppresses re-log and re-audit when only binding holder identity changes", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
|
||||
|
||||
@@ -1629,16 +1629,14 @@ describe("Scheduler", () => {
|
||||
const concurrencyReasonCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
(call: unknown[]) => call[0] === "FN-D" && String(call[1]).includes("queued — concurrency limit reached"),
|
||||
);
|
||||
expect(concurrencyReasonCalls).toHaveLength(2);
|
||||
expect(concurrencyReasonCalls).toHaveLength(1);
|
||||
|
||||
const auditCalls = (store.recordRunAuditEvent as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
(call: unknown[]) => (call[0] as { mutationType?: string } | undefined)?.mutationType === "scheduler:dispatch-queued-concurrency",
|
||||
);
|
||||
expect(auditCalls).toHaveLength(2);
|
||||
expect(auditCalls).toHaveLength(1);
|
||||
expect(auditCalls[0]?.[0]?.metadata?.bindingGates).toEqual(["maxWorktrees"]);
|
||||
expect(auditCalls[1]?.[0]?.metadata?.bindingGates).toEqual(["maxWorktrees"]);
|
||||
expect(auditCalls[0]?.[0]?.metadata?.holders?.maxWorktrees).toEqual(["FN-A"]);
|
||||
expect(auditCalls[1]?.[0]?.metadata?.holders?.maxWorktrees).toEqual(["FN-B"]);
|
||||
});
|
||||
|
||||
it("re-logs and re-audits when binding gate changes", async () => {
|
||||
@@ -1686,7 +1684,7 @@ describe("Scheduler", () => {
|
||||
expect(auditCalls[1]?.[0]?.metadata?.bindingGates).toEqual(["maxWorktrees"]);
|
||||
});
|
||||
|
||||
it("formats queued-concurrency memo keys from binding gates and holder identity only", () => {
|
||||
it("formats queued-concurrency memo keys from binding gates only", () => {
|
||||
const key = formatConcurrencyLimitMemoKey({
|
||||
available: 0,
|
||||
bindingGates: ["maxConcurrent", "maxWorktrees"],
|
||||
@@ -1700,8 +1698,9 @@ describe("Scheduler", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(key).toBe("queued-concurrency:maxConcurrent,maxWorktrees:holders=FN-A,FN-B");
|
||||
expect(key).toBe("queued-concurrency:maxConcurrent,maxWorktrees");
|
||||
expect(key).not.toMatch(/used=|limit=|available=|\d+\/\d+/);
|
||||
expect(key).not.toContain("FN-A");
|
||||
expect(key).not.toContain("FN-Z");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,26 @@ describe("SelfHealingManager meta auto-archive guards", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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 = fixture.store.getRunAuditEvents({ limit: 200 }).filter((e) => e.mutationType === "task:auto-archive-meta-resolved-skipped");
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0]?.metadata as any)?.blockedBy).toEqual(expect.arrayContaining(["task-done-retry-pending"]));
|
||||
|
||||
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 0, status: "merging" } as any);
|
||||
await fixture.selfHeal.autoArchiveResolvedMetaTasks();
|
||||
events = fixture.store.getRunAuditEvents({ 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" },
|
||||
@@ -158,4 +178,29 @@ describe("SelfHealingManager meta auto-archive guards", () => {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("dedupes stalled skipped audits until the guard reason changes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-05-18T12:00:00.000Z");
|
||||
vi.setSystemTime(now);
|
||||
const { fixture, meta } = await createResolvedMetaPair({ metaTaskStallAutoCloseMs: 60_000 });
|
||||
await fixture.store.updateTask(meta.id, { taskDoneRetryCount: 1 } as any);
|
||||
vi.setSystemTime(new Date(now.getTime() + 2 * 60 * 60_000));
|
||||
try {
|
||||
await fixture.selfHeal.autoArchiveStalledMetaTasks();
|
||||
await fixture.selfHeal.autoArchiveStalledMetaTasks();
|
||||
let events = fixture.store.getRunAuditEvents({ 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 = fixture.store.getRunAuditEvents({ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -357,11 +357,7 @@ function formatConcurrencyLimitReason(diagnostic: ConcurrencyGateDiagnostic): st
|
||||
|
||||
export function formatConcurrencyLimitMemoKey(diagnostic: ConcurrencyGateDiagnostic): string {
|
||||
const gates = diagnostic.bindingGates.join(",");
|
||||
const bindingHolders = [...new Set(
|
||||
diagnostic.bindingGates.flatMap((gate) => diagnostic.holders[gate] ?? []),
|
||||
)].sort();
|
||||
const holderKey = bindingHolders.length > 0 ? bindingHolders.join(",") : "none";
|
||||
return `queued-concurrency:${gates}:holders=${holderKey}`;
|
||||
return `queued-concurrency:${gates || "none"}`;
|
||||
}
|
||||
|
||||
export interface SchedulerOptions {
|
||||
@@ -456,6 +452,10 @@ export class Scheduler {
|
||||
private wasPermanentAgentUnavailable = new Set<string>();
|
||||
/** Tracks dispatch-queued reason signatures to avoid per-tick log spam. */
|
||||
private wasDispatchQueuedReasonLogged = new Set<string>();
|
||||
/** Tracks the last overlap blocker that emitted a priority inversion audit for a task. */
|
||||
private overlapPriorityInversionMemo = new Map<string, string>();
|
||||
/** Tracks the last stable concurrency-block signature emitted for a task. */
|
||||
private dispatchQueuedConcurrencyAuditMemo = new Map<string, string>();
|
||||
/** Tracks per-task candidacy fingerprints for task:updated auto-claim invalidation gating. */
|
||||
private lastAutoClaimFingerprint = new Map<string, string>();
|
||||
private readonly staleTaskReporter: StaleTaskReporter;
|
||||
@@ -706,6 +706,8 @@ export class Scheduler {
|
||||
this.wasNodeBlocked.delete(task.id);
|
||||
this.wasPermanentAgentUnavailable.delete(task.id);
|
||||
this.clearDispatchQueuedReasonMemo(task.id);
|
||||
this.clearOverlapPriorityInversionMemo(task.id);
|
||||
this.clearDispatchQueuedConcurrencyAuditMemo(task.id);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -850,6 +852,8 @@ export class Scheduler {
|
||||
this.wasNodeDispatchValidationBlocked.clear();
|
||||
this.wasPermanentAgentUnavailable.clear();
|
||||
this.wasDispatchQueuedReasonLogged.clear();
|
||||
this.overlapPriorityInversionMemo.clear();
|
||||
this.dispatchQueuedConcurrencyAuditMemo.clear();
|
||||
schedulerLog.log("Stopped");
|
||||
}
|
||||
|
||||
@@ -868,11 +872,40 @@ export class Scheduler {
|
||||
}
|
||||
|
||||
this.clearDispatchQueuedReasonMemo(taskId);
|
||||
if (!key.includes(":queued-concurrency:")) {
|
||||
this.clearDispatchQueuedConcurrencyAuditMemo(taskId);
|
||||
}
|
||||
this.wasDispatchQueuedReasonLogged.add(key);
|
||||
await this.store.logEntry(taskId, reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
private shouldEmitOverlapPriorityInversion(taskId: string, blockerId: string): boolean {
|
||||
const lastBlockerId = this.overlapPriorityInversionMemo.get(taskId);
|
||||
if (lastBlockerId === blockerId) {
|
||||
return false;
|
||||
}
|
||||
this.overlapPriorityInversionMemo.set(taskId, blockerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private clearOverlapPriorityInversionMemo(taskId: string): void {
|
||||
this.overlapPriorityInversionMemo.delete(taskId);
|
||||
}
|
||||
|
||||
private shouldEmitDispatchQueuedConcurrencyAudit(taskId: string, signature: string): boolean {
|
||||
const lastSignature = this.dispatchQueuedConcurrencyAuditMemo.get(taskId);
|
||||
if (lastSignature === signature) {
|
||||
return false;
|
||||
}
|
||||
this.dispatchQueuedConcurrencyAuditMemo.set(taskId, signature);
|
||||
return true;
|
||||
}
|
||||
|
||||
private clearDispatchQueuedConcurrencyAuditMemo(taskId: string): void {
|
||||
this.dispatchQueuedConcurrencyAuditMemo.delete(taskId);
|
||||
}
|
||||
|
||||
private emitDependencyParityDiff(diff: SchedulingDependencyParityDiff): void {
|
||||
void this.store.recordRunAuditEvent?.({
|
||||
taskId: diff.taskId,
|
||||
@@ -1260,7 +1293,6 @@ export class Scheduler {
|
||||
activeScopes.set(taskId, scope);
|
||||
activeScopeColumns.set(taskId, column);
|
||||
};
|
||||
const inversionEmitted = new Set<string>();
|
||||
const queuedHigherPriorityScopes: QueuedOverlapCandidate[] = [];
|
||||
const queuedHigherPriorityTaskById = new Map<string, Task>();
|
||||
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
|
||||
@@ -1504,13 +1536,11 @@ export class Scheduler {
|
||||
}
|
||||
|
||||
const overlapBlockerTask = tasks.find((candidate) => candidate.id === overlappingTaskId);
|
||||
const inversionKey = `${task.id}|${overlappingTaskId}`;
|
||||
if (
|
||||
overlapBlockerTask
|
||||
&& !inversionEmitted.has(inversionKey)
|
||||
&& this.shouldEmitOverlapPriorityInversion(task.id, overlappingTaskId)
|
||||
&& compareTasksByPriorityThenAgeAndId(task, overlapBlockerTask) < 0
|
||||
) {
|
||||
inversionEmitted.add(inversionKey);
|
||||
try {
|
||||
await this.store.recordRunAuditEvent?.({
|
||||
taskId: task.id,
|
||||
@@ -1549,8 +1579,10 @@ export class Scheduler {
|
||||
if (task.overlapBlockedBy) {
|
||||
await this.store.updateTask(task.id, { overlapBlockedBy: null });
|
||||
}
|
||||
this.clearOverlapPriorityInversionMemo(task.id);
|
||||
} else if (coordinationOnlyTask && task.overlapBlockedBy) {
|
||||
await this.store.updateTask(task.id, { overlapBlockedBy: null });
|
||||
this.clearOverlapPriorityInversionMemo(task.id);
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
"coordination/no-commit task bypassed non-implementation overlap lease",
|
||||
@@ -1561,12 +1593,13 @@ export class Scheduler {
|
||||
// Dependencies met — check concurrency
|
||||
if (started >= available) {
|
||||
const reason = formatConcurrencyLimitReason(concurrencyGateDiagnostic);
|
||||
const didLog = await this.logDispatchQueuedReason(
|
||||
const concurrencySignature = formatConcurrencyLimitMemoKey(concurrencyGateDiagnostic);
|
||||
await this.logDispatchQueuedReason(
|
||||
task.id,
|
||||
reason,
|
||||
formatConcurrencyLimitMemoKey(concurrencyGateDiagnostic),
|
||||
concurrencySignature,
|
||||
);
|
||||
if (didLog) {
|
||||
if (this.shouldEmitDispatchQueuedConcurrencyAudit(task.id, concurrencySignature)) {
|
||||
await this.emitDispatchQueuedConcurrencyAudit(task, concurrencyGateDiagnostic);
|
||||
}
|
||||
continue;
|
||||
@@ -1816,6 +1849,8 @@ export class Scheduler {
|
||||
this.wasNodeDispatchValidationBlocked.delete(task.id);
|
||||
this.wasPermanentAgentUnavailable.delete(task.id);
|
||||
this.clearDispatchQueuedReasonMemo(task.id);
|
||||
this.clearOverlapPriorityInversionMemo(task.id);
|
||||
this.clearDispatchQueuedConcurrencyAuditMemo(task.id);
|
||||
await this.store.logEntry(task.id, `Node routing resolved: ${effectiveNode.nodeId ?? "local"} (source: ${effectiveNode.source})`);
|
||||
this.options.onSchedule?.(task);
|
||||
started++;
|
||||
|
||||
@@ -605,6 +605,8 @@ export class SelfHealingManager {
|
||||
private deadlockRecoveryCooldown: Map<string, number> = new Map();
|
||||
private mergeStarvationDrops: Map<string, number> = new Map();
|
||||
private finalizeUnprovenWarned = new Set<string>();
|
||||
private metaResolvedSkipAuditMemo = new Map<string, string>();
|
||||
private metaStalledSkipAuditMemo = new Map<string, string>();
|
||||
private maintenanceTickCounter = 0;
|
||||
private readonly processBootStartedAt = Date.now();
|
||||
private dependencyBlockedTodoReporter: DependencyBlockedTodoReporter | null = null;
|
||||
@@ -933,6 +935,9 @@ export class SelfHealingManager {
|
||||
this.maintenanceInterval = null;
|
||||
}
|
||||
|
||||
this.finalizeUnprovenWarned.clear();
|
||||
this.metaResolvedSkipAuditMemo.clear();
|
||||
this.metaStalledSkipAuditMemo.clear();
|
||||
log.log("Stopped");
|
||||
}
|
||||
|
||||
@@ -3760,6 +3765,24 @@ export class SelfHealingManager {
|
||||
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);
|
||||
}
|
||||
|
||||
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]));
|
||||
@@ -3768,23 +3791,32 @@ export class SelfHealingManager {
|
||||
if (task.column === "archived") continue;
|
||||
const classified = this.classifyMetaTask(task);
|
||||
const targetTaskId = this.resolveMetaTargetTaskId(byId, task);
|
||||
if (!classified.isMeta || !targetTaskId) continue;
|
||||
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) continue;
|
||||
if (!resolved && !rebounded && chainDepth < 2) {
|
||||
this.clearReasonMemo(this.metaResolvedSkipAuditMemo, task.id);
|
||||
continue;
|
||||
}
|
||||
const guardResult = await this.evaluateMetaAutoArchiveGuards(task);
|
||||
if (guardResult.block) {
|
||||
const auditor = createRunAuditor(this.store, { runId: generateSyntheticRunId("fn4890-meta", task.id), agentId: "self-healing", taskId: task.id, phase: "auto-archive-meta-resolved-skipped" });
|
||||
await auditor.database({
|
||||
type: "task:auto-archive-meta-resolved-skipped",
|
||||
target: task.id,
|
||||
metadata: { taskId: task.id, targetTaskId, targetColumn: target?.column ?? "unknown", chainDepth, blockedBy: guardResult.reasons },
|
||||
});
|
||||
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.log(`[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);
|
||||
@@ -3810,25 +3842,37 @@ export class SelfHealingManager {
|
||||
if (task.column === "archived") continue;
|
||||
const classified = this.classifyMetaTask(task);
|
||||
const targetTaskId = this.resolveMetaTargetTaskId(byId, task);
|
||||
if (!classified.isMeta || !targetTaskId) continue;
|
||||
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)) continue;
|
||||
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) continue;
|
||||
if (chainDepth < 2 && !targetStalled) {
|
||||
this.clearReasonMemo(this.metaStalledSkipAuditMemo, task.id);
|
||||
continue;
|
||||
}
|
||||
const guardResult = await this.evaluateMetaAutoArchiveGuards(task);
|
||||
if (guardResult.block) {
|
||||
const auditor = createRunAuditor(this.store, { runId: generateSyntheticRunId("fn4890-meta", task.id), agentId: "self-healing", taskId: task.id, phase: "auto-archive-meta-stalled-skipped" });
|
||||
await auditor.database({
|
||||
type: "task:auto-archive-meta-stalled-skipped",
|
||||
target: task.id,
|
||||
metadata: { taskId: task.id, targetTaskId, chainDepth, stalledMs: Math.max(ageMs, 0), blockedBy: guardResult.reasons },
|
||||
});
|
||||
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.log(`[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);
|
||||
@@ -4688,9 +4732,11 @@ export class SelfHealingManager {
|
||||
rootDir: this.options.rootDir,
|
||||
settings,
|
||||
});
|
||||
this.finalizeUnprovenWarned.delete(task.id);
|
||||
await this.store.updateTask(task.id, {
|
||||
mergeDetails: {
|
||||
...(task.mergeDetails || {}),
|
||||
integrityWarning: undefined,
|
||||
commitSha: classification.commit.sha,
|
||||
filesChanged: classification.commit.filesChanged,
|
||||
insertions: classification.commit.insertions,
|
||||
@@ -4707,10 +4753,12 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
if (classification.kind === "proven-no-op") {
|
||||
this.finalizeUnprovenWarned.delete(task.id);
|
||||
await this.store.updateTask(task.id, {
|
||||
modifiedFiles: [],
|
||||
mergeDetails: {
|
||||
...(task.mergeDetails || {}),
|
||||
integrityWarning: undefined,
|
||||
mergeConfirmed: true,
|
||||
noOpMerge: true,
|
||||
noOpReason: `branch has zero commits ahead of ${classification.baseRef}`,
|
||||
@@ -4726,10 +4774,12 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
if (classification.kind === "no-changes-finalized") {
|
||||
this.finalizeUnprovenWarned.delete(task.id);
|
||||
await this.store.updateTask(task.id, {
|
||||
modifiedFiles: [],
|
||||
mergeDetails: {
|
||||
...(task.mergeDetails || {}),
|
||||
integrityWarning: undefined,
|
||||
mergeConfirmed: true,
|
||||
noOpMerge: true,
|
||||
noOpReason: "verification-only finalize: no branch and no owned commits",
|
||||
@@ -4770,14 +4820,14 @@ export class SelfHealingManager {
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.recordIntegrityAudit(task.id, "task:integrity-warning", {
|
||||
reason: classification.reason,
|
||||
modifiedFilesCount: task.modifiedFiles?.length ?? 0,
|
||||
details: classification.details,
|
||||
});
|
||||
} else {
|
||||
this.finalizeUnprovenWarned.add(task.id);
|
||||
}
|
||||
await this.recordIntegrityAudit(task.id, "task:integrity-warning", {
|
||||
reason: classification.reason,
|
||||
modifiedFilesCount: task.modifiedFiles?.length ?? 0,
|
||||
details: classification.details,
|
||||
});
|
||||
}
|
||||
|
||||
return reconciled;
|
||||
|
||||
Reference in New Issue
Block a user