FN-8956: track resolved review findings

Add durable, scoped resolution states for workflow review findings.

- Persist reviewer-applied and superseded finding receipts without making them actionable.
- Scope supersession claims to a named prior workflow result, preserving duplicate IDs in other review lanes.
- Render informational resolution badges and reject resolved items from revision requests.

Files changed: .changeset/fn-8956-review-finding-resolution.md    |   7 +
 docs/dashboard-guide.md                            |   2 +-
 docs/workflow-steps.md                             |   8 +-
 .../src/__tests__/review-severity-gate.test.ts     |  25 ++++
 .../src/__tests__/workflow-step-results.test.ts    |  45 +++++-
 packages/core/src/index.gate.ts                    |   6 +
 packages/core/src/index.ts                         |   6 +
 packages/core/src/types.ts                         |   2 +
 packages/core/src/types/task/task-review.ts        |   4 +
 packages/core/src/types/workflow/workflow-steps.ts |  15 +-
 .../src/workflows/builtin-code-review-group.ts     |   2 +-
 .../src/workflows/builtin-plan-review-group.ts     |   2 +-
 .../core/src/workflows/review-severity-gate.ts     |  37 ++++-
 .../core/src/workflows/workflow-step-results.ts    |  54 ++++++-
 packages/dashboard/app/api/agents/run-audit.ts     |   1 +
 .../dashboard/app/components/TaskReviewTab.css     |  22 +++
 .../dashboard/app/components/TaskReviewTab.tsx     |  36 +++--
 .../components/__tests__/TaskReviewTab.test.tsx    |  46 ++++++
 .../dashboard/src/__tests__/routes-tasks.test.ts   |  48 ++++++
 .../src/routes/register-task-workflow-routes.ts    |  14 +-
 .../__tests__/review-finding-supersession.test.ts  | 163 +++++++++++++++++++++
 .../__tests__/review-findings-injection.test.ts    |  22 +++
 .../workflow-step-verdict-parsing.test.ts          |  16 +-
 .../engine/src/executor/execute-workflow-graph.ts  | 129 ++++++++--------
 .../engine/src/executor/execute-workflow-step.ts   |  25 +++-
 .../engine/src/executor/run-graph-custom-node.ts   |   7 +
 .../executor/workflow-step-failure-injection.ts    |   8 +-
 .../engine/src/executor/workflow-step-verdict.ts   |  17 ++-
 .../src/workflows/workflow-graph-executor.ts       |  15 ++
 packages/i18n/locales/en/app.json                  |   4 +-
 packages/i18n/locales/es/app.json                  |   4 +-
 packages/i18n/locales/fr/app.json                  |   4 +-
 packages/i18n/locales/ko/app.json                  |   4 +-
 packages/i18n/locales/pt-BR/app.json               |   4 +-
 packages/i18n/locales/zh-CN/app.json               |   4 +-
 packages/i18n/locales/zh-TW/app.json               |   4 +-
 36 files changed, 703 insertions(+), 109 deletions(-)

Fusion-Task-Id: FN-8956

Fusion-Task-Lineage: 80568280-85aa-4a49-a60a-99b75f88f486

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-11 13:34:27 -07:00
parent 6ae9299576
commit 9f24a517cf
36 changed files with 702 additions and 108 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Keep resolved review findings visible without allowing no-op revision requests.
category: feature
dev: Adds WorkflowReviewFinding.resolution and prompt/script supersededFindingIds claims persisted at the result sink; resolved findings bypass gate/remediation actions and POST /tasks/:id/review/address rejects them.

View File

@@ -2417,7 +2417,7 @@ Todo Lists is an optional first-party plugin. Enable `fusion-plugin-todos` for a
## Workflow direct-review items
The Review tab shows a custom workflow result only when its selected workflow declares the exact top-level node and result source, the result explicitly snapshots `reviewKind: "plan"` or `"code"`, and it is current, terminal, and not bypassed or superseded. Each persisted structured finding becomes one independently selectable reviewer-agent item with its server-owned identity, optional location, and severity. Selecting a subset sends only those canonical items for revision; client-supplied text and metadata are ignored. A current result without findings retains one prose/notes fallback item. Pending, skipped, historical prior attempts, blank results, and records without that declared top-level identity (including template instances) are not selectable or addressable. Node-ID punctuation alone does not identify a template instance. Existing historical `plan-review` and `code-review` results retain narrow compatibility; Fusion does not infer or backfill custom review meaning from names, verdicts, prose, or gate settings.
The Review tab shows a custom workflow result only when its selected workflow declares the exact top-level node and result source, the result explicitly snapshots `reviewKind: "plan"` or `"code"`, and it is current, terminal, and not bypassed or superseded. Each persisted open structured finding becomes one independently selectable reviewer-agent item with its server-owned identity, optional location, and severity. Findings marked `resolved-in-review` or `superseded` remain visible as audit-only, badged rows and cannot be selected; the revision route enforces this as well. Selecting a subset sends only those canonical open items for revision; client-supplied text and metadata are ignored. A current result without findings retains one prose/notes fallback item. Pending, skipped, historical prior attempts, blank results, and records without that declared top-level identity (including template instances) are not selectable or addressable. Node-ID punctuation alone does not identify a template instance. Existing historical `plan-review` and `code-review` results retain narrow compatibility; Fusion does not infer or backfill custom review meaning from names, verdicts, prose, or gate settings.
### Workflow agent routing

View File

@@ -785,7 +785,7 @@ The gate **fails closed** and only ever relaxes a verdict:
- a `REVISE` carrying any finding that omits `severity` still blocks;
- an `APPROVE`/`APPROVE_WITH_NOTES` is never promoted to a block, whatever its findings say.
Set either setting to `any` to restore the previous behavior where every `REVISE` blocks. The built-in Plan Review and Code Review prompts request the structured findings schema, define severity as P0/P1/P2 by consequence, instruct reviewers to omit nits entirely rather than file them as low-severity findings, and use an incremental re-review contract that forbids introducing new non-blocking findings as grounds for another round. Remediation instructions render findings grouped by priority — P0 must fix, P1 fix or explicitly decline with a rationale, P2 optional.
Set either setting to `any` to restore the previous behavior where every `REVISE` blocks. The built-in Plan Review and Code Review prompts request the structured findings schema, define severity as P0/P1/P2 by consequence, instruct reviewers to omit nits entirely rather than file them as low-severity findings, and use an incremental re-review contract that forbids introducing new non-blocking findings as grounds for another round. Remediation instructions render findings grouped by priority — P0 must fix, P1 fix or explicitly decline with a rationale, P2 optional. Findings marked `resolved-in-review` or `superseded` are audit receipts: they never block, become advisory notes, or enter remediation priority lists; remediation shows them only in an explicit do-not-redo block, and the Review tab plus revision route do not allow them to be selected.
Remediation now also **preserves the implementation session**: a review bounce keeps `task.sessionFile`, so the next round continues the same conversation instead of re-reading the repository and re-deriving the change it just wrote. The resume prompt directs the agent to re-read PROMPT.md, which is where the new findings were written. Paths that genuinely need a fresh session (context overflow, stale continuation, worktree reacquisition, task-done refusal) still clear the session explicitly at their own site, and the resume guard re-validates the persisted worktree before reopening.
@@ -991,9 +991,11 @@ A top-level `prompt`, `gate`, `script`, or `optional-group` node may set `config
{ "id": "architecture-review", "kind": "prompt", "config": { "name": "Architecture review", "reviewKind": "code", "prompt": "Review the proposed architecture." } }
```
When a marked supported node runs, its pending and terminal workflow-step result snapshots the declared value. Review-kind prompt and script output may end with one JSON object containing `verdict`, `notes`, and `findings`. Each finding has a stable `id`, actionable `title` and `body`, plus optional `filePath`, positive `line`, and `low`/`medium`/`high`/`critical` severity. Fusion trims and bounds strings, drops malformed entries, and suffixes duplicate IDs; it never splits Markdown prose into findings.
When a marked supported node runs, its pending and terminal workflow-step result snapshots the declared value. Review-kind prompt and declared `reviewKind` script output may end with one JSON object containing `verdict`, `notes`, `findings`, and optional `supersededFindingIds`. Each finding has a stable `id`, actionable `title` and `body`, optional `filePath`, positive `line`, severity, and optional resolution: `resolved-in-review` is a self-fixed receipt and `superseded` is a re-verified stale finding. Absent resolution means open; explicit `open` and invalid resolutions are dropped while the finding remains. Fusion trims and bounds strings, drops malformed entries, and suffixes duplicate IDs; it never splits Markdown prose into findings.
Findings persist through both ordinary-node and optional-group result writers in the existing JSONB result. A retry moves the replaced result (including its findings) into bounded single-level `priorAttempts`; only current findings are actionable. Findings are advisory metadata: they do not alter verdict parsing, gate status, merge blocking, recovery, or retry routing. A row without findings keeps its one prose/notes fallback item. Omission means the node is **not** a direct review, regardless of its ID, label, verdict, output prose, phase, or gate mode. Markers are rejected on foreach and loop templates and optional-group source/template nodes: those executions do not yet have an instance-safe current-result or Review-tab address contract.
Review prompts list earlier open finding IDs. A later reviewer may claim only those IDs in `supersededFindingIds`; the audit-only list is carried on `WorkflowStepResult` and applied to earlier persisted findings at the engine result sink. This works for prompt and declared review scripts, while unmarked scripts cannot emit review metadata. Supersession is always an explicit reviewer claim: Fusion never infers it from commit timestamps.
Findings persist through both ordinary-node and optional-group result writers in the existing JSONB result. A retry moves the replaced result (including its findings) into bounded single-level `priorAttempts`; only current findings are actionable. Findings do not alter verdict parsing, merge blocking, recovery, or retry routing. Open findings continue through the severity gate and advisory/remediation paths; resolved receipts are excluded from those actionable paths without rewriting an explicit `REVISE` verdict. A row without findings keeps its one prose/notes fallback item. Omission means the node is **not** a direct review, regardless of its ID, label, verdict, output prose, phase, or gate mode. Markers are rejected on foreach and loop templates and optional-group source/template nodes: those executions do not yet have an instance-safe current-result or Review-tab address contract.
## Durable workflow principals

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
applyReviewSeverityGate,
formatFindingsByPriority,
formatResolvedFindings,
isBlockingFinding,
resolveReviewBlockingSeverity,
DEFAULT_CODE_REVIEW_BLOCKING_SEVERITY,
@@ -65,6 +66,13 @@ describe("isBlockingFinding", () => {
it("blocks everything at threshold \"any\"", () => {
expect(isBlockingFinding(finding({ severity: "low" }), "any")).toBe(true);
});
it("never blocks review receipts or superseded findings", () => {
for (const resolution of ["resolved-in-review", "superseded"] as const) {
expect(isBlockingFinding(finding({ severity: "critical", resolution }), "critical")).toBe(false);
expect(isBlockingFinding(finding({ severity: "critical", resolution }), "any")).toBe(false);
}
});
});
describe("applyReviewSeverityGate", () => {
@@ -133,6 +141,16 @@ describe("applyReviewSeverityGate", () => {
expect(result.downgraded).toBe(false);
});
it("keeps an all-resolved REVISE fail-closed while exposing only audit receipts", () => {
const result = applyReviewSeverityGate({
verdict: "REVISE",
findings: [finding({ id: "receipt", severity: "critical", resolution: "resolved-in-review" })],
threshold: "any",
});
expect(result).toMatchObject({ verdict: "REVISE", downgraded: false, blocking: [], advisory: [] });
expect(result.resolved.map((item) => item.id)).toEqual(["receipt"]);
});
it("only ever relaxes — an APPROVE carrying a critical finding is left alone", () => {
for (const verdict of ["APPROVE", "APPROVE_WITH_NOTES", "CLOSE_NO_OP", undefined]) {
const result = applyReviewSeverityGate({
@@ -172,4 +190,11 @@ describe("formatFindingsByPriority", () => {
const out = formatFindingsByPriority([finding({ title: "unknown", body: "x" })]);
expect(out).toContain("### Unclassified — treat as must fix");
});
it("omits non-open findings from priorities and renders audit receipts separately", () => {
const receipt = finding({ id: "receipt", title: "Fixed", body: "Already handled", resolution: "resolved-in-review" });
expect(formatFindingsByPriority([receipt])).toBe("");
expect(formatResolvedFindings([receipt])).toContain("Already resolved during this review pass — do NOT redo");
expect(formatResolvedFindings([receipt])).toContain("[resolved-in-review]");
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { normalizeWorkflowReviewFindings, upsertWorkflowStepResult, MAX_WORKFLOW_STEP_PRIOR_ATTEMPTS } from "../workflows/workflow-step-results.js";
import { applySupersededFindingIds, MAX_WORKFLOW_REVIEW_FINDINGS, MAX_WORKFLOW_STEP_PRIOR_ATTEMPTS, normalizeSupersededFindingIds, normalizeWorkflowReviewFindings, upsertWorkflowStepResult } from "../workflows/workflow-step-results.js";
import type { WorkflowStepResult } from "../types.js";
function makeResult(overrides: Partial<WorkflowStepResult> = {}): WorkflowStepResult {
@@ -31,6 +31,49 @@ describe("normalizeWorkflowReviewFindings", () => {
{ title: "title", body: "x".repeat(4001) },
])).toBeUndefined();
});
it("preserves valid non-open resolutions but normalizes open and invalid values away", () => {
expect(normalizeWorkflowReviewFindings([
{ id: "receipt", title: "Receipt", body: "Fixed", resolution: "resolved-in-review" },
{ id: "stale", title: "Stale", body: "Fixed elsewhere", resolution: "superseded" },
{ id: "open", title: "Open", body: "Fix", resolution: "open" },
{ id: "invalid", title: "Invalid", body: "Still valid", resolution: "fixed" },
{ id: "null", title: "Null", body: "Still valid", resolution: null },
])).toEqual([
{ id: "receipt", title: "Receipt", body: "Fixed", resolution: "resolved-in-review" },
{ id: "stale", title: "Stale", body: "Fixed elsewhere", resolution: "superseded" },
{ id: "open", title: "Open", body: "Fix" },
{ id: "invalid", title: "Invalid", body: "Still valid" },
{ id: "null", title: "Null", body: "Still valid" },
]);
});
});
describe("superseded finding claims", () => {
it("normalizes bounded, deduplicated string ids", () => {
expect(normalizeSupersededFindingIds([" c1 ", 4, "c1", "", "c2"])).toEqual(["c1", "c2"]);
expect(normalizeSupersededFindingIds(Array.from({ length: MAX_WORKFLOW_REVIEW_FINDINGS + 1 }, (_, index) => `f${index}`))).toHaveLength(MAX_WORKFLOW_REVIEW_FINDINGS);
expect(normalizeSupersededFindingIds({})).toBeUndefined();
});
it("stamps only unresolved findings outside the claiming result", () => {
const prior = makeResult({ workflowStepId: "cleanup", findings: [
{ id: "c1", title: "Open", body: "Fix" },
{ id: "receipt", title: "Receipt", body: "Fixed", resolution: "resolved-in-review" },
], priorAttempts: [{ ...makeResult({ workflowStepId: "cleanup", findings: [{ id: "c1", title: "Old", body: "Old" }] }) }] });
const claimant = makeResult({ workflowStepId: "code", findings: [{ id: "c1", title: "Own", body: "Own" }] });
const next = applySupersededFindingIds([prior, claimant], ["c1", "receipt"], { excludeWorkflowStepId: "code", sourceWorkflowStepId: "cleanup" });
expect(next?.[0].findings).toEqual([
{ id: "c1", title: "Open", body: "Fix", resolution: "superseded" },
{ id: "receipt", title: "Receipt", body: "Fixed", resolution: "resolved-in-review" },
]);
const unrelated = makeResult({ workflowStepId: "other-review", findings: [{ id: "c1", title: "Different lane", body: "Must remain open" }] });
const scoped = applySupersededFindingIds([prior, unrelated, claimant], ["c1"], { excludeWorkflowStepId: "code", sourceWorkflowStepId: "cleanup" });
expect(scoped?.[1].findings?.[0]).not.toHaveProperty("resolution");
expect(next?.[0].priorAttempts).toEqual(prior.priorAttempts);
expect(next?.[1]).toBe(claimant);
expect(applySupersededFindingIds(next, ["missing"], { excludeWorkflowStepId: "code", sourceWorkflowStepId: "cleanup" })).toBe(next);
});
});
describe("upsertWorkflowStepResult", () => {

View File

@@ -569,6 +569,7 @@ THIS file, so a gate export present only in index.ts resolves to `undefined` und
export {
applyReviewSeverityGate,
formatFindingsByPriority,
formatResolvedFindings,
isBlockingFinding,
isReviewBlockingSeverity,
resolveReviewBlockingSeverity,
@@ -2323,8 +2324,13 @@ export {
upsertWorkflowStepResult,
normalizeWorkflowReviewFindings,
isWorkflowReviewFindingSeverity,
isWorkflowReviewFindingResolution,
isOpenWorkflowReviewFinding,
normalizeSupersededFindingIds,
applySupersededFindingIds,
MAX_WORKFLOW_REVIEW_FINDINGS,
WORKFLOW_REVIEW_FINDING_SEVERITIES,
WORKFLOW_REVIEW_FINDING_RESOLUTIONS,
MAX_WORKFLOW_STEP_PRIOR_ATTEMPTS,
PLAN_REVIEW_LEASE_STALENESS_MS,
classifyReviewLease,

View File

@@ -664,6 +664,7 @@ export {
export {
applyReviewSeverityGate,
formatFindingsByPriority,
formatResolvedFindings,
isBlockingFinding,
isReviewBlockingSeverity,
resolveReviewBlockingSeverity,
@@ -2755,8 +2756,13 @@ export {
upsertWorkflowStepResult,
normalizeWorkflowReviewFindings,
isWorkflowReviewFindingSeverity,
isWorkflowReviewFindingResolution,
isOpenWorkflowReviewFinding,
normalizeSupersededFindingIds,
applySupersededFindingIds,
MAX_WORKFLOW_REVIEW_FINDINGS,
WORKFLOW_REVIEW_FINDING_SEVERITIES,
WORKFLOW_REVIEW_FINDING_RESOLUTIONS,
MAX_WORKFLOW_STEP_PRIOR_ATTEMPTS,
PLAN_REVIEW_LEASE_STALENESS_MS,
classifyReviewLease,

View File

@@ -273,6 +273,7 @@ import type {
WorkflowStepPhase,
WorkflowReviewKind,
WorkflowReviewFindingSeverity,
WorkflowReviewFindingResolution,
WorkflowReviewFinding,
WorkflowStep,
NtfyNotificationEvent,
@@ -295,6 +296,7 @@ export type {
WorkflowStepPhase,
WorkflowReviewKind,
WorkflowReviewFindingSeverity,
WorkflowReviewFindingResolution,
WorkflowReviewFinding,
WorkflowStep,
NtfyNotificationEvent,

View File

@@ -15,6 +15,7 @@ export type TaskReviewVerdict = "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE" | "R
export type TaskReviewerType = "plan" | "code";
export type TaskReviewItemStatus = "queued" | "in-progress" | "addressed" | "failed";
export type TaskReviewFindingSeverity = "low" | "medium" | "high" | "critical";
export type TaskReviewFindingResolution = "open" | "resolved-in-review" | "superseded";
export interface LegacyTaskReviewItem {
id: string;
@@ -100,6 +101,7 @@ export interface TaskReviewStateItem {
step?: number;
summary?: string;
severity?: TaskReviewFindingSeverity;
resolution?: TaskReviewFindingResolution;
}
export type ReviewAddressingStatus = "queued" | "in-progress" | "addressed" | "failed";
@@ -114,6 +116,7 @@ export interface ReviewAddressingSnapshot {
filePath?: string;
lineNumber?: number;
severity?: TaskReviewFindingSeverity;
resolution?: TaskReviewFindingResolution;
threadId?: string;
url?: string;
}
@@ -171,6 +174,7 @@ export interface TaskReviewDataItem {
filePath?: string;
line?: number;
severity?: TaskReviewFindingSeverity;
resolution?: TaskReviewFindingResolution;
threadId?: string;
reviewState?: string | null;
/** Machine-readable reviewer verdict when the source supplied one. */

View File

@@ -33,12 +33,14 @@ export type WorkflowStepGateMode = "gate" | "advisory";
/** Closed severity vocabulary shared by persisted workflow findings and Review-tab items. */
export type WorkflowReviewFindingSeverity = "low" | "medium" | "high" | "critical";
export type WorkflowReviewFindingResolution = "open" | "resolved-in-review" | "superseded";
/**
* FNXC:WorkflowReviewFindings 2026-08-05-06:29:
* Review-kind nodes persist independently actionable feedback in the existing JSONB result so
* Review-tab selection never depends on model prose or client-provided metadata. Finding identity
* is normalized before persistence; historical prose-only rows intentionally omit this field.
* FNXC:WorkflowReviewFindings 2026-08-11-19:39:
* Absent resolution means open and is never persisted as `"open"`. An explicit reviewer claim
* marks a receipt as resolved-in-review or a prior finding as superseded; Fusion never infers this
* from commit timestamps. This lets already-handled findings remain auditable without becoming
* actionable Review-tab revision items.
*/
export interface WorkflowReviewFinding {
id: string;
@@ -47,6 +49,7 @@ export interface WorkflowReviewFinding {
filePath?: string;
line?: number;
severity?: WorkflowReviewFindingSeverity;
resolution?: WorkflowReviewFindingResolution;
}
/** Lifecycle phase for workflow step execution. */
@@ -277,6 +280,10 @@ export interface WorkflowStepResult {
output?: string;
/** Normalized structured advisory findings from an explicitly classified review node. */
findings?: WorkflowReviewFinding[];
/** Prior result containing the finding IDs this review step explicitly declared superseded. */
supersededFindingSourceWorkflowStepId?: string;
/** Prior-lane finding IDs this review step explicitly declared superseded; audit-only. */
supersededFindingIds?: string[];
/**
* Machine-readable verdict from prompt-mode structured output.
* Absent for script-mode steps and legacy prose-only prompt outputs.

View File

@@ -82,7 +82,7 @@ Be specific: cite \`file:line\` for every finding and explain the concrete failu
- REVISE: a correctness bug, regression, or contract break requires changes before merge. Requires at least one \`critical\` finding in \`findings\`; a REVISE without one will be treated as APPROVE_WITH_NOTES.
- Every blocking issue MUST appear as an entry in \`findings\` with its severity and \`filePath\`/\`line\`. Prose in \`notes\` alone does not block.
- Final output: output exactly one trailing JSON object on the final line (no markdown fences, no surrounding prose):
{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"...","findings":[{"id":"stable-id","title":"concise issue","body":"concrete failure and remediation","filePath":"path/to/file.ts","line":1,"severity":"critical|high|medium|low"}]}`;
{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"...","findings":[{"id":"stable-id","title":"concise issue","body":"concrete failure and remediation","filePath":"path/to/file.ts","line":1,"severity":"critical|high|medium|low","resolution":"open|resolved-in-review|superseded"}]}`;
/**
* Build the `code-review` optional-group node placed on a workflow's pre-merge path.

View File

@@ -46,7 +46,7 @@ Be specific: cite the plan section or file path for every finding and explain th
- CLOSE_NO_OP: implementation must not proceed because the premise is stale, the work is already satisfied, redundant, or a duplicate. The notes field MUST begin with exactly one existing completion sentinel: PREMISE STALE:, NO-OP:, NOOP:, REDUNDANT:, or DUPLICATE:. For duplicates, use DUPLICATE: FN-NNNN ... when the canonical task is known.
- Every blocking issue MUST appear as an entry in \`findings\` with its severity. Prose in \`notes\` alone does not block, and is not a durable input to the next planning round.
- Final output: output exactly one trailing JSON object on the final line (no markdown fences, no surrounding prose):
{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE|CLOSE_NO_OP","notes":"...","findings":[{"id":"stable-id","title":"concise issue","body":"actionable correction","severity":"critical|high|medium|low"}]}`;
{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE|CLOSE_NO_OP","notes":"...","findings":[{"id":"stable-id","title":"concise issue","body":"actionable correction","severity":"critical|high|medium|low","resolution":"open|resolved-in-review|superseded"}]}`;
/*
FNXC:PlanReviewStep 2026-07-27-06:10:

View File

@@ -24,6 +24,7 @@ every review that does not opt into the structured contract.
*/
import type { WorkflowReviewFinding, WorkflowReviewFindingSeverity, WorkflowReviewKind } from "../types.js";
import { isOpenWorkflowReviewFinding } from "./workflow-step-results.js";
/**
* Blocking threshold for a review gate. A severity value blocks at that level and above;
@@ -108,6 +109,7 @@ export function resolveReviewBlockingSeverity({
* An UNCLASSIFIED finding always blocks — see the fail-closed contract in the module header.
*/
export function isBlockingFinding(finding: WorkflowReviewFinding, threshold: ReviewBlockingSeverity): boolean {
if (!isOpenWorkflowReviewFinding(finding)) return false;
if (threshold === "any") return true;
if (!finding.severity) return true;
return SEVERITY_RANK[finding.severity] >= SEVERITY_RANK[threshold];
@@ -128,6 +130,8 @@ export interface ReviewSeverityGateResult<V = string | undefined> {
blocking: WorkflowReviewFinding[];
/** Findings below the threshold. Still persisted and still handed to the implementer. */
advisory: WorkflowReviewFinding[];
/** Audit-only receipts and superseded findings; never actionable. */
resolved: WorkflowReviewFinding[];
}
/**
@@ -139,15 +143,22 @@ export interface ReviewSeverityGateResult<V = string | undefined> {
*/
export function applyReviewSeverityGate({ verdict, findings, threshold }: ReviewSeverityGateInput): ReviewSeverityGateResult {
const all = findings ?? [];
const blocking = all.filter((finding) => isBlockingFinding(finding, threshold));
const advisory = all.filter((finding) => !isBlockingFinding(finding, threshold));
const open = all.filter(isOpenWorkflowReviewFinding);
const resolved = all.filter((finding) => !isOpenWorkflowReviewFinding(finding));
const blocking = open.filter((finding) => isBlockingFinding(finding, threshold));
const advisory = open.filter((finding) => !isBlockingFinding(finding, threshold));
if (verdict !== "REVISE") return { verdict, downgraded: false, blocking, advisory };
if (verdict !== "REVISE") return { verdict, downgraded: false, blocking, advisory, resolved };
// Fail closed: an unstructured REVISE keeps its blocking power.
if (all.length === 0) return { verdict, downgraded: false, blocking, advisory };
if (blocking.length > 0) return { verdict, downgraded: false, blocking, advisory };
/*
FNXC:ReviewSeverityGate 2026-08-11-19:39:
Resolution is audit metadata, never authority to rewrite an explicit REVISE. Receipts avoid
no-op rework through remediation's do-not-redo block while an all-resolved REVISE stays fail-closed.
*/
if (all.length === 0 || (open.length === 0 && resolved.length > 0)) return { verdict, downgraded: false, blocking, advisory, resolved };
if (blocking.length > 0) return { verdict, downgraded: false, blocking, advisory, resolved };
return { verdict: "APPROVE_WITH_NOTES", downgraded: true, blocking, advisory };
return { verdict: "APPROVE_WITH_NOTES", downgraded: true, blocking, advisory, resolved };
}
/**
@@ -157,6 +168,7 @@ export function applyReviewSeverityGate({ verdict, findings, threshold }: Review
* consistent shape whether the review blocked or was downgraded.
*/
export function formatFindingsByPriority(findings: WorkflowReviewFinding[]): string {
findings = findings.filter(isOpenWorkflowReviewFinding);
if (findings.length === 0) return "";
const groups: Array<{ label: string; obligation: string; severities: WorkflowReviewFindingSeverity[] }> = [
{ label: "P0 — must fix", obligation: "Fix every item in this group before returning.", severities: ["critical"] },
@@ -192,3 +204,14 @@ export function formatFindingsByPriority(findings: WorkflowReviewFinding[]): str
return sections.join("\n\n");
}
/** Render audit receipts separately so implementers do not redo completed review work. */
export function formatResolvedFindings(findings: WorkflowReviewFinding[]): string {
const resolved = findings.filter((finding) => !isOpenWorkflowReviewFinding(finding));
if (resolved.length === 0) return "";
const lines = resolved.map((finding) => {
const location = finding.filePath ? ` (${finding.filePath}${finding.line ? `:${finding.line}` : ""})` : "";
return `- **${finding.title}**${location} [${finding.resolution}]\n ${finding.body}`;
});
return `### Already resolved during this review pass — do NOT redo\n\n${lines.join("\n")}`;
}

View File

@@ -1,6 +1,7 @@
import type { WorkflowReviewFinding, WorkflowReviewFindingSeverity, WorkflowStepResult } from "../types.js";
import type { WorkflowReviewFinding, WorkflowReviewFindingResolution, WorkflowReviewFindingSeverity, WorkflowStepResult } from "../types.js";
export const WORKFLOW_REVIEW_FINDING_SEVERITIES = ["low", "medium", "high", "critical"] as const;
export const WORKFLOW_REVIEW_FINDING_RESOLUTIONS = ["open", "resolved-in-review", "superseded"] as const;
export const MAX_WORKFLOW_REVIEW_FINDINGS = 20;
const MAX_FINDING_ID_LENGTH = 128;
const MAX_FINDING_TITLE_LENGTH = 240;
@@ -33,7 +34,10 @@ export function normalizeWorkflowReviewFindings(raw: unknown): WorkflowReviewFin
? Math.floor(value.line)
: undefined;
const severity = isWorkflowReviewFindingSeverity(value.severity) ? value.severity : undefined;
normalized.push({ id, title, body, ...(filePath ? { filePath } : {}), ...(line ? { line } : {}), ...(severity ? { severity } : {}) });
const resolution = isWorkflowReviewFindingResolution(value.resolution) && value.resolution !== "open"
? value.resolution
: undefined;
normalized.push({ id, title, body, ...(filePath ? { filePath } : {}), ...(line ? { line } : {}), ...(severity ? { severity } : {}), ...(resolution ? { resolution } : {}) });
}
return normalized.length > 0 ? normalized : undefined;
}
@@ -48,6 +52,52 @@ export function isWorkflowReviewFindingSeverity(value: unknown): value is Workfl
return typeof value === "string" && (WORKFLOW_REVIEW_FINDING_SEVERITIES as readonly string[]).includes(value);
}
export function isWorkflowReviewFindingResolution(value: unknown): value is WorkflowReviewFindingResolution {
return typeof value === "string" && (WORKFLOW_REVIEW_FINDING_RESOLUTIONS as readonly string[]).includes(value);
}
/** Historical findings and explicit `open` findings remain actionable. */
export function isOpenWorkflowReviewFinding(finding: WorkflowReviewFinding): boolean {
return finding.resolution === undefined || finding.resolution === "open";
}
/** Normalize untrusted reviewer claims before they can target persisted prior-lane findings. */
export function normalizeSupersededFindingIds(raw: unknown): string[] | undefined {
if (!Array.isArray(raw)) return undefined;
const ids = [...new Set(raw
.map((value) => boundedTrimmedString(value, MAX_FINDING_ID_LENGTH))
.filter((value): value is string => value !== undefined))]
.slice(0, MAX_WORKFLOW_REVIEW_FINDINGS);
return ids.length > 0 ? ids : undefined;
}
/**
* Apply a later review step's explicit supersession claim only to its named prior result,
* preserving other lanes that may use the same finding IDs.
*/
export function applySupersededFindingIds(
results: WorkflowStepResult[] | undefined,
ids: string[],
options: { excludeWorkflowStepId: string; sourceWorkflowStepId: string },
): WorkflowStepResult[] | undefined {
if (!results || ids.length === 0 || !options.sourceWorkflowStepId) return results;
const claimed = new Set(ids);
let changed = false;
const next = results.map((result) => {
if (result.workflowStepId !== options.sourceWorkflowStepId || result.workflowStepId === options.excludeWorkflowStepId || !result.findings?.length) return result;
let findingsChanged = false;
const findings = result.findings.map((finding) => {
if (!claimed.has(finding.id) || !isOpenWorkflowReviewFinding(finding)) return finding;
findingsChanged = true;
return { ...finding, resolution: "superseded" as const };
});
if (!findingsChanged) return result;
changed = true;
return { ...result, findings };
});
return changed ? next : results;
}
/*
FNXC:WorkflowStepResults 2026-07-09-00:20:
FN-7727: both engine `WorkflowStepResult` recorders (the executor graph adapter's

View File

@@ -259,6 +259,7 @@ function mapTaskReviewDataToLegacy(data: TaskReviewData): TaskReviewResponse {
state: item.reviewState ?? undefined,
verdict: item.verdict,
reviewType: item.reviewType,
resolution: item.resolution,
summary: item.title ?? undefined,
isResolved: item.isResolved,
...(typeof item.line === "number" ? { line: item.line } : {}),

View File

@@ -538,3 +538,25 @@ PR-linked tasks must expose decision-adjacent reviewers/checks/blockers plus ite
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
}
}
.task-review-tab__item--informational {
border-color: color-mix(in srgb, var(--color-success) 30%, var(--border));
}
.task-review-tab__resolution-badge {
display: inline-flex;
align-items: center;
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-success) 28%, transparent);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-success) 14%, transparent);
color: var(--color-success);
font-size: var(--font-size-xs);
font-weight: 600;
padding: 0 var(--space-sm);
}
@media (max-width: 768px) {
.task-review-tab__resolution-badge {
align-self: flex-start;
}
}

View File

@@ -56,6 +56,7 @@ type DisplayReviewItem = {
path?: string;
line?: number;
severity?: "low" | "medium" | "high" | "critical";
resolution?: "open" | "resolved-in-review" | "superseded";
createdAt?: string;
status: "queued" | "in-progress" | "addressed" | "failed";
addressing?: AddressingRecord;
@@ -135,6 +136,7 @@ function getDisplayReviewItems(review: ReviewState): DisplayReviewItem[] {
path: item.path,
line: item.line,
severity: item.severity,
resolution: item.resolution,
createdAt: item.createdAt,
status: addressing?.status ?? "queued",
addressing,
@@ -212,7 +214,7 @@ export function TaskReviewTab({
return authorTypeFilter === "bot" ? authorInfo.authorIsBot : !authorInfo.authorIsBot;
});
}, [authorTypeFilter, displayItems]);
const visibleItemIds = useMemo(() => new Set(filteredDisplayItems.map((item) => item.id)), [filteredDisplayItems]);
const visibleItemIds = useMemo(() => new Set(filteredDisplayItems.filter((item) => !item.resolution || item.resolution === "open").map((item) => item.id)), [filteredDisplayItems]);
const canRevise = selected.length > 0 && !revising;
const canAddressPrFeedback = isPrMode
&& Boolean(getTaskPrimaryPrInfo(task))
@@ -286,7 +288,10 @@ export function TaskReviewTab({
? "status-dot status-dot--pending"
: "status-dot status-dot--online";
const toggleSelected = (id: string) => setSelected((prev) => (prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]));
const toggleSelected = (id: string) => {
if (displayItems.some((item) => item.id === id && item.resolution && item.resolution !== "open")) return;
setSelected((prev) => (prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id]));
};
const onRefresh = async () => {
try {
@@ -567,19 +572,30 @@ export function TaskReviewTab({
const prState = isPrMode ? item.item?.state : undefined;
const prUrl = isPrMode ? item.item?.htmlUrl ?? item.addressing?.snapshot?.url : undefined;
const summaryPrefix = item.path && !isPrMode ? `${item.path}: ` : "";
const isOpen = !item.resolution || item.resolution === "open";
const resolutionLabel = item.resolution === "resolved-in-review"
? t("taskReview.fixedInReview", "Fixed in review")
: t("taskReview.superseded", "Superseded");
return (
<li key={item.id} className="task-review-tab__item card" data-review-comment-author-type={authorType}>
<li key={item.id} className={`task-review-tab__item card${isOpen ? "" : " task-review-tab__item--informational"}`} data-review-comment-author-type={authorType} {...(!isOpen ? { "data-review-resolution": item.resolution } : {})}>
<div className="task-review-tab__item-inner">
<label htmlFor={checkboxId} className="task-review-tab__direct-item task-review-tab__direct-item--selectable">
<div className="task-review-tab__item-header">
<div className="task-review-tab__item-selection">
<input id={checkboxId} type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelected(item.id)} />
<span className="task-review-tab__item-summary">{summaryPrefix}{item.summary}</span>
{isOpen ? (
<label htmlFor={checkboxId} className="task-review-tab__direct-item task-review-tab__direct-item--selectable">
<div className="task-review-tab__item-header">
<div className="task-review-tab__item-selection">
<input id={checkboxId} type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelected(item.id)} />
<span className="task-review-tab__item-summary">{summaryPrefix}{item.summary}</span>
</div>
<span className={`task-review-tab__status task-review-tab__status--${item.status}`}>{item.status}</span>
</div>
<span className={`task-review-tab__status task-review-tab__status--${item.status}`}>{item.status}</span>
</label>
) : (
<div className="task-review-tab__item-header">
<span className="task-review-tab__item-summary">{summaryPrefix}{item.summary}</span>
<span className="task-review-tab__resolution-badge">{resolutionLabel}</span>
</div>
</label>
)}
{/*
FNXC:TaskReview 2026-06-27-00:00:
Every Review-tab item needs visible author provenance across PR live items, reviewer-agent items, and snapshot-only addressing records. Render a deterministic avatar image only for human GitHub logins; missing authors and bots use generic icons so there is never an empty or broken avatar shell.

View File

@@ -1276,6 +1276,52 @@ describe("TaskReviewTab", () => {
expect(screen.queryByTestId("task-review-create-pr")).toBeNull();
});
it("renders resolved findings as audit rows without revision checkboxes on desktop and mobile", async () => {
const task = makeTask();
const items = [
{ id: "c1", summary: "Earlier cleanup", body: "Earlier finding", resolution: "superseded" as const },
{ id: "c2", summary: "Earlier error", body: "Earlier finding", resolution: "superseded" as const },
{ id: "c3", summary: "Earlier timeout", body: "Earlier finding", resolution: "superseded" as const },
{ id: "r1", summary: "Receipt one", body: "Fixed: explicit catch", resolution: "resolved-in-review" as const },
{ id: "r2", summary: "Receipt two", body: "Fixed: timeout budget", resolution: "resolved-in-review" as const },
{ id: "o1", summary: "Open finding", body: "The only actionable item" },
];
apiMocks.fetchTaskReview.mockResolvedValue({
reviewState: { source: "reviewer-agent", summary: { verdict: "REVISE", reviewType: "code", summary: "Needs fixes" }, items, addressing: [] },
automationStatus: null,
emptyMessage: null,
});
apiMocks.reviseTaskReviewItems.mockResolvedValue({ task: makeTask(), reviewState: { source: "reviewer-agent", items: [], addressing: [] } });
const renderAt = async (mobile: boolean) => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockReturnValue({ matches: mobile, addEventListener: vi.fn(), removeEventListener: vi.fn() }),
});
return renderWithAct(<TaskReviewTab task={task} addToast={vi.fn()} />);
};
const desktop = await renderAt(false);
expect(await screen.findAllByRole("checkbox")).toHaveLength(1);
expect(screen.getAllByText("Fixed in review")).toHaveLength(2);
expect(screen.getAllByText("Superseded")).toHaveLength(3);
expect(screen.queryAllByLabelText(/Earlier cleanup|Receipt one/)).toHaveLength(0);
expect(document.querySelectorAll(".task-review-tab__item-selection")).toHaveLength(1);
await act(async () => {
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button", { name: "Request revision" }));
});
expect(apiMocks.reviseTaskReviewItems).toHaveBeenCalledWith(task.id, [expect.objectContaining({ id: "o1" })], undefined);
desktop.unmount();
apiMocks.reviseTaskReviewItems.mockClear();
const mobile = await renderAt(true);
expect(await screen.findAllByRole("checkbox")).toHaveLength(1);
expect(document.querySelectorAll("[data-review-resolution]")).toHaveLength(5);
expect(document.querySelectorAll(".task-review-tab__item-selection")).toHaveLength(1);
mobile.unmount();
});
it("submits reviewer-agent selections through same revision action", async () => {
const task = makeTask();
apiMocks.fetchTaskReview.mockResolvedValue({

View File

@@ -2905,6 +2905,54 @@ describe("POST /tasks/:id/review/address", () => {
expect(store.getAgentLogs).not.toHaveBeenCalled();
});
it("projects resolution and rejects resolved workflow findings from revision requests", async () => {
const task = {
...FAKE_TASK_DETAIL,
id: "FN-8956",
column: "in-review",
status: "awaiting-user-review",
assignedAgentId: null,
sessionFile: null,
workflowStepResults: [{
workflowStepId: "custom-code-review",
workflowStepName: "Code review",
source: "node",
status: "failed",
reviewKind: "code",
verdict: "REVISE",
output: "Structured review findings",
findings: [
{ id: "receipt", title: "Receipt", body: "Fixed in review", resolution: "resolved-in-review" },
{ id: "stale", title: "Stale", body: "Fixed later", resolution: "superseded" },
{ id: "open", title: "Open", body: "Still needs work" },
],
}],
};
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
authorizeMarkedTopLevelReviewNodes({ id: "custom-code-review" });
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "sc-8956" });
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...task, column: "in-progress", status: null });
const review = await REQUEST(buildApp(), "GET", "/api/tasks/FN-8956/review");
expect(review.status).toBe(200);
expect(review.body.items).toEqual(expect.arrayContaining([
expect.objectContaining({ resolution: "resolved-in-review" }),
expect.objectContaining({ resolution: "superseded" }),
expect.not.objectContaining({ resolution: expect.anything() }),
]));
const byBody = new Map(review.body.items.map((item: { itemId: string; body: string }) => [item.body, item.itemId]));
const resolved = await REQUEST(buildApp(), "POST", "/api/tasks/FN-8956/review/address", JSON.stringify({
selectedItems: [{ id: byBody.get("Fixed in review"), source: "reviewer-agent" }],
}), { "Content-Type": "application/json" });
expect(resolved.status).toBe(400);
const open = await REQUEST(buildApp(), "POST", "/api/tasks/FN-8956/review/address", JSON.stringify({
selectedItems: [{ id: byBody.get("Still needs work"), source: "reviewer-agent" }],
}), { "Content-Type": "application/json" });
expect(open.status).toBe(200);
});
it("does not expose materialized template identities even when manually marked", async () => {
const taskWithMaterializedResult = {
...FAKE_TASK_DETAIL,

View File

@@ -902,7 +902,7 @@ function getWorkflowReviewKind(
return undefined;
}
function buildWorkflowReviewItemId(task: Task, result: WorkflowStepResult, findingId?: string): string {
function buildWorkflowReviewItemId(task: Task, result: WorkflowStepResult, findingId?: string, resolution?: string): string {
const identity = JSON.stringify({
taskId: task.id,
workflowStepId: result.workflowStepId,
@@ -916,6 +916,7 @@ function buildWorkflowReviewItemId(task: Task, result: WorkflowStepResult, findi
output: result.output,
notes: result.notes,
findingId,
resolution,
});
return `workflow-review-${createHash("sha256").update(identity).digest("hex").slice(0, 24)}`;
}
@@ -929,7 +930,7 @@ async function buildWorkflowReviewItems(task: Task, store: TaskStore): Promise<T
const timestamp = result.completedAt ?? result.startedAt ?? task.updatedAt ?? task.createdAt;
if (result.findings?.length) {
return result.findings.map((finding) => ({
itemId: buildWorkflowReviewItemId(task, result, finding.id),
itemId: buildWorkflowReviewItemId(task, result, finding.id, finding.resolution),
sourceMode: "reviewer-agent" as const,
title: finding.title,
body: finding.body,
@@ -939,6 +940,7 @@ async function buildWorkflowReviewItems(task: Task, store: TaskStore): Promise<T
...(finding.filePath ? { filePath: finding.filePath } : {}),
...(finding.line ? { line: finding.line } : {}),
...(finding.severity ? { severity: finding.severity } : {}),
...(finding.resolution ? { resolution: finding.resolution } : {}),
reviewState: result.verdict,
verdict: result.verdict,
reviewType,
@@ -6704,6 +6706,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
path: item.filePath,
line: item.line,
severity: item.severity,
resolution: item.resolution,
threadId: item.threadId,
htmlUrl: item.url,
state: item.reviewState ?? undefined,
@@ -6741,6 +6744,13 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
const canonicalById = new Map(reviewState.items.map((item) => [item.id, item] as const));
const resolvedSelection = selectedItems.find((selected) => {
const item = canonicalById.get(selected.id);
return item?.resolution === "resolved-in-review" || item?.resolution === "superseded";
});
if (resolvedSelection) {
throw badRequest("Review items already resolved during review cannot be selected for revision");
}
const canonicalSelections = selectedItems.map((selected) => {
const item = canonicalById.get(selected.id);
if (!item) throw badRequest("selectedItems must reference existing review items");

View File

@@ -0,0 +1,163 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WorkflowGraphExecutor } from "../workflows/workflow-graph-executor.js";
import { persistWorkflowStepResult } from "../executor/execute-workflow-graph.js";
import { TaskExecutor } from "../executor.js";
import { createMockStore, mockedExistsSync, resetExecutorMocks } from "./executor-test-helpers.js";
const RAW_REVIEW = JSON.stringify({
verdict: "REVISE",
notes: "later review",
findings: [
{ id: "r1", title: "Receipt", body: "Fixed: explicit catch", resolution: "resolved-in-review" },
{ id: "r2", title: "Receipt", body: "Fixed: timeout budget", resolution: "resolved-in-review" },
{ id: "o1", title: "Open", body: "The only actionable finding" },
],
supersededFindingSourceWorkflowStepId: "cleanup-review",
supersededFindingIds: ["c1", "c2", "c3"],
});
function task(workflowStepResults = []) {
return {
id: "FN-8956", title: "Review findings", description: "test", column: "in-progress",
dependencies: [], steps: [], currentStep: 0, log: [], prompt: "# Task", worktree: "/tmp/test-worktree", branch: "fusion/FN-8956",
createdAt: "2026-08-12T00:00:00.000Z", updatedAt: "2026-08-12T00:00:00.000Z", workflowStepResults,
};
}
const earlierResult = () => ({
workflowStepId: "cleanup-review", workflowStepName: "Cleanup review", phase: "pre-merge", source: "node", status: "passed", reviewKind: "code",
findings: [
{ id: "c1", title: "Cleanup one", body: "still present" },
{ id: "c2", title: "Cleanup two", body: "still present" },
{ id: "c3", title: "Cleanup three", body: "still present" },
], startedAt: "2026-08-12T00:00:00.000Z", completedAt: "2026-08-12T00:01:00.000Z",
});
function reviewGraph(node) {
return {
version: "v2", name: "review carrier", columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [{ id: "start", kind: "start" }, node, { id: "end", kind: "end" }],
edges: [{ from: "start", to: node.id }, { from: node.id, to: "end" }],
};
}
function sink(row, options = {}) {
const store = {
getTask: vi.fn(async () => row),
updateTask: vi.fn(async (_id, patch) => Object.assign(row, patch)),
isBackendMode: vi.fn(() => options.backend ?? false),
withPlanningLifecycleLock: vi.fn(async (_id, fn) => fn()),
lockCurrentPlanWhilePlanningLocked: vi.fn(async () => {}),
reconcileSpecDriftWhilePlanningLocked: vi.fn(async () => {}),
};
return {
row,
store,
record: (id, result) => persistWorkflowStepResult({
store,
getRunContextFor: () => undefined,
readTaskArtifact: async () => "# Task",
} as any, id, result),
};
}
async function declaredScriptOutcome() {
const store = createMockStore();
store.getTask.mockResolvedValue(task());
store.getSettings.mockResolvedValue({ autoMerge: false, experimentalFeatures: { workflowGraphExecutor: true } });
const executor = new TaskExecutor(store, "/tmp/test");
vi.spyOn(executor as any, "executeScriptWorkflowStep").mockResolvedValue({ success: true, output: RAW_REVIEW });
return (executor as any).runGraphCustomNode(
{ id: "code-script", kind: "script", config: { scriptName: "review", reviewKind: "code" } }, task(), {}, undefined,
);
}
/*
FNXC:WorkflowReviewFindings 2026-08-11-20:10:
Supersession is only safe when the production carrier is covered end to end: custom-review parsing feeds a graph
writer, and the shared persistence sink marks earlier durable findings in that same update. The unmarked control
proves arbitrary script stdout cannot become a cross-lane write capability.
*/
describe("review finding supersession production carrier", () => {
beforeEach(() => {
resetExecutorMocks();
mockedExistsSync.mockReturnValue(true);
});
it("carries declared scripts through the ordinary graph writer and stamps only earlier findings at the sink", async () => {
const outcome = await declaredScriptOutcome();
const persisted = sink(task([earlierResult()]));
const graph = new WorkflowGraphExecutor({
runCustomNode: async () => outcome,
recordWorkflowStepResult: persisted.record,
});
await graph.run(task([earlierResult()]), { experimentalFeatures: { workflowGraphExecutor: true } }, reviewGraph({
id: "code-script", kind: "script", config: { name: "Code script", reviewKind: "code" },
}));
const results = persisted.row.workflowStepResults;
expect(results.find((result) => result.workflowStepId === "code-script")).toMatchObject({
supersededFindingSourceWorkflowStepId: "cleanup-review",
supersededFindingIds: ["c1", "c2", "c3"],
});
expect(results[0].findings.map((finding) => finding.resolution)).toEqual(["superseded", "superseded", "superseded"]);
expect(results.find((result) => result.workflowStepId === "code-script").findings).toEqual(expect.arrayContaining([
expect.objectContaining({ id: "r1", resolution: "resolved-in-review" }),
expect.objectContaining({ id: "o1" }),
]));
});
it("does not let identical unmarked script output reach either graph writer field or persistence mutation", async () => {
const outcome = await declaredScriptOutcome();
const persisted = sink(task([earlierResult()]));
const graph = new WorkflowGraphExecutor({
runCustomNode: async () => outcome,
recordWorkflowStepResult: persisted.record,
});
await graph.run(task([earlierResult()]), { experimentalFeatures: { workflowGraphExecutor: true } }, reviewGraph({
id: "plain-script", kind: "script", config: { name: "Plain script" },
}));
// Unmarked scripts do not qualify for graph review-progress recording at all.
expect(persisted.row.workflowStepResults).toHaveLength(1);
expect(persisted.row.workflowStepResults[0].findings).not.toContainEqual(expect.objectContaining({ resolution: "superseded" }));
});
it("carries prompt and optional-group exits through their distinct graph writers into the same sink", async () => {
const patch = { findings: JSON.parse(RAW_REVIEW).findings, supersededFindingSourceWorkflowStepId: "cleanup-review", supersededFindingIds: ["c1", "c2", "c3"] };
for (const node of [
{ id: "prompt-review", kind: "prompt", config: { name: "Prompt review", reviewKind: "code" } },
{
id: "group-review", kind: "optional-group", config: {
name: "Group review", reviewKind: "code", defaultOn: true,
template: { nodes: [{ id: "inside", kind: "prompt", config: { reviewKind: "code" } }], edges: [] },
},
},
]) {
const persisted = sink(task([earlierResult()]));
const graph = new WorkflowGraphExecutor({
handlers: { prompt: async () => ({ outcome: "success", value: "APPROVE", contextPatch: patch }) },
recordWorkflowStepResult: persisted.record,
});
await graph.run(task([earlierResult()]), { experimentalFeatures: { workflowGraphExecutor: true } }, reviewGraph(node));
expect(persisted.row.workflowStepResults[0].findings.map((finding) => finding.resolution)).toEqual(["superseded", "superseded", "superseded"]);
expect(persisted.row.workflowStepResults.find((result) => result.workflowStepId === node.id)).toMatchObject({
supersededFindingSourceWorkflowStepId: "cleanup-review",
supersededFindingIds: ["c1", "c2", "c3"],
});
}
});
it("applies a Plan Review claim within the planning lifecycle lock rather than a second update", async () => {
const persisted = sink(task([earlierResult()]), { backend: true });
await persisted.record("FN-8956", {
workflowStepId: "plan-review", workflowStepName: "Plan Review", phase: "pre-merge", source: "optional-group", status: "passed",
reviewKind: "plan", verdict: "APPROVE", supersededFindingSourceWorkflowStepId: "cleanup-review", supersededFindingIds: ["c1"], startedAt: "2026-08-12T00:00:00.000Z", completedAt: "2026-08-12T00:01:00.000Z",
});
expect(persisted.store.withPlanningLifecycleLock).toHaveBeenCalledOnce();
expect(persisted.store.updateTask).toHaveBeenCalledOnce();
expect(persisted.row.workflowStepResults[0].findings[0]).toMatchObject({ id: "c1", resolution: "superseded" });
});
});

View File

@@ -72,6 +72,28 @@ describe("injectWorkflowStepFailureInstructions", () => {
expect(content).toContain("Original plan body.");
});
it("keeps receipts out of actionable findings while preserving a do-not-redo audit block", async () => {
await injectWorkflowStepFailureInstructions(
store,
task,
"prose feedback",
"Code Review",
{ attempt: 1, max: 3 },
[
finding({ id: "o1", title: "Open finding", body: "Implement this", severity: "high" }),
finding({ id: "r1", title: "Receipt", body: "Fixed: already committed", severity: "critical", resolution: "resolved-in-review" }),
finding({ id: "c1", title: "Earlier lane", body: "No longer applies", resolution: "superseded" }),
],
);
const content = await readFile(promptPath, "utf-8");
expect(content).toContain("Open finding");
expect(content).toContain("Already resolved during this review pass — do NOT redo");
expect(content).toContain("Fixed: already committed");
expect(content).toContain("No longer applies");
expect(content.indexOf("Open finding")).toBeLessThan(content.indexOf("Already resolved during this review pass"));
});
it("falls back to prose feedback when the step produced no structured findings", async () => {
await injectWorkflowStepFailureInstructions(store, task, "prose feedback", "Verification", { attempt: 1, max: 2 });

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { inferWorkflowStepVerdictFromProse, parseWorkflowStepVerdict } from "../executor.js";
import { inferWorkflowStepVerdictFromProse, parseWorkflowStepOutput, parseWorkflowStepVerdict } from "../executor.js";
import { proseSignalsClearApproval, extractJsonObjectCandidates, classifyReviewVerdictToken } from "../execution/reviewer.js";
describe("parseWorkflowStepVerdict", () => {
@@ -19,6 +19,20 @@ describe("parseWorkflowStepVerdict", () => {
expect(parseWorkflowStepVerdict('{"verdict":"PASS"}')).toBeNull();
});
it("preserves normalized supersession claims with resolved finding receipts", () => {
const parsed = parseWorkflowStepOutput('{"verdict":"REVISE","notes":"reviewed","findings":[{"id":"r1","title":"Receipt","body":"Fixed in this review","resolution":"resolved-in-review"},{"id":"o1","title":"Open","body":"Still needs work"}],"supersededFindingSourceWorkflowStepId":"cleanup-review","supersededFindingIds":[" c1 ",42,"c1","c2"]}');
expect(parsed).toMatchObject({
verdict: "REVISE",
supersededFindingSourceWorkflowStepId: "cleanup-review",
supersededFindingIds: ["c1", "c2"],
findings: [
{ id: "r1", resolution: "resolved-in-review" },
{ id: "o1" },
],
});
expect(parsed.findings?.[1]).not.toHaveProperty("resolution");
});
it("recognizes CLOSE_NO_OP only for the Plan Review optional group", () => {
const response = '{"verdict":"CLOSE_NO_OP","notes":"DUPLICATE: FN-1234 already covered"}';
expect(parseWorkflowStepVerdict(response, { optionalGroupId: "plan-review" })).toMatchObject({

View File

@@ -27,6 +27,7 @@ import {
resolveMaxConsecutiveToolFailureRetries,
resolveWorkflowIrForTask,
upsertWorkflowStepResult,
applySupersededFindingIds,
} from "@fusion/core";
import type { ImplementationExit } from "./implementation-exit.js";
import type { WorkflowGraphTaskRunResult } from "../workflows/workflow-graph-task-runner.js";
@@ -128,6 +129,73 @@ export function clearPrincipalHoldBackoff(taskId: string): void {
principalHoldBackoff.delete(taskId);
}
/**
* Persists graph review evidence and applies explicit prior-lane supersession in
* the same write. Exported for production-shaped graph-writer tests.
*/
export async function persistWorkflowStepResult(
deps: Pick<ExecuteWorkflowGraphDeps, "store" | "getRunContextFor" | "readTaskArtifact">,
taskId: string,
result: CoreWorkflowStepResult,
): Promise<void> {
if (typeof deps.store.updateTask !== "function") return;
try {
const live = await deps.store.getTask(taskId);
const isPlanReviewResult = result.workflowStepId === PLAN_REVIEW_GROUP_ID
|| result.workflowStepName === "Plan Review";
const resultToPersist = isPlanReviewResult
? {
...result,
planReviewAttemptCount: nextPlanReviewAttemptCount(
live?.workflowStepResults?.find((existing) => existing.workflowStepId === result.workflowStepId),
result,
),
}
: result;
const upserted = upsertWorkflowStepResult(
live?.workflowStepResults,
resultToPersist,
isPlanReviewResult ? { maxPriorAttempts: PLAN_REVIEW_FEEDBACK_HISTORY_LIMIT } : undefined,
);
/*
FNXC:WorkflowReviewFindings 2026-08-11-20:30:
Prompt and declared-script review nodes converge at this persistence sink. A supersession claim names
its prior workflow result, so duplicate finding IDs in other review lanes remain actionable.
*/
const existing = applySupersededFindingIds(upserted, resultToPersist.supersededFindingIds ?? [], {
excludeWorkflowStepId: resultToPersist.workflowStepId,
sourceWorkflowStepId: resultToPersist.supersededFindingSourceWorkflowStepId ?? "",
}) ?? upserted;
if (isPlanReviewResult && isPlanReviewSatisfied(resultToPersist) && deps.store.isBackendMode()) {
const prompt = await deps.readTaskArtifact(taskId, "PROMPT.md");
if (!prompt?.trim()) throw new Error("Plan Review cannot accept an unreadable PROMPT.md without a spec lock");
const fingerprint = computePlanApprovalFingerprint(prompt);
await deps.store.withPlanningLifecycleLock(taskId, async () => {
const fresh = await deps.store.getTask(taskId);
const acceptedUpserted = upsertWorkflowStepResult(
fresh.workflowStepResults,
resultToPersist,
{ maxPriorAttempts: PLAN_REVIEW_FEEDBACK_HISTORY_LIMIT },
);
const acceptedResult = applySupersededFindingIds(acceptedUpserted, resultToPersist.supersededFindingIds ?? [], {
excludeWorkflowStepId: resultToPersist.workflowStepId,
sourceWorkflowStepId: resultToPersist.supersededFindingSourceWorkflowStepId ?? "",
}) ?? acceptedUpserted;
await deps.store.lockCurrentPlanWhilePlanningLocked(taskId, fingerprint, prompt);
const accepted = await deps.store.updateTask(taskId, {
workflowStepResults: acceptedResult,
approvedPlanFingerprint: fingerprint,
}, deps.getRunContextFor(taskId));
await deps.store.reconcileSpecDriftWhilePlanningLocked(accepted);
});
} else {
await deps.store.updateTask(taskId, { workflowStepResults: existing }, deps.getRunContextFor(taskId));
}
} catch {
// Result recording is additive visibility — never affect the graph run.
}
}
export async function executeWorkflowGraph(
deps: ExecuteWorkflowGraphDeps,
task: Task,
@@ -464,65 +532,8 @@ export async function executeWorkflowGraph(
holdPlanReviewNoOp: async (nodeTask, suspension) => {
continuation = await deps.holdPlanReviewNoOpContinuation(nodeTask, suspension, continuation, resolvedRunId);
},
recordWorkflowStepResult: async (taskId: string, result: CoreWorkflowStepResult) => {
if (typeof deps.store.updateTask !== "function") return;
try {
const live = await deps.store.getTask(taskId);
/*
FNXC:WorkflowStepResults 2026-07-09-00:25:
FN-7727: route through the shared, pure upsert helper instead of a
bare `existing[idx] = result` replace-in-place — a self-healing
recovery re-run of this same node (e.g. code-review sent back for
fix) must preserve the prior `status:"failed"` entry's history in
`priorAttempts` rather than silently overwriting it.
*/
const isPlanReviewResult = result.workflowStepId === PLAN_REVIEW_GROUP_ID
|| result.workflowStepName === "Plan Review";
const resultToPersist = isPlanReviewResult
? {
...result,
planReviewAttemptCount: nextPlanReviewAttemptCount(
live?.workflowStepResults?.find((existing) => existing.workflowStepId === result.workflowStepId),
result,
),
}
: result;
const existing = upsertWorkflowStepResult(
live?.workflowStepResults,
resultToPersist,
isPlanReviewResult ? { maxPriorAttempts: PLAN_REVIEW_FEEDBACK_HISTORY_LIMIT } : undefined,
);
if (isPlanReviewResult && isPlanReviewSatisfied(resultToPersist) && deps.store.isBackendMode()) {
/*
FNXC:SpecLock 2026-08-09-20:21:
A graph Plan Review pass is an acceptance producer, not merely progress telemetry.
Create its immutable lock before publishing the satisfied result that scheduler and
hold-release consume; a lock failure leaves the old unsatisfied result in place.
*/
const prompt = await deps.readTaskArtifact(taskId, "PROMPT.md");
if (!prompt?.trim()) throw new Error("Plan Review cannot accept an unreadable PROMPT.md without a spec lock");
const fingerprint = computePlanApprovalFingerprint(prompt);
await deps.store.withPlanningLifecycleLock(taskId, async () => {
const fresh = await deps.store.getTask(taskId);
const acceptedResult = upsertWorkflowStepResult(
fresh.workflowStepResults,
resultToPersist,
{ maxPriorAttempts: PLAN_REVIEW_FEEDBACK_HISTORY_LIMIT },
);
await deps.store.lockCurrentPlanWhilePlanningLocked(taskId, fingerprint, prompt);
const accepted = await deps.store.updateTask(taskId, {
workflowStepResults: acceptedResult,
approvedPlanFingerprint: fingerprint,
}, deps.getRunContextFor(taskId));
await deps.store.reconcileSpecDriftWhilePlanningLocked(accepted);
});
} else {
await deps.store.updateTask(taskId, { workflowStepResults: existing }, deps.getRunContextFor(taskId));
}
} catch {
// Result recording is additive visibility — never affect the run.
}
},
recordWorkflowStepResult: (taskId: string, result: CoreWorkflowStepResult) =>
persistWorkflowStepResult(deps, taskId, result),
requestPreMergeOptionalStepFix: (taskId, info) => deps.requestPreMergeOptionalStepFix(taskId, task, info),
// U5c (U1 KTD-1/2/3/12): wire the production lifecycle-move hooks so the
// graph interpreter owns the card's column moves (was reverted in U5a

View File

@@ -18,6 +18,8 @@ import type {
} from "@fusion/core";
import {
applyReviewSeverityGate,
isOpenWorkflowReviewFinding,
MAX_WORKFLOW_REVIEW_FINDINGS,
finalizePlanningSegment,
resolveExecutorFallbackModel,
resolvePersistAgentThinkingLog,
@@ -334,6 +336,20 @@ export async function executeWorkflowStep(
nodeBlockingSeverity: (workflowStep as WorkflowStep & { blockingSeverity?: unknown }).blockingSeverity,
})
: undefined;
/*
* FNXC:WorkflowReviewFindings 2026-08-11-19:39:
* Prior open findings make cross-lane supersession an explicit reviewer claim rather than a
* commit-timestamp inference, so receipts cannot be mistaken for new executor work.
*/
const priorFindings = reviewFindingsContract
? (task.workflowStepResults ?? []).flatMap((result) => result.workflowStepId === workflowStep.id
? []
: (result.findings ?? []).filter(isOpenWorkflowReviewFinding).map((finding) => ({ finding, result })))
.slice(0, MAX_WORKFLOW_REVIEW_FINDINGS)
: [];
const priorFindingsBlock = priorFindings.length > 0
? `\n\n ## Prior Findings In This Review Pass\n\n${priorFindings.map(({ finding, result }) => `- [${result.workflowStepId}] ${finding.id} — [${finding.severity ?? "unclassified"}] ${finding.title}${finding.filePath ? ` (${finding.filePath}${finding.line ? `:${finding.line}` : ""})` : ""}`).join("\n")}`
: "";
const blockingSeverityRule = reviewBlockingSeverity === undefined || reviewBlockingSeverity === "any"
? ""
: reviewBlockingSeverity === "critical"
@@ -347,14 +363,14 @@ export async function executeWorkflowStep(
When your review is complete, your final line MUST be a single JSON object (no markdown fences):
${reviewFindingsContract
? "{\"verdict\":\"APPROVE|APPROVE_WITH_NOTES|REVISE\",\"notes\":\"...\",\"findings\":[{\"id\":\"stable-id\",\"title\":\"concise issue\",\"body\":\"actionable detail\",\"filePath\":\"optional/path\",\"line\":1,\"severity\":\"low|medium|high|critical\"}]}"
? "{\"verdict\":\"APPROVE|APPROVE_WITH_NOTES|REVISE\",\"notes\":\"...\",\"findings\":[{\"id\":\"stable-id\",\"title\":\"concise issue\",\"body\":\"actionable detail\",\"filePath\":\"optional/path\",\"line\":1,\"severity\":\"low|medium|high|critical\",\"resolution\":\"open|resolved-in-review|superseded\"}],\"supersededFindingSourceWorkflowStepId\":\"prior-review-step-id\",\"supersededFindingIds\":[\"prior-finding-id\"]}"
: "{\"verdict\":\"APPROVE|APPROVE_WITH_NOTES|REVISE\",\"notes\":\"...\"}"}
Rules:
- Output exactly one trailing JSON object and stop.
- verdict must be exactly APPROVE, APPROVE_WITH_NOTES, or REVISE.
- notes should be concise and actionable. Use an empty string when there are no notes.
- For out-of-scope fast-bail responses, use: {"verdict":"APPROVE","notes":"out of scope: no UI files changed"}${reviewFindingsContract ? "\n - Every finding MUST carry a `severity`. Put each blocking issue in `findings` — prose in `notes` alone does not block." : ""}${blockingSeverityRule}
- For out-of-scope fast-bail responses, use: {"verdict":"APPROVE","notes":"out of scope: no UI files changed"}${reviewFindingsContract ? "\n - Every finding MUST carry a `severity`. Put each blocking issue in `findings` — prose in `notes` alone does not block.\n - Omit resolution (or use open) for work still needed; use resolved-in-review only for an issue you fixed in this session.\n - supersededFindingIds may list only IDs from one named Prior Findings result that you re-verified no longer apply; include that result’s workflow step ID in supersededFindingSourceWorkflowStepId; never list your own findings." : ""}${blockingSeverityRule}
Backward compat fallback: if JSON is unavailable, you may still begin output with REQUEST REVISION to request changes.`
: `
@@ -375,7 +391,7 @@ export async function executeWorkflowStep(
- If you find an in-scope issue you can fix safely, edit the relevant files in this same session, run the smallest relevant verification, and then return APPROVE or APPROVE_WITH_NOTES.
- Return REVISE only when the issue is still present, cannot be safely fixed in this reviewer session, needs broader executor remediation, or needs user input.
- Plan Review may use fn_task_prompt_write to replace the task's PROMPT.md with the complete revised plan. Do not implement product code from Plan Review.
- Code Review and Browser Verification may fix implementation issues inside the assigned task worktree and should mention the fix in notes.`
- Code Review and Browser Verification may fix implementation issues inside the assigned task worktree. Report each self-fixed issue as a finding with resolution resolved-in-review; list a fixed prior-lane finding in supersededFindingIds.`
: "";
const systemPrompt = `You are a workflow step agent executing: ${workflowStep.name}
@@ -385,7 +401,7 @@ export async function executeWorkflowStep(
- Task Description: ${task.description}
- Worktree: ${worktreePath}
${scopeBlock}${workflowStepUserCommentSection ? `\n\n${workflowStepUserCommentSection}` : ""}
${scopeBlock}${workflowStepUserCommentSection ? `\n\n${workflowStepUserCommentSection}` : ""}${priorFindingsBlock}
Your role:
- Execute this workflow step exactly as scoped.
@@ -815,6 +831,7 @@ export async function executeWorkflowStep(
verdict: effectiveVerdict,
notes: parsed.notes,
...(parsed.findings ? { findings: parsed.findings } : {}),
...(parsed.supersededFindingSourceWorkflowStepId && parsed.supersededFindingIds ? { supersededFindingSourceWorkflowStepId: parsed.supersededFindingSourceWorkflowStepId, supersededFindingIds: parsed.supersededFindingIds } : {}),
};
}

View File

@@ -459,6 +459,9 @@ export async function runGraphCustomNode(
if (declaredReviewKind && typeof outcome.output === "string") {
const parsedReviewOutput = parseWorkflowStepOutput(outcome.output, { requireVerdict: false });
if (parsedReviewOutput.findings?.length) outcome = { ...outcome, findings: parsedReviewOutput.findings };
if (parsedReviewOutput.supersededFindingIds?.length && parsedReviewOutput.supersededFindingSourceWorkflowStepId && !outcome.supersededFindingIds?.length) {
outcome = { ...outcome, supersededFindingSourceWorkflowStepId: parsedReviewOutput.supersededFindingSourceWorkflowStepId, supersededFindingIds: parsedReviewOutput.supersededFindingIds };
}
}
// Skill-emitted await-input (U6): if the skill asked the user a blocking
@@ -500,6 +503,10 @@ export async function runGraphCustomNode(
if (typeof stepNotes === "string" && stepNotes) contextPatch.notes = stepNotes;
const stepFindings = outcome.findings;
if (stepFindings?.length) contextPatch.findings = stepFindings;
if (outcome.supersededFindingIds?.length && outcome.supersededFindingSourceWorkflowStepId) {
contextPatch.supersededFindingSourceWorkflowStepId = outcome.supersededFindingSourceWorkflowStepId;
contextPatch.supersededFindingIds = outcome.supersededFindingIds;
}
if (cfg.summaryTarget === "task" && typeof stepOutput === "string" && stepOutput.trim()) {
/*
* FNXC:WorkflowCompletion 2026-06-29-11:09:

View File

@@ -6,7 +6,7 @@
import { join } from "node:path";
import { readFile, writeFile } from "node:fs/promises";
import type { Task, TaskStore, WorkflowReviewFinding } from "@fusion/core";
import { formatFindingsByPriority } from "@fusion/core";
import { formatFindingsByPriority, formatResolvedFindings, isOpenWorkflowReviewFinding } from "@fusion/core";
import { executorLog } from "../logger.js";
import { buildWorkflowFailureScopeGuard } from "./workflow-failure-scope-guard.js";
@@ -39,6 +39,7 @@ export async function injectReviewAdvisoryNotes(
stepName: string,
findings: WorkflowReviewFinding[],
): Promise<void> {
findings = findings.filter(isOpenWorkflowReviewFinding);
if (findings.length === 0) return;
const promptPath = join(store.getFusionDir(), "tasks", task.id, "PROMPT.md");
let content: string;
@@ -93,12 +94,13 @@ export async function injectWorkflowStepFailureInstructions(
const failureSectionHeader = "## Workflow Step Failure";
const scopeGuard = buildWorkflowFailureScopeGuard(task, content);
const prioritized = findings?.length ? formatFindingsByPriority(findings) : "";
const resolved = findings?.length ? formatResolvedFindings(findings) : "";
const feedbackBlock = prioritized
? `**Findings:**
${prioritized}`
${prioritized}${resolved ? `\n\n${resolved}` : ""}`
: `**Failure Feedback:**
${failureFeedback}`;
${failureFeedback}${resolved ? `\n\n${resolved}` : ""}`;
/*
* FNXC:ReviewSeverityGate 2026-08-10-17:33:
* The closing instruction sanctions an explicit DECLINE with rationale. Previously the only sanctioned

View File

@@ -7,7 +7,7 @@
* review groups and prose cannot open a terminal lifecycle path.
*/
import { proseSignalsClearApproval, extractJsonObjectCandidates } from "../execution/reviewer.js";
import { normalizeWorkflowReviewFindings, PLAN_REVIEW_GROUP_ID, type WorkflowReviewFinding } from "@fusion/core";
import { normalizeSupersededFindingIds, normalizeWorkflowReviewFindings, PLAN_REVIEW_GROUP_ID, type WorkflowReviewFinding } from "@fusion/core";
/** Machine-readable workflow-step verdicts, including Plan Review CLOSE_NO_OP. */
export type WorkflowStepVerdict = "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE" | "CLOSE_NO_OP";
@@ -58,6 +58,10 @@ export interface WorkflowStepOutcome {
notes?: string;
/** Normalized independently actionable feedback from a review-kind node. */
findings?: WorkflowReviewFinding[];
/** Specific prior result containing the findings this review step claims are superseded. */
supersededFindingSourceWorkflowStepId?: string;
/** Explicit prior-lane finding IDs this review step claims are now superseded. */
supersededFindingIds?: string[];
/** Set when the call exceeded `settings.workflowStepTimeoutMs`. Signals the
* caller to escalate to the fallback model rather than treat the failure
* as a generic revision request. */
@@ -81,7 +85,7 @@ export type WorkflowStepResult =
export function parseWorkflowStepVerdict(
rawOutput: string,
options: { optionalGroupId?: string } = {},
): { verdict: WorkflowStepVerdict; notes: string; findings?: WorkflowReviewFinding[] } | null {
): { verdict: WorkflowStepVerdict; notes: string; findings?: WorkflowReviewFinding[]; supersededFindingSourceWorkflowStepId?: string; supersededFindingIds?: string[] } | null {
const trimmed = rawOutput.trim();
const candidates: string[] = [];
const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)];
@@ -96,7 +100,7 @@ export function parseWorkflowStepVerdict(
for (let i = candidates.length - 1; i >= 0; i -= 1) {
try {
const parsed = JSON.parse(candidates[i]) as { verdict?: unknown; notes?: unknown; findings?: unknown };
const parsed = JSON.parse(candidates[i]) as { verdict?: unknown; notes?: unknown; findings?: unknown; supersededFindingIds?: unknown; supersededFindingSourceWorkflowStepId?: unknown };
if (!parsed || typeof parsed.verdict !== "string") continue;
/*
FNXC:ReviewLeniency 2026-07-01-23:30:
@@ -123,10 +127,14 @@ export function parseWorkflowStepVerdict(
entries never poison the step outcome or Review-tab selection contract.
*/
const findings = normalizeWorkflowReviewFindings(parsed.findings);
const supersededFindingIds = normalizeSupersededFindingIds(parsed.supersededFindingIds);
const supersededFindingSourceWorkflowStepId = normalizeSupersededFindingIds([parsed.supersededFindingSourceWorkflowStepId])?.[0];
return {
verdict,
notes: typeof parsed.notes === "string" ? parsed.notes : "",
...(findings ? { findings } : {}),
...(supersededFindingSourceWorkflowStepId && supersededFindingIds ? { supersededFindingSourceWorkflowStepId } : {}),
...(supersededFindingSourceWorkflowStepId && supersededFindingIds ? { supersededFindingIds } : {}),
};
} catch {
// continue
@@ -181,6 +189,8 @@ export function parseWorkflowStepOutput(rawOutput: string, options: { requireVer
verdict?: WorkflowStepVerdict;
notes?: string;
findings?: WorkflowReviewFinding[];
supersededFindingSourceWorkflowStepId?: string;
supersededFindingIds?: string[];
malformed?: boolean;
} {
const trimmed = rawOutput.trim();
@@ -191,6 +201,7 @@ export function parseWorkflowStepOutput(rawOutput: string, options: { requireVer
verdict: parsed.verdict,
notes: parsed.notes,
...(parsed.findings ? { findings: parsed.findings } : {}),
...(parsed.supersededFindingSourceWorkflowStepId && parsed.supersededFindingIds ? { supersededFindingSourceWorkflowStepId: parsed.supersededFindingSourceWorkflowStepId, supersededFindingIds: parsed.supersededFindingIds } : {}),
};
}

View File

@@ -1072,6 +1072,12 @@ export class WorkflowGraphExecutor {
const stepFindings = this.workflowReviewKind(node) && Array.isArray(exitContextPatch?.findings)
? exitContextPatch.findings as WorkflowStepResult["findings"]
: undefined;
const supersededFindingSourceWorkflowStepId = this.workflowReviewKind(node) && typeof exitContextPatch?.supersededFindingSourceWorkflowStepId === "string"
? exitContextPatch.supersededFindingSourceWorkflowStepId
: undefined;
const supersededFindingIds = supersededFindingSourceWorkflowStepId && this.workflowReviewKind(node) && Array.isArray(exitContextPatch?.supersededFindingIds)
? exitContextPatch.supersededFindingIds.filter((id): id is string => typeof id === "string")
: undefined;
/*
* FNXC:WorkflowStepResults 2026-07-07-00:00:
* A non-verdict `stepStatus === "failed"` (dispatch/infra exception, not a
@@ -1107,6 +1113,7 @@ export class WorkflowGraphExecutor {
...(stepOutput !== undefined ? { output: stepOutput } : {}),
...(stepNotes !== undefined ? { notes: stepNotes } : {}),
...(stepFindings?.length ? { findings: stepFindings } : {}),
...(supersededFindingSourceWorkflowStepId && supersededFindingIds?.length ? { supersededFindingSourceWorkflowStepId, supersededFindingIds } : {}),
startedAt: stepStartedAt,
completedAt: new Date().toISOString(),
});
@@ -2007,6 +2014,13 @@ export class WorkflowGraphExecutor {
const findings = this.workflowReviewKind(node) && Array.isArray(contextPatch.findings)
? contextPatch.findings as WorkflowStepResult["findings"]
: undefined;
/* FNXC:WorkflowReviewFindings 2026-08-11-19:39: This ordinary writer and the optional-group exit writer above carry explicit review supersession claims to the shared persistence sink. */
const supersededFindingSourceWorkflowStepId = this.workflowReviewKind(node) && typeof contextPatch.supersededFindingSourceWorkflowStepId === "string"
? contextPatch.supersededFindingSourceWorkflowStepId
: undefined;
const supersededFindingIds = supersededFindingSourceWorkflowStepId && this.workflowReviewKind(node) && Array.isArray(contextPatch.supersededFindingIds)
? contextPatch.supersededFindingIds.filter((id): id is string => typeof id === "string")
: undefined;
/*
* FNXC:WorkflowStepResults 2026-07-07-00:00:
* CE `source:"node"` skill-gate failures share the same `(no feedback
@@ -2032,6 +2046,7 @@ export class WorkflowGraphExecutor {
...(output !== undefined ? { output } : {}),
...(notes !== undefined ? { notes } : {}),
...(findings?.length ? { findings } : {}),
...(supersededFindingSourceWorkflowStepId && supersededFindingIds?.length ? { supersededFindingSourceWorkflowStepId, supersededFindingIds } : {}),
startedAt: started?.startedAt ?? new Date().toISOString(),
completedAt: new Date().toISOString(),
});

View File

@@ -8382,7 +8382,9 @@
"showRawText": "Show raw text",
"startedAtSep": " · Started: {{timestamp}}",
"updateFailed": "Failed to update {{taskId}}: {{error}}",
"upToDate": "Up to date"
"upToDate": "Up to date",
"fixedInReview": "Fixed in review",
"superseded": "Superseded"
},
"tasks": {
"addTaskPlaceholder": "Add a task...",

View File

@@ -8368,7 +8368,9 @@
"addressPrFeedback": "",
"addressPrFeedbackFailed": "",
"addressPrFeedbackStarted": "",
"addressingPrFeedback": ""
"addressingPrFeedback": "",
"fixedInReview": "Corregido durante la revisión",
"superseded": "Reemplazado"
},
"tasks": {
"addTaskPlaceholder": "Añadir una tarea...",

View File

@@ -8368,7 +8368,9 @@
"addressPrFeedback": "",
"addressPrFeedbackFailed": "",
"addressPrFeedbackStarted": "",
"addressingPrFeedback": ""
"addressingPrFeedback": "",
"fixedInReview": "Corrigé pendant la révision",
"superseded": "Remplacé"
},
"tasks": {
"addTaskPlaceholder": "Ajouter une tâche…",

View File

@@ -8368,7 +8368,9 @@
"addressPrFeedback": "",
"addressPrFeedbackFailed": "",
"addressPrFeedbackStarted": "",
"addressingPrFeedback": ""
"addressingPrFeedback": "",
"fixedInReview": "검토 중 수정됨",
"superseded": "대체됨"
},
"tasks": {
"addTaskPlaceholder": "작업 추가...",

View File

@@ -8364,7 +8364,9 @@
"showRawText": "Mostrar texto bruto",
"startedAtSep": " · Iniciado: {{timestamp}}",
"updateFailed": "Falha ao atualizar {{taskId}}: {{error}}",
"upToDate": "Atualizado"
"upToDate": "Atualizado",
"fixedInReview": "Corrigido na revisão",
"superseded": "Substituído"
},
"tasks": {
"addTaskPlaceholder": "Adicionar uma tarefa...",

View File

@@ -8368,7 +8368,9 @@
"addressPrFeedback": "",
"addressPrFeedbackFailed": "",
"addressPrFeedbackStarted": "",
"addressingPrFeedback": ""
"addressingPrFeedback": "",
"fixedInReview": "已在审查中修复",
"superseded": "已被取代"
},
"tasks": {
"addTaskPlaceholder": "添加任务……",

View File

@@ -8368,7 +8368,9 @@
"addressPrFeedback": "",
"addressPrFeedbackFailed": "",
"addressPrFeedbackStarted": "",
"addressingPrFeedback": ""
"addressingPrFeedback": "",
"fixedInReview": "已在審查中修正",
"superseded": "已被取代"
},
"tasks": {
"addTaskPlaceholder": "新增任務……",