research: reject-with-revision loop (R2)
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 14s
ci / frontend (push) Successful in 28s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Before: reviewer rejected a publish → audit log flipped, topic stayed
in reviewing, no way to feed the critique back into the run pipeline.
Reviewers with revision notes had to eat them or hand-message the
coordinator.

Now: reject accepts an optional `notes` field. When present:
- Persisted on the research_publish_approvals row (migration 0039).
- Topic flips `reviewing → standby` so the next `start_topic` is legal.
- `start_topic` reads the most recent rejected-approval notes for the
  topic and prepends "PRIOR REVIEW NOTES (address these in this
  revision):\n<notes>\n---" to the coordinator task.

Loop closes through the same run pipeline — no new spawn code path,
which means the reviewer's guidance flows through the same
topology_worker, run_events, outcome-writer chain and lands as a fresh
research_outcomes row (versioned, prior drafts preserved). No notes on
reject = legacy behavior (topic stays in reviewing, publish requests
still allowed).

Migration 0039 adds nullable `notes TEXT` to
research_publish_approvals. `decide()` gains a `notes: Option<&str>`
parameter (only one caller, updated inline). New
`latest_rejection_notes(pool, topic_id)` helper for start_topic.

Frontend:
- rejectPublish(id, notes?) now sends a JSON body when notes are
  provided.
- ResearchCanvas reject button opens an inline form with a textarea +
  Cancel/"Send back for revision" pair. Empty notes → plain reject.
- Button label switches: "Send back for revision" when notes present,
  "Reject without notes" when empty.

Follow-up:
- Notes shown in the review UI on the resulting draft so the next
  reviewer sees what changed.
- Multiple rejection rounds — currently only the LATEST rejection's
  notes surface. Accumulating history is a schema-only tweak.
This commit is contained in:
Omar Sobh
2026-07-09 15:56:16 -07:00
parent 4831033910
commit e3011ed025
5 changed files with 191 additions and 21 deletions
@@ -92,6 +92,8 @@ export function ResearchCanvas({
const [topic, setTopic] = useState<TopicDetail | null>(null);
const [pendingApproval, setPendingApproval] = useState<PublishApproval | null>(null);
const [decidingApproval, setDecidingApproval] = useState<"approve" | "reject" | null>(null);
const [rejectFormOpen, setRejectFormOpen] = useState(false);
const [rejectNotes, setRejectNotes] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [acting, setActing] = useState(false);
@@ -208,13 +210,15 @@ export function ResearchCanvas({
? pendingApproval
: null;
async function decide(kind: "approve" | "reject") {
async function decide(kind: "approve" | "reject", notes?: string) {
if (!boundApproval || decidingApproval) return;
setDecidingApproval(kind);
setError(null);
try {
if (kind === "approve") await approvePublish(boundApproval.id);
else await rejectPublish(boundApproval.id);
else await rejectPublish(boundApproval.id, notes);
setRejectFormOpen(false);
setRejectNotes("");
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "decision failed");
@@ -580,8 +584,8 @@ export function ResearchCanvas({
</button>
<button
type="button"
onClick={() => decide("reject")}
disabled={!boundApproval || decidingApproval !== null}
onClick={() => setRejectFormOpen(true)}
disabled={!boundApproval || decidingApproval !== null || rejectFormOpen}
style={{
padding: "9px 16px",
borderRadius: 8,
@@ -591,18 +595,92 @@ export function ResearchCanvas({
fontSize: 13,
fontWeight: 700,
cursor:
boundApproval && decidingApproval === null
boundApproval && decidingApproval === null && !rejectFormOpen
? "pointer"
: "default",
opacity:
!boundApproval || decidingApproval !== null
!boundApproval || decidingApproval !== null || rejectFormOpen
? 0.55
: 1,
}}
>
{decidingApproval === "reject" ? "Rejecting…" : "Reject"}
Reject
</button>
</div>
{rejectFormOpen ? (
<div
style={{
marginTop: 12,
padding: 12,
borderRadius: 8,
border: "1px solid rgba(255,138,122,.3)",
background: "rgba(255,138,122,.06)",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<label style={{ fontSize: 11.5, color: "#cfcfd5", fontWeight: 600 }}>
Revision guidance (optional send back for another pass)
</label>
<textarea
value={rejectNotes}
onChange={(e) => setRejectNotes(e.target.value)}
placeholder="What should the coordinator address in the next iteration?"
rows={4}
autoFocus
style={{
width: "100%",
resize: "vertical",
borderRadius: 6,
border: "1px solid rgba(255,255,255,.12)",
background: "#101014",
color: "#eaeaee",
fontSize: 12.5,
padding: "8px 10px",
outline: "none",
fontFamily: "inherit",
}}
/>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
type="button"
onClick={() => { setRejectFormOpen(false); setRejectNotes(""); }}
disabled={decidingApproval !== null}
style={{
padding: "7px 14px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,.12)",
background: "transparent",
color: "#cfcfd5",
fontSize: 12,
cursor: "pointer",
}}
>
Cancel
</button>
<button
type="button"
onClick={() => decide("reject", rejectNotes)}
disabled={decidingApproval !== null}
style={{
padding: "7px 14px",
borderRadius: 6,
border: 0,
background: rejectNotes.trim() ? "#ff8a7a" : "rgba(255,138,122,.4)",
color: "#1a0808",
fontSize: 12,
fontWeight: 700,
cursor: decidingApproval === null ? "pointer" : "default",
}}
>
{decidingApproval === "reject"
? (rejectNotes.trim() ? "Sending back…" : "Rejecting…")
: (rejectNotes.trim() ? "Send back for revision" : "Reject without notes")}
</button>
</div>
</div>
) : null}
</div>
) : null}
</div>