fix: stop inactive-duplicate clear from replan-storming

Clearing a DUPLICATE marker for an inactive or kept canonical left
status:null without PROMPT.md. The scheduler treats planning→null as
"finished planning" and re-dispatched the card; FS validation rebounded
to needs-replan with no ceiling, and triage re-planned with empty
feedback so the planner could re-emit the same inactive marker forever
(FN-8704 / FN-8676).

Leave needs-replan + dismissal metadata + replan feedback instead, share
the clear contract across triage and self-healing, and bound scheduler
filesystem-validation rebounds with the shared recovery budget.
This commit is contained in:
gsxdsm
2026-08-01 11:53:53 -07:00
parent 60706ed5e4
commit e3fbd2ebf5
7 changed files with 247 additions and 48 deletions

View File

@@ -107,8 +107,18 @@ const canRun = hasGit && hasPg;
const updated = await fx.store.getTask(duplicate.id);
expect(updated.paused).not.toBe(true);
expect(updated.pausedReason ?? null).toBeNull();
expect(updated.status ?? null).toBeNull();
// needs-replan (not null) so the card cannot look planning-finished without PROMPT
expect(updated.status).toBe("needs-replan");
expect(updated.sourceMetadata).toEqual(expect.objectContaining({
nearDuplicateOf: canonicalId,
nearDuplicateDismissed: true,
duplicateSource: "triage-marker",
}));
expect(existsSync(promptPath)).toBe(false);
expect(updated.log?.some((entry) =>
entry.action === "Duplicate marker cleared for re-specification"
&& String(entry.outcome ?? "").includes(canonicalId),
)).toBe(true);
});
it.each([
@@ -146,7 +156,11 @@ const canRun = hasGit && hasPg;
const updated = await fx.store.getTask(duplicate.id);
expect(updated.paused).not.toBe(true);
expect(updated.pausedReason ?? null).toBeNull();
expect(updated.sourceMetadata).toEqual(expect.objectContaining({ nearDuplicateOf: canonical.id.toLowerCase(), nearDuplicateDismissed: true }));
expect(updated.status).toBe("needs-replan");
expect(updated.sourceMetadata).toEqual(expect.objectContaining({
nearDuplicateOf: canonical.id,
nearDuplicateDismissed: true,
}));
expect(existsSync(promptPath)).toBe(false);
});

View File

@@ -105,6 +105,8 @@ describe("FN-8356: reconcile stale duplicate-decision pauses", () => {
const recovered = await store.getTask(id);
expect(recovered?.paused).toBe(false);
expect(recovered?.pausedReason).toBeNull();
// needs-replan (not null) so the card cannot look planning-finished without a real PROMPT
expect(recovered?.status).toBe("needs-replan");
expect(recovered?.sourceMetadata?.nearDuplicateDismissed).toBe(true);
// TaskCard and NotificationService both key their decision affordance on this predicate.
expect(recovered?.pausedReason === "duplicate-decision-required").toBe(false);
@@ -137,6 +139,7 @@ describe("FN-8356: reconcile stale duplicate-decision pauses", () => {
const recovered = await store.getTask("FN-1");
expect(recovered?.paused).toBe(false);
expect(recovered?.pausedReason).toBeNull();
expect(recovered?.status).toBe("needs-replan");
});
/*

View File

@@ -94,7 +94,18 @@ describe("triage explicit duplicate marker short-circuit", () => {
});
await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n", { ...settings, triageDuplicateResolution: "keep" })).resolves.toBe(true);
expect(store.deleteTask).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: false, pausedReason: null, status: null }));
// Must leave needs-replan (not status:null) so the scheduler does not wake on a prompt-less card.
expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({
paused: false,
pausedReason: null,
status: "needs-replan",
sourceMetadataPatch: expect.objectContaining({ nearDuplicateOf: "FN-001", nearDuplicateDismissed: true }),
}));
expect(store.logEntry).toHaveBeenCalledWith(
"FN-002",
"Duplicate marker cleared for re-specification",
expect.stringContaining("FN-001"),
);
});
it("does not re-pause a same-canonical Keep acknowledgement after marker reprocessing", async () => {
@@ -113,7 +124,8 @@ describe("triage explicit duplicate marker short-circuit", () => {
expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({
paused: false,
pausedReason: null,
sourceMetadataPatch: { nearDuplicateDismissed: true },
status: "needs-replan",
sourceMetadataPatch: expect.objectContaining({ nearDuplicateOf: "FN-001", nearDuplicateDismissed: true }),
}));
expect(store.updateTask).not.toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: true }));
});
@@ -165,11 +177,22 @@ describe("triage explicit duplicate marker short-circuit", () => {
await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true);
expect(store.updateTask).toHaveBeenCalledWith("FN-002", {
// needs-replan + dismissal + feedback — never status:null (FN-8704 replan storm)
expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({
paused: false,
pausedReason: null,
status: null,
});
status: "needs-replan",
sourceMetadataPatch: expect.objectContaining({
nearDuplicateOf: "FN-001",
nearDuplicateDismissed: true,
duplicateSource: "triage-marker",
}),
}));
expect(store.logEntry).toHaveBeenCalledWith(
"FN-002",
"Duplicate marker cleared for re-specification",
expect.stringMatching(/FN-001.*do not re-emit/i),
);
expect(store.updateTask).not.toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: true }));
expect(store.deleteTask).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,47 @@
/**
* Shared contracts for clearing an explicit DUPLICATE: marker without a real plan.
*
* FNXC:NearDuplicateDetection 2026-08-01-18:47:
* Clearing a DUPLICATE marker must leave needs-replan + durable feedback +
* nearDuplicateDismissed — never status:null. status:null is the planning-finished
* signal the scheduler wakes on, so a prompt-less null-status card re-dispatches,
* FS-fails, and storms (observed on FN-8704 / inactive FN-8676).
*/
/** Log action picked up by triage's needs-replan feedback scanner. */
export const TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION = "Duplicate marker cleared for re-specification";
export function buildInactiveDuplicateClearFeedback(canonicalId: string): string {
return `Explicit duplicate marker targeting ${canonicalId} was cleared because that task is missing, deleted, done, or archived. Write a full PROMPT.md for this work. Do not re-emit DUPLICATE: ${canonicalId}.`;
}
export function buildKeepDuplicateClearFeedback(canonicalId: string): string {
return `Duplicate marker for ${canonicalId} was cleared (Keep / keep-acknowledged). Write a full PROMPT.md for this work. Do not re-emit DUPLICATE: ${canonicalId}.`;
}
/** Patch applied when a marker is cleared so the card is unplanned, not "planning finished". */
export function buildMarkerClearedReplanTaskPatch(canonicalId: string): {
paused: false;
pausedReason: null;
status: "needs-replan";
error: null;
sourceMetadataPatch: {
nearDuplicateOf: string;
nearDuplicateScore: number;
duplicateSource: "triage-marker";
nearDuplicateDismissed: true;
};
} {
return {
paused: false,
pausedReason: null,
status: "needs-replan",
error: null,
sourceMetadataPatch: {
nearDuplicateOf: canonicalId,
nearDuplicateScore: 1,
duplicateSource: "triage-marker",
nearDuplicateDismissed: true,
},
};
}

View File

@@ -48,6 +48,7 @@ import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } fr
import { moveTaskToReplanColumn } from "./replan-target.js";
import { evaluateParkedAgentTaskLink } from "./task-agent-sync.js";
import { decideMissionSymbolAdmission, resolveMissionFeatureForTask } from "./mission-symbol-admission.js";
import { computeRecoveryDecision, formatDelay, MAX_RECOVERY_RETRIES } from "./recovery-policy.js";
const SYMBOL_LOCK_LEASE_MS = 10 * 60_000;
@@ -2339,12 +2340,41 @@ export class Scheduler {
const validation = await this.validateTaskFilesystem(task.id);
if (!validation.valid) {
schedulerLog.warn(`Task ${task.id} filesystem validation failed: ${validation.reason}`);
// See the FNXC:WorkflowScheduling 2026-07-13-11:25 note in the legacy loop: the
// status write is what makes triage rediscover a card whose replan column equals
// its current column.
/*
FNXC:WorkflowScheduling 2026-08-01-18:47:
Missing/empty PROMPT used to rebound with unbounded needs-replan writes (FN-8704
storm twin). Share the planning recovery budget: backoff while attempts remain,
then park failed so a broken task dir cannot spin forever. The status write is
still what makes triage rediscover a card whose replan column equals its current column.
*/
const replanColumn = await moveTaskToReplanColumn(this.store, task);
await this.store.updateTask(task.id, { status: "needs-replan" });
await this.store.logEntry(task.id, `Task rebounded to ${replanColumn} for re-specification — filesystem validation failed`, validation.reason);
const decision = computeRecoveryDecision({
recoveryRetryCount: task.recoveryRetryCount,
nextRecoveryAt: task.nextRecoveryAt,
});
if (!decision.shouldRetry) {
const error = `REQUIRED_ARTIFACT_RECOVERY_EXHAUSTED: filesystem validation failed (${validation.reason}) after ${MAX_RECOVERY_RETRIES} automatic planning retries.`;
await this.store.updateTask(task.id, {
status: "failed",
error,
recoveryRetryCount: null,
nextRecoveryAt: null,
});
await this.store.logEntry(task.id, error, validation.reason);
return null;
}
const attempt = decision.nextState.recoveryRetryCount ?? MAX_RECOVERY_RETRIES;
await this.store.updateTask(task.id, {
status: "needs-replan",
error: null,
recoveryRetryCount: decision.nextState.recoveryRetryCount,
nextRecoveryAt: decision.nextState.nextRecoveryAt,
});
await this.store.logEntry(
task.id,
`Task rebounded to ${replanColumn} for re-specification — filesystem validation failed (attempt ${attempt}/${MAX_RECOVERY_RETRIES} in ${formatDelay(decision.delayMs)})`,
validation.reason,
);
return null;
}

View File

@@ -40,6 +40,12 @@ import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PR
import { finalizePlanningSegment } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger, schedulerLog } from "./logger.js";
import {
TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION,
buildInactiveDuplicateClearFeedback,
buildKeepDuplicateClearFeedback,
buildMarkerClearedReplanTaskPatch,
} from "./duplicate-marker-clear.js";
import { mergeEffectiveSettings } from "./effective-settings.js";
import { RemovalReason, classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, hasUsableWorktreeShape, isUsableTaskWorktree, relocateReclaimableWorktreeIntoRoot, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
import {
@@ -7064,12 +7070,31 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonical);
if (!isNearDuplicateCanonicalInactive(canonical ?? undefined, canonicalFlags)) continue;
await this.store.updateTask(task.id, {
paused: false,
pausedReason: null,
status: null,
sourceMetadataPatch: { nearDuplicateDismissed: true },
});
/*
FNXC:NearDuplicateDetection 2026-08-01-18:47:
Stale-decision recovery for an inactive canonical is the same writer as marker clear:
needs-replan (not status:null) plus dismissal so the card cannot look planning-finished
without a real PROMPT. Drop a still-present DUPLICATE marker file when present.
*/
const promptPath = join(this.options.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
if (existsSync(promptPath)) {
try {
const written = readFileSync(promptPath, "utf-8");
if (parseExplicitDuplicateMarker(written)) {
rmSync(promptPath, { force: true });
}
} catch {
// best-effort marker removal; status write still proceeds
}
}
await this.store.updateTask(task.id, buildMarkerClearedReplanTaskPatch(canonicalId));
if (typeof this.store.logEntry === "function") {
await Promise.resolve(this.store.logEntry(
task.id,
TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION,
buildInactiveDuplicateClearFeedback(canonicalId),
)).catch(() => {});
}
await createRunAuditor(this.store, {
runId: generateSyntheticRunId("reconcile-stale-duplicate-decision", task.id),
agentId: "self-healing",
@@ -14415,6 +14440,10 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
FN-8356 keeps maintenance from re-parking a marker against a missing, deleted, done,
or archived canonical. Such a decision has no detail-banner action, so cleanup restores
eligible work to planning while preserving explicit, implicit, and unrelated system pauses.
FNXC:NearDuplicateDetection 2026-08-01-18:47:
Mirror triage: marker clear leaves needs-replan + feedback + dismissal, never
status:null (FN-8704 replan storm when the scheduler wakes on planning→null without PROMPT).
*/
const canClearInactiveMarker = task.userPaused !== true
&& (task.paused !== true || task.pausedReason === "duplicate-decision-required")
@@ -14423,7 +14452,15 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
if (!canonicalTask || isNearDuplicateCanonicalInactive(canonicalTask, canonicalFlags)) {
if (canClearInactiveMarker) {
rmSync(promptPath, { force: true });
await this.store.updateTask(task.id, { paused: false, pausedReason: null, status: null });
const patch = buildMarkerClearedReplanTaskPatch(marker.canonicalId);
await this.store.updateTask(task.id, patch);
if (typeof this.store.logEntry === "function") {
await Promise.resolve(this.store.logEntry(
task.id,
TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION,
buildInactiveDuplicateClearFeedback(marker.canonicalId),
)).catch(() => {});
}
resolved += 1;
}
continue;
@@ -14439,12 +14476,14 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
if (resolution === "prompt" && isTriageDuplicateKeepAcknowledged(task.sourceMetadata, canonicalTask.id)) {
if (canClearInactiveMarker) {
rmSync(promptPath, { force: true });
await this.store.updateTask(task.id, {
paused: false,
pausedReason: null,
status: null,
sourceMetadataPatch: { nearDuplicateDismissed: true },
});
await this.store.updateTask(task.id, buildMarkerClearedReplanTaskPatch(canonicalTask.id));
if (typeof this.store.logEntry === "function") {
await Promise.resolve(this.store.logEntry(
task.id,
TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION,
buildKeepDuplicateClearFeedback(canonicalTask.id),
)).catch(() => {});
}
resolved += 1;
}
continue;
@@ -14456,7 +14495,14 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
await this.store.updateTask(task.id, { paused: true, pausedReason: "duplicate-decision-required", status: null });
} else {
rmSync(promptPath, { force: true });
await this.store.updateTask(task.id, { paused: false, pausedReason: null, status: null, sourceMetadataPatch: { nearDuplicateOf: canonicalTask.id, nearDuplicateScore: 1, duplicateSource: "triage-marker", nearDuplicateDismissed: true } });
await this.store.updateTask(task.id, buildMarkerClearedReplanTaskPatch(canonicalTask.id));
if (typeof this.store.logEntry === "function") {
await Promise.resolve(this.store.logEntry(
task.id,
TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION,
buildKeepDuplicateClearFeedback(canonicalTask.id),
)).catch(() => {});
}
}
log.log(`[self-healing] resolved explicit duplicate marker ${task.id} → ${canonicalTask.id}`);
resolved += 1;

View File

@@ -218,6 +218,12 @@ import {
} from "./tool-availability.js";
import { runGhostBugPreflight } from "./triage-preflight.js";
import { archiveAsGhostBug } from "./self-healing.js";
import {
TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION,
buildInactiveDuplicateClearFeedback,
buildKeepDuplicateClearFeedback,
buildMarkerClearedReplanTaskPatch,
} from "./duplicate-marker-clear.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import { resolveAndEmitGoalContext } from "./goal-injection-diagnostics.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
@@ -2907,6 +2913,7 @@ export class TriageProcessor {
|| entry.action === "User comment invalidated spec approval — task needs re-specification"
|| entry.action === "AI spec revision requested"
|| entry.action === TRIAGE_STUCK_RESUME_LOG_ACTION
|| entry.action === TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION
);
feedback = feedbackLogEntry?.outcome;
@@ -4016,6 +4023,36 @@ export class TriageProcessor {
return true;
}
/*
FNXC:NearDuplicateDetection 2026-08-01-18:47:
Shared writer for every "delete the DUPLICATE marker and ask planning for a real plan"
exit. Must leave status:needs-replan (not null), durable replan feedback, and
nearDuplicateDismissed so (a) the scheduler's planning→null wake does not re-dispatch
a prompt-less card, and (b) the next planner is told not to re-emit the same id.
Outcome stays parked (default) — no Plan Review handoff until a real plan is written.
*/
private async clearDuplicateMarkerForReplan(
task: Task,
canonicalId: string,
feedback: string,
): Promise<boolean> {
if (!await this.runIfStillPlanningUnderTaskLock(task, async () => {
await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true });
})) return false;
try {
await Promise.resolve(this.store.logEntry(task.id, TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION, feedback));
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
planLog.warn(`${task.id}: failed to log marker-clear replan feedback: ${msg}`);
}
return await this.updatePlanningStateIfStillCurrent(
task,
buildMarkerClearedReplanTaskPatch(canonicalId),
);
}
private async finalizeApprovedTaskBody(
task: Task,
writtenInput: string,
@@ -4053,14 +4090,20 @@ export class TriageProcessor {
view deliberately hides decisions for missing, deleted, done, or archived canonicals, so
remove only the marker and return eligible work to planning instead of stranding its badge;
explicit, implicit, and unrelated pauses are preserved.
FNXC:NearDuplicateDetection 2026-08-01-18:47:
Clearing must leave needs-replan + feedback + dismissal — never status:null. A prompt-less
null status is the scheduler's "planning finished" wake signal and re-opens the FN-8704
replan storm (schedule → missing PROMPT → needs-replan → re-emit inactive DUPLICATE).
*/
const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonicalTask);
if (isNearDuplicateCanonicalInactive(canonicalTask ?? undefined, canonicalFlags)) {
if (canClearInactiveMarker) {
if (!await this.runIfStillPlanningUnderTaskLock(task, async () => {
await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true });
})) return;
await this.updatePlanningStateIfStillCurrent(task, { paused: false, pausedReason: null, status: null });
await this.clearDuplicateMarkerForReplan(
task,
canonicalId,
buildInactiveDuplicateClearFeedback(canonicalId),
);
}
return;
}
@@ -4075,15 +4118,11 @@ export class TriageProcessor {
const keepAcknowledged = fusionCore.isTriageDuplicateKeepAcknowledged(task.sourceMetadata, canonicalId);
if (resolution === "prompt" && keepAcknowledged) {
if (canClearInactiveMarker) {
if (!await this.runIfStillPlanningUnderTaskLock(task, async () => {
await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true });
})) return;
await this.updatePlanningStateIfStillCurrent(task, {
paused: false,
pausedReason: null,
status: null,
sourceMetadataPatch: { nearDuplicateDismissed: true },
});
await this.clearDuplicateMarkerForReplan(
task,
canonicalId,
buildKeepDuplicateClearFeedback(canonicalId),
);
}
return;
}
@@ -4114,15 +4153,12 @@ export class TriageProcessor {
await this.store.recordActivity({ type: "task:auto-archived-duplicate", taskId: task.id, details: "Flagged (not deleted) as triage-marker duplicate", metadata: { canonicalTaskId: canonicalId, source: "triage-marker-flagged" } });
return;
}
if (!await this.runIfStillPlanningUnderTaskLock(task, async () => {
await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true });
})) return;
if (!await this.updatePlanningStateIfStillCurrent(task, {
paused: false,
pausedReason: null,
status: null,
sourceMetadataPatch: { nearDuplicateOf: canonicalId, nearDuplicateScore: 1, duplicateSource: "triage-marker", nearDuplicateDismissed: true },
})) return;
// resolution === "keep" (and any other non-prompt/delete policy that drops the marker)
await this.clearDuplicateMarkerForReplan(
task,
canonicalId,
buildKeepDuplicateClearFeedback(canonicalId),
);
return;
}