fix(feedback): allow restamping suspect resolutions

This commit is contained in:
Mikael Hugo 2026-05-15 18:10:14 +02:00
parent 820f9aaf8e
commit f00762ffdb
2 changed files with 92 additions and 11 deletions

View file

@ -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;
}
}

View file

@ -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(