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:
7
.changeset/self-healing-cluster-round2.md
Normal file
7
.changeset/self-healing-cluster-round2.md
Normal 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`.
|
||||||
@@ -5359,9 +5359,19 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
}
|
}
|
||||||
let repaired = 0;
|
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) {
|
for (const task of allTasks) {
|
||||||
if (!task.worktree) continue;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5398,8 +5408,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
|
|
||||||
const scopeOverrideMergeActiveSafe =
|
const scopeOverrideMergeActiveSafe =
|
||||||
task.scopeOverride === true
|
task.scopeOverride === true
|
||||||
&& task.column !== "in-progress"
|
/* Same resolved sets as the liveness guard below, so the two cannot disagree about live lanes. */
|
||||||
&& (task.column !== "in-review" || (typeof task.status === "string" && RECONCILE_SCOPE_OVERRIDE_MERGE_ACTIVE_STATUS_SET.has(task.status)));
|
&& !worktreeReconcileWipColumns.has(task.column)
|
||||||
|
&& (!worktreeReconcileReviewColumns.has(task.column) || (typeof task.status === "string" && RECONCILE_SCOPE_OVERRIDE_MERGE_ACTIVE_STATUS_SET.has(task.status)));
|
||||||
if (scopeOverrideMergeActiveSafe) {
|
if (scopeOverrideMergeActiveSafe) {
|
||||||
/*
|
/*
|
||||||
FNXC:MissingWorktreeRecovery 2026-07-10-18:23:
|
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
|
// 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
|
// fusion/<id>), the executor's own recovery paths will detect and recreate
|
||||||
// it. Clearing here yanks the worktree from a still-running shell.
|
// 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({
|
await this.emitWorktreeMetadataAuditEvent({
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
mutationType: "task:auto-recover-worktree-metadata-skipped-active",
|
mutationType: "task:auto-recover-worktree-metadata-skipped-active",
|
||||||
@@ -7397,6 +7408,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
|
|
||||||
async reconcileOrphanedPendingStepResults(): Promise<number> {
|
async reconcileOrphanedPendingStepResults(): Promise<number> {
|
||||||
try {
|
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;
|
const pageSize = 500;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let recovered = 0;
|
let recovered = 0;
|
||||||
@@ -7416,15 +7429,20 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
// An operator park is authoritative; this sweep must not reach through it.
|
// An operator park is authoritative; this sweep must not reach through it.
|
||||||
if (task.userPaused === true) continue;
|
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 (!task.workflowStepResults?.some((result) => result.status === "pending")) continue;
|
||||||
if (isSessionLive(task.id)) continue;
|
if (isSessionLive(task.id)) continue;
|
||||||
|
|
||||||
// Re-read the live row before mutating: the page snapshot can be stale against
|
// 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.
|
// a merger/planner that wrote a fresh pending lease after the page was fetched.
|
||||||
const fresh = await this.store.getTask(task.id);
|
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:
|
FNXC:WorkflowReviewGates 2026-07-26-15:50:
|
||||||
Honor a LIVE review-gate lease, not just in-process session liveness.
|
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 now = Date.now();
|
||||||
const recoveredAgentIds = new Set<string>();
|
const recoveredAgentIds = new Set<string>();
|
||||||
const runningAgents = await agentStore.listAgents({ state: "running", includeEphemeral: true });
|
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) {
|
for (const agent of runningAgents) {
|
||||||
if (isEphemeralAgent(agent) || !agent.taskId) {
|
if (isEphemeralAgent(agent) || !agent.taskId) {
|
||||||
@@ -12995,7 +13018,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const linkedTask = await this.store.getTask(agent.taskId);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
|
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
|
||||||
"byFile": {
|
"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/notification/notification-service.ts": 5,
|
||||||
"packages/engine/src/executor.ts": 4,
|
"packages/engine/src/executor.ts": 4,
|
||||||
"packages/core/src/task-store/moves.ts": 2,
|
"packages/core/src/task-store/moves.ts": 2,
|
||||||
|
|||||||
Reference in New Issue
Block a user