loops: reorder rationale extraction — REORDER: markers logged per iteration
ci / gates (push) Successful in 7s
ci / frontend (push) Successful in 27s
ci / rust (push) Failing after 43s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Coordinator can now log WHY it worked on an INT-XX out of order
("REORDER: INT-05 before INT-04 because prereq X is unmet") and the
completion hook captures each rationale as an append-only event on
the loop. Sets up a reviewable timeline of when the plan was
adjusted, independent of the underlying `consumed_int_ids` advance.

Migration 0043:
- loops.reorder_events JSONB NOT NULL DEFAULT '[]'::jsonb — append-
  only array of {run_id, iteration, text, ts}. Kept on the loop row
  (rather than a dedicated table) so the mini-timeline is one read
  away from the loop card.

Backend:
- topology_worker::parse_reorder_rationale — line matcher symmetric
  with parse_completed_int_ids. Tolerates list dashes / prefixes /
  markdown emphasis; case-insensitive marker match, preserves case of
  the rationale text.
- cm_db::repo::loops::append_reorder_event — one INSERT-like append
  per rationale, uses jsonb_build_object with postgres now() so ts is
  wall-clock canonical (no client-clock skew).
- topology_runs::iteration_for_run — new helper so events carry the
  iteration index.
- routes::loops::compose_iteration_task — coordinator prompt now
  explicitly asks for `REORDER: <one-sentence>` at the top of the
  first substantive turn when working out of order, AND spells out
  that both markers must appear literally with colons (no bold, no
  code fence) so the line parser doesn't miss them.

Non-loop and standalone-loop runs are unaffected — the hook only
fires when the run belongs to a source-bound loop.

Follow-up: expose reorder_events on the loops list endpoint + render
a small collapsed timeline on the LoopsList card.
This commit is contained in:
Omar Sobh
2026-07-09 18:58:55 -07:00
parent a34be33261
commit 0c17de52dd
5 changed files with 121 additions and 7 deletions
+37
View File
@@ -247,6 +247,43 @@ pub async fn set_zeroclaw_container(
Ok(())
}
/// Append one reorder rationale event to the loop's reorder_events
/// jsonb array. Called from the topology_worker completion hook after
/// parsing REORDER: markers out of the run output. Each event carries
/// the iteration index, run_id, text, and now() timestamp so a
/// downstream mini-timeline can show WHEN the plan was adjusted and
/// WHY. Idempotent: appending a duplicate text/run_id combo is allowed
/// (rare — indicates the parser matched twice on the same line).
pub async fn append_reorder_event(
pool: &PgPool,
loop_id: Uuid,
run_id: Uuid,
iteration: i32,
text: &str,
) -> Result<(), DbError> {
// Build the event server-side so `ts` uses postgres now() (canonical
// wall clock; avoids skew if callers had stale local clocks).
sqlx::query(
"UPDATE loops
SET reorder_events = reorder_events || jsonb_build_object(
'run_id', $2::text,
'iteration', $3::int,
'text', $4::text,
'ts', to_char(now() AT TIME ZONE 'UTC',
'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')
),
updated_at = now()
WHERE id = $1",
)
.bind(loop_id)
.bind(run_id.to_string())
.bind(iteration)
.bind(text)
.execute(pool)
.await?;
Ok(())
}
/// Atomically append `completed` INT-XX ids to the loop's
/// `consumed_int_ids` array and bump `current_int_index` by the count
/// of NEW ids landed. Existing ids are not re-appended (idempotent on
+13
View File
@@ -194,6 +194,19 @@ pub async fn loop_id_for_run(pool: &PgPool, id: Uuid) -> Result<Option<Uuid>, Db
Ok(row.and_then(|r| r.try_get::<Option<Uuid>, _>("loop_id").ok().flatten()))
}
/// The iteration counter for a loop-bound run. Returns None for chat /
/// research runs (iteration column is nullable). Used by the reorder
/// rationale hook so the mini-timeline can order events by iteration.
pub async fn iteration_for_run(pool: &PgPool, id: Uuid) -> Result<Option<i32>, DbError> {
use sqlx::Row;
let row: Option<sqlx::postgres::PgRow> =
sqlx::query("SELECT iteration FROM topology_runs WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.and_then(|r| r.try_get::<Option<i32>, _>("iteration").ok().flatten()))
}
/// Enqueue a durable run bound to a research topic. `research_topic_id` is
/// stored so `notify_run_completed` can flip the owning topic
/// `processing → reviewing` when its last run terminates (see