fix(engine): self-healing lane cluster round 2 — 38 → 26 (two sweeps could disturb live work) (#3078)

The largest census cluster became **unclaimed again** when #3055 closed
conflicting. I had closed my own #3050 an hour earlier expecting #3055
to land, so this re-applies the conversions #3047 and #3049 did not
cover.

**Re-applied from current main rather than rebasing the closed branch.**
The conversions are small; the conflict archaeology is what went wrong
last time — nine conflicts against #3049, several on variable names
identical to mine, and my mechanical fixup corrupted the file badly
enough that I aborted. Starting from main cost less than resolving that
and carries no risk of resurrecting a stale line.

## Census

| | before | after |
|---|---|---|
| `packages/engine/src/self-healing.ts` | **38** | **26** |
| repo-wide column guards | 84 | **72** |

## Three sweeps, existing role helpers only

| sweep | roles | what it did on a renamed board |
|---|---|---|
| worktree metadata | terminal + wip + review | rebound finished cards
every pass, **and the FN-5256 liveness guard went silent** |
| orphaned pending step results | wip | **could rewrite `pending`
results under a live executor run** |
| agent-link drift | wip + review + terminal | evaluated agents whose
task was plainly still executing |

Two of these disturb **live** work, which is why they were worth redoing
now rather than leaving for the next fleet round:

- The worktree-metadata sweep clears `worktree`/`branch` metadata. Its
liveness guard is the thing standing between that and a running shell
(FN-5256). Keyed on ids, it matched nothing on a renamed board. The
scope-override safety condition beside it now reads the **same resolved
sets**, so the two cannot disagree about which lanes are live —
previously they were two independent literal lists.
- The orphaned-step-results sweep's own header says it must never touch
an executor-owned row. The id-keyed skip made it do exactly that.
Resolved once per sweep, outside the paging loop, so a large board still
pays one resolve.

## Flagged, not guessed — the 26 that remain

Unchanged from my earlier audit and re-verified on this base:

- **Sync predicates** (`isWorkspaceOwnerLive`, the pause-abort
classifier, the phantom-binding check, the `task:moved` listener
guards). No store handle; converting means a signature change or making
a synchronous event listener async, which reorders handlers against a
synchronous emitter.
- **Already-converted fallbacks** — `own.length > 0 ? own.includes(...)
: task.column === "in-review"`. The resolved answer wins; the literal is
the documented no-metadata path.
- **The notification-route `fresh.column === "todo"` sites** — measured
previously: any `await` before the wedge resolve drops an operator
notification. Needs the wedge-episode contract, not a column pass.

## Verification

self-healing suites **204 passed** · agent-link-drift +
query-filter-blindness **83 passed** · `pnpm test:gate` 161 + 13 + 487 +
71 · lint · census `--strict` · lane-wiring — green.
This commit is contained in:
gsxdsm
2026-07-31 03:48:44 -07:00
committed by GitHub
parent 58791fac88
commit 3c531d984c
3 changed files with 41 additions and 9 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Three more self-healing repairs use your board's own column names, including one that could disturb a running task.
category: fix
dev: Converts the worktree-metadata reconcile, orphaned-pending-step-results, and agent-link-drift sweeps in `self-healing.ts` to `resolveProjectColumnsForRoles`.

View File

@@ -5359,9 +5359,19 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
}
let repaired = 0;
/*
FNXC:WorkflowResolvedColumns 2026-07-31-16:40 (fleet, self-healing round 2):
TERMINAL/WIP/REVIEW roles for this sweep's three lane questions. Keyed on ids the terminal skip
never fired on a renamed board (finished cards were rebound every pass) and — the dangerous half —
the FN-5256 liveness guard below went silent, so the sweep could clear worktree metadata out from
under a running shell.
*/
const worktreeReconcileTerminalColumns = await resolveProjectColumnsForRoles(this.store, TERMINAL_ROLES);
const worktreeReconcileWipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]);
const worktreeReconcileReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES);
for (const task of allTasks) {
if (!task.worktree) continue;
if (!options?.includeTaskIds?.has(task.id) && (task.column === "done" || task.column === "archived")) {
if (!options?.includeTaskIds?.has(task.id) && worktreeReconcileTerminalColumns.has(task.column)) {
continue;
}
@@ -5398,8 +5408,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const scopeOverrideMergeActiveSafe =
task.scopeOverride === true
&& task.column !== "in-progress"
&& (task.column !== "in-review" || (typeof task.status === "string" && RECONCILE_SCOPE_OVERRIDE_MERGE_ACTIVE_STATUS_SET.has(task.status)));
/* Same resolved sets as the liveness guard below, so the two cannot disagree about live lanes. */
&& !worktreeReconcileWipColumns.has(task.column)
&& (!worktreeReconcileReviewColumns.has(task.column) || (typeof task.status === "string" && RECONCILE_SCOPE_OVERRIDE_MERGE_ACTIVE_STATUS_SET.has(task.status)));
if (scopeOverrideMergeActiveSafe) {
/*
FNXC:MissingWorktreeRecovery 2026-07-10-18:23:
@@ -5427,7 +5438,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
// live task's worktree looks stale here (and we couldn't rebind to a live
// fusion/<id>), the executor's own recovery paths will detect and recreate
// it. Clearing here yanks the worktree from a still-running shell.
if (task.column === "in-progress" || task.column === "in-review") {
if (worktreeReconcileWipColumns.has(task.column) || worktreeReconcileReviewColumns.has(task.column)) {
await this.emitWorktreeMetadataAuditEvent({
taskId: task.id,
mutationType: "task:auto-recover-worktree-metadata-skipped-active",
@@ -7397,6 +7408,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
async reconcileOrphanedPendingStepResults(): Promise<number> {
try {
/* Resolved once per sweep, outside the paging loop, so a large board still pays one resolve. */
const orphanedPendingWipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]);
const pageSize = 500;
let offset = 0;
let recovered = 0;
@@ -7416,15 +7429,20 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
for (const task of tasks) {
// An operator park is authoritative; this sweep must not reach through it.
if (task.userPaused === true) continue;
// Executor-owned rows: resume is deferred at startup, liveness unprovable here.
if (task.column === "in-progress") continue;
/*
FNXC:WorkflowResolvedColumns 2026-07-31-16:45 (fleet, self-healing round 2):
The executor-owned skip is a WIP-ROLE question. Keyed on the id it stopped firing on a renamed
board, so this sweep would rewrite `pending` step results out from under a LIVE executor run —
the one thing its own header says it must never do.
*/
if (orphanedPendingWipColumns.has(task.column)) continue;
if (!task.workflowStepResults?.some((result) => result.status === "pending")) continue;
if (isSessionLive(task.id)) continue;
// Re-read the live row before mutating: the page snapshot can be stale against
// a merger/planner that wrote a fresh pending lease after the page was fetched.
const fresh = await this.store.getTask(task.id);
if (!fresh || fresh.userPaused === true || fresh.column === "in-progress") continue;
if (!fresh || fresh.userPaused === true || orphanedPendingWipColumns.has(fresh.column)) continue;
/*
FNXC:WorkflowReviewGates 2026-07-26-15:50:
Honor a LIVE review-gate lease, not just in-process session liveness.
@@ -12988,6 +13006,11 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const now = Date.now();
const recoveredAgentIds = new Set<string>();
const runningAgents = await agentStore.listAgents({ state: "running", includeEphemeral: true });
const agentLinkTerminalColumns = await resolveProjectColumnsForRoles(this.store, TERMINAL_ROLES);
const agentLinkLiveColumns = new Set<string>([
...await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]),
...await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES),
]);
for (const agent of runningAgents) {
if (isEphemeralAgent(agent) || !agent.taskId) {
@@ -12995,7 +13018,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
}
const linkedTask = await this.store.getTask(agent.taskId);
if (linkedTask && (linkedTask.column === "in-progress" || linkedTask.column === "in-review" || linkedTask.column === "done" || linkedTask.column === "archived")) {
/* FNXC:WorkflowResolvedColumns 2026-07-31-16:50 (fleet, round 2): WIP u REVIEW u TERMINAL. Keyed on
ids none matched on a renamed board, so this sweep evaluated agents whose task was still executing. */
if (linkedTask && (agentLinkLiveColumns.has(linkedTask.column) || agentLinkTerminalColumns.has(linkedTask.column))) {
continue;
}

View File

@@ -1,7 +1,7 @@
{
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
"byFile": {
"packages/engine/src/self-healing.ts": 38,
"packages/engine/src/self-healing.ts": 26,
"packages/engine/src/notification/notification-service.ts": 5,
"packages/engine/src/executor.ts": 4,
"packages/core/src/task-store/moves.ts": 2,