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
+48 -5
View File
@@ -742,7 +742,7 @@ pub async fn start_topic(
})
.collect::<Vec<_>>()
.join("\n");
let task = build_coordinator_task(
let mut task = build_coordinator_task(
&topic.title,
&topic.outcome_kind,
&topic.description,
@@ -750,6 +750,17 @@ pub async fn start_topic(
&roster_lines,
repo_context.as_ref(),
);
// R2 — reject-with-revision. If the last publish decision was a
// reject with reviewer notes, prepend them to the coordinator task
// as revision guidance. This is what closes the loop: the reviewer's
// critique steers the next iteration through the same run pipeline.
if let Ok(Some(notes)) =
cm_db::repo::research_publish_approvals::latest_rejection_notes(&state.pool, id).await
{
task = format!(
"PRIOR REVIEW NOTES (address these in this revision):\n{notes}\n\n---\n\n{task}"
);
}
let run_id = uuid::Uuid::now_v7();
cm_db::repo::topology_runs::enqueue_run_for_research_topic(
@@ -852,13 +863,18 @@ pub async fn list_pending_publish(
}
/// Shared body of approve + reject. On approve, transition the topic
/// `reviewing → publishing` (and set published_at via set_status). On
/// reject, topic stays put; new requests are allowed.
/// `reviewing → publishing → published` and teardown its container. On
/// reject: audit the decision. If the reject carries revision `notes`,
/// bump the topic `reviewing → standby` so a subsequent `start_topic`
/// spawns a fresh run with the reviewer's guidance folded into the
/// coordinator prompt (R2). Without notes: legacy behavior — topic
/// stays in reviewing, new publish requests are allowed.
async fn decide_publish(
state: AppState,
user: cm_auth::AuthedUser,
id: Uuid,
approve: bool,
notes: Option<String>,
) -> Result<StatusCode, ApiError> {
let approval =
cm_db::repo::research_publish_approvals::get(&state.pool, id, user.workspace_id.as_uuid())
@@ -867,12 +883,17 @@ async fn decide_publish(
if approval.status != "pending" {
return Err(ApiError::Conflict);
}
let notes_ref = notes
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let landed = cm_db::repo::research_publish_approvals::decide(
&state.pool,
id,
user.workspace_id.as_uuid(),
user.user_id.as_uuid(),
approve,
notes_ref,
)
.await?;
// Someone else won the race — treat as a no-op success; the topic
@@ -910,24 +931,46 @@ async fn decide_publish(
// The research work is done — tear down the per-topic team
// container. Artifact-writing already happened during runs.
crate::research_container::teardown(approval.topic_id).await;
} else if notes_ref.is_some() {
// Reject-with-revision (R2): flip the topic back to standby so
// the reviewer's guidance takes effect on the next `start_topic`
// via `latest_rejection_notes` in the coordinator prompt.
cm_db::repo::research_topics::set_status(
&state.pool,
approval.topic_id,
user.workspace_id.as_uuid(),
"standby",
)
.await?;
}
Ok(StatusCode::NO_CONTENT)
}
#[derive(serde::Deserialize, Default)]
pub struct RejectPublishRequest {
/// Optional revision guidance. When present + non-empty, decide_publish
/// bumps the topic back to standby and start_topic reads the notes
/// from the approval row to steer the next coordinator prompt.
#[serde(default)]
pub notes: Option<String>,
}
pub async fn approve_publish(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
decide_publish(state, user, id, true).await
decide_publish(state, user, id, true, None).await
}
pub async fn reject_publish(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
body: Option<Json<RejectPublishRequest>>,
) -> Result<StatusCode, ApiError> {
decide_publish(state, user, id, false).await
let notes = body.and_then(|Json(b)| b.notes);
decide_publish(state, user, id, false, notes).await
}
/// `GET /api/research/:id/artifact` — download the latest outcome as