diff --git a/src/resources/extensions/sf/sf-db/sf-db-self-feedback.js b/src/resources/extensions/sf/sf-db/sf-db-self-feedback.js index c34d43e55..a4477847e 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-self-feedback.js +++ b/src/resources/extensions/sf/sf-db/sf-db-self-feedback.js @@ -1,6 +1,5 @@ -import { _getAdapter, rowToSelfFeedback } from './sf-db-core.js'; -import { SF_STALE_STATE, SFError } from '../errors.js'; -import { logWarning } from '../workflow-logger.js'; +import { SF_STALE_STATE, SFError } from "../errors.js"; +import { _getAdapter, rowToSelfFeedback } from "./sf-db-core.js"; /** * Severity-derived default impact_score (sf-mp4rxkx0-fkt3e2). Operators can @@ -144,7 +143,10 @@ export function linkEntries(fromId, toId, relationKind) { }); return result.changes > 0; } catch (err) { - const msg = err && typeof err === "object" && "message" in err ? String(err.message) : ""; + const msg = + err && typeof err === "object" && "message" in err + ? String(err.message) + : ""; if ( msg.includes("UNIQUE constraint failed") || msg.includes("PRIMARY KEY constraint failed") @@ -202,7 +204,10 @@ export function resolveSelfFeedbackEntry(entryId, resolution) { const existing = currentDb .prepare("SELECT * FROM self_feedback WHERE id = :id") .get({ ":id": entryId }); - if (!existing || existing["resolved_at"]) return false; + if (!existing) return false; + if (existing["resolved_at"] && hasCredibleResolutionEvidence(existing)) { + return false; + } const resolvedAt = resolution.resolvedAt ?? new Date().toISOString(); const entry = { ...rowToSelfFeedback(existing), @@ -221,7 +226,7 @@ export function resolveSelfFeedbackEntry(entryId, resolution) { resolved_by_sf_version = :resolved_by_sf_version, resolved_evidence_json = :resolved_evidence_json, resolved_criteria_json = :resolved_criteria_json - WHERE id = :id AND resolved_at IS NULL`) + WHERE id = :id`) .run({ ":id": entryId, ":full_json": JSON.stringify(entry), @@ -237,3 +242,18 @@ export function resolveSelfFeedbackEntry(entryId, resolution) { }); return result.changes > 0; } + +function hasCredibleResolutionEvidence(row) { + try { + const parsed = row["resolved_evidence_json"] + ? JSON.parse(row["resolved_evidence_json"]) + : null; + return ( + parsed?.kind === "agent-fix" || + parsed?.kind === "human-clear" || + parsed?.kind === "promoted-to-requirement" + ); + } catch { + return false; + } +} diff --git a/src/resources/extensions/sf/tests/self-feedback-db.test.mjs b/src/resources/extensions/sf/tests/self-feedback-db.test.mjs index e1596b48f..813431c68 100644 --- a/src/resources/extensions/sf/tests/self-feedback-db.test.mjs +++ b/src/resources/extensions/sf/tests/self-feedback-db.test.mjs @@ -153,6 +153,51 @@ test("markResolved_when_db_available_updates_sqlite_and_markdown_projection", () assert.match(markdown, /Recently Resolved/); }); +test("markResolved_when_existing_resolution_lacks_credible_evidence_restamps_entry", () => { + const project = makeForgeProject(); + const result = recordSelfFeedback( + { + kind: "gap:suspect-resolution", + severity: "high", + summary: "Resolved row lacks credible evidence", + }, + project, + ); + assert.ok(result?.entry.id); + const first = markResolved( + result.entry.id, + { + reason: "raw SQL-like resolution", + evidence: { kind: "auto-version-bump" }, + }, + project, + ); + assert.equal(first, true); + + const restamped = markResolved( + result.entry.id, + { + reason: "fixed with a real agent patch", + evidence: { kind: "agent-fix" }, + }, + project, + ); + + assert.equal(restamped, true); + const [entry] = readAllSelfFeedback(project); + assert.equal(entry.resolvedReason, "fixed with a real agent patch"); + assert.equal(entry.resolvedEvidence.kind, "agent-fix"); + const idempotent = markResolved( + result.entry.id, + { + reason: "second credible resolution should not mutate", + evidence: { kind: "human-clear" }, + }, + project, + ); + assert.equal(idempotent, false); +}); + test("markResolved_rejects_resolution_with_non_canonical_evidence_kind", () => { const project = makeForgeProject(); const result = recordSelfFeedback( @@ -942,7 +987,10 @@ test("markResolved_db_only_entry_resolves_without_jsonl_lookup", () => { // DB row is resolved const entries = listSelfFeedbackEntries(); const resolved = entries.find((e) => e.id === "db-only-test-entry-1"); - assert.ok(resolved?.resolvedAt, "DB row must carry resolvedAt after resolution"); + assert.ok( + resolved?.resolvedAt, + "DB row must carry resolvedAt after resolution", + ); }); test("markResolved_db_only_entry_populates_jsonl_and_markdown_after_resolution", () => { @@ -1089,7 +1137,10 @@ test("regenerateSelfFeedbackJsonl_includes_generated_comment_header", () => { ); const firstLine = content.split("\n")[0]; const meta = JSON.parse(firstLine); - assert.ok(meta._meta, "JSONL first line must be a JSON object with _meta key"); + assert.ok( + meta._meta, + "JSONL first line must be a JSON object with _meta key", + ); assert.ok( meta._meta.includes("generated from .sf/sf.db"), "_meta must mention sf.db", @@ -1107,8 +1158,14 @@ test("backfillSelfFeedbackJsonl_four_stuck_entries_appear_in_both_outputs", () = // appear as resolved in both JSONL and Markdown. const project = makeForgeProject(); const stuckEntries = [ - { id: "sf-mp5tuvdx-ibyk9b", kind: "architecture-defect:sf-print-mode-hangs" }, - { id: "sf-mp5tp6uh-8eafni", kind: "architecture-defect:no-subagent-dispatch-observability" }, + { + id: "sf-mp5tuvdx-ibyk9b", + kind: "architecture-defect:sf-print-mode-hangs", + }, + { + id: "sf-mp5tp6uh-8eafni", + kind: "architecture-defect:no-subagent-dispatch-observability", + }, { id: "sf-mp6ed4xq-2sgx8w", kind: "runaway-loop:idle-halt" }, { id: "sf-mp6eitbh-9krkd9", kind: "runaway-loop:idle-halt" }, ]; @@ -1166,7 +1223,11 @@ test("backfillSelfFeedbackJsonl_four_stuck_entries_appear_in_both_outputs", () = return false; } }); - assert.equal(resolvedLines.length, 4, "all 4 stuck entries must appear as resolved in JSONL"); + assert.equal( + resolvedLines.length, + 4, + "all 4 stuck entries must appear as resolved in JSONL", + ); // Markdown must show all 4 in "Recently Resolved" const markdown = readFileSync(