loops: reorder rationale extraction — REORDER: markers logged per iteration
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:
@@ -79,10 +79,13 @@ async fn compose_iteration_task(pool: &PgPool, loop_id: Uuid, task_template: &st
|
|||||||
- Already completed: {}.\n\
|
- Already completed: {}.\n\
|
||||||
- Address the NEXT unconsumed INT-XX item in the artifact, in order.\n\
|
- Address the NEXT unconsumed INT-XX item in the artifact, in order.\n\
|
||||||
- If the next item has unmet prerequisites, work on the smallest\n\
|
- If the next item has unmet prerequisites, work on the smallest\n\
|
||||||
unblocking INT-XX instead AND log the reorder rationale in your\n\
|
unblocking INT-XX instead. When you reorder, emit a line\n\
|
||||||
opening turn so the reviewer can trace it.\n\
|
`REORDER: <one-sentence rationale>` at the top of your first\n\
|
||||||
|
substantive turn — the loop indexes these for a review timeline.\n\
|
||||||
- Emit `COMPLETED: INT-<NN>` on its own line at the end of the run\n\
|
- Emit `COMPLETED: INT-<NN>` on its own line at the end of the run\n\
|
||||||
when the item is done — the loop advances on that marker.\n\n\
|
when the item is done — the loop advances on that marker.\n\
|
||||||
|
- Both markers must appear literally with the colon (no bold, no\n\
|
||||||
|
code fence); the parser is line-based.\n\n\
|
||||||
ORIGINAL TASK TEMPLATE:\n{}\n",
|
ORIGINAL TASK TEMPLATE:\n{}\n",
|
||||||
outcome.version, outcome.body_md, current_idx, consumed_list, task_template
|
outcome.version, outcome.body_md, current_idx, consumed_list, task_template
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -233,14 +233,58 @@ async fn advance_loop_after_completion(pool: &PgPool, run_id: Uuid, final_output
|
|||||||
// Drop items already recorded so re-runs don't double-count.
|
// Drop items already recorded so re-runs don't double-count.
|
||||||
let (_topic, already, _idx) = ctx;
|
let (_topic, already, _idx) = ctx;
|
||||||
completed.retain(|id| !already.contains(id));
|
completed.retain(|id| !already.contains(id));
|
||||||
if completed.is_empty() {
|
if !completed.is_empty() {
|
||||||
return;
|
if let Err(e) =
|
||||||
}
|
cm_db::repo::loops::advance_after_completion(pool, loop_id, &completed).await
|
||||||
if let Err(e) = cm_db::repo::loops::advance_after_completion(pool, loop_id, &completed).await {
|
{
|
||||||
eprintln!("topology_worker: loops::advance_after_completion({loop_id}) failed: {e}");
|
eprintln!("topology_worker: loops::advance_after_completion({loop_id}) failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reorder rationale — coordinator emits "REORDER: <text>" when it
|
||||||
|
// works on an INT-XX out of order (usually because a prereq was
|
||||||
|
// unmet). Append each occurrence to the loop's reorder_events
|
||||||
|
// array so a mini-timeline UI can surface the history. Iteration
|
||||||
|
// number comes from topology_runs; -1 if the lookup fails (best-
|
||||||
|
// effort — we still record the event with a sentinel).
|
||||||
|
let iteration = cm_db::repo::topology_runs::iteration_for_run(pool, run_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(Some(-1))
|
||||||
|
.unwrap_or(-1);
|
||||||
|
for text in parse_reorder_rationale(final_output) {
|
||||||
|
if let Err(e) =
|
||||||
|
cm_db::repo::loops::append_reorder_event(pool, loop_id, run_id, iteration, &text).await
|
||||||
|
{
|
||||||
|
eprintln!("topology_worker: loops::append_reorder_event({loop_id}) failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract "REORDER: <text>" rationales — one per line the coordinator
|
||||||
|
/// emits when it works out of order. Same permissive line matcher as
|
||||||
|
/// the completed-marker parser (list dashes, backticks, emphasis).
|
||||||
|
/// Returns the text after the colon, trimmed. Skips empty rationales.
|
||||||
|
fn parse_reorder_rationale(text: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
let normalized = line.trim_start_matches(|c: char| {
|
||||||
|
c.is_whitespace() || c == '-' || c == '*' || c == '#' || c == '>'
|
||||||
|
});
|
||||||
|
let upper = normalized.to_ascii_uppercase();
|
||||||
|
if !upper.starts_with("REORDER:") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Preserve original case of the rationale text — only the
|
||||||
|
// marker matched case-insensitively.
|
||||||
|
let colon = normalized.find(':').map(|i| i + 1).unwrap_or(0);
|
||||||
|
let rationale = normalized[colon..].trim();
|
||||||
|
if !rationale.is_empty() {
|
||||||
|
out.push(rationale.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract stable INT-XX ids from a completion line. Matches
|
/// Extract stable INT-XX ids from a completion line. Matches
|
||||||
/// `COMPLETED: INT-01`, `COMPLETED: INT-01, INT-02`, or `- COMPLETED: `INT-01``.
|
/// `COMPLETED: INT-01`, `COMPLETED: INT-01, INT-02`, or `- COMPLETED: `INT-01``.
|
||||||
/// De-duplicates within a single output.
|
/// De-duplicates within a single output.
|
||||||
|
|||||||
@@ -247,6 +247,43 @@ pub async fn set_zeroclaw_container(
|
|||||||
Ok(())
|
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
|
/// Atomically append `completed` INT-XX ids to the loop's
|
||||||
/// `consumed_int_ids` array and bump `current_int_index` by the count
|
/// `consumed_int_ids` array and bump `current_int_index` by the count
|
||||||
/// of NEW ids landed. Existing ids are not re-appended (idempotent on
|
/// of NEW ids landed. Existing ids are not re-appended (idempotent on
|
||||||
|
|||||||
@@ -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()))
|
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
|
/// Enqueue a durable run bound to a research topic. `research_topic_id` is
|
||||||
/// stored so `notify_run_completed` can flip the owning topic
|
/// stored so `notify_run_completed` can flip the owning topic
|
||||||
/// `processing → reviewing` when its last run terminates (see
|
/// `processing → reviewing` when its last run terminates (see
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Append-only event log of coordinator-issued reorders on a
|
||||||
|
-- research-bound loop. Whenever a run's coordinator emits
|
||||||
|
-- REORDER: INT-05 before INT-04 because prereq X is unmet
|
||||||
|
-- (or any `REORDER: <text>` line in the run's final output), the
|
||||||
|
-- topology_worker completion hook parses it and appends a
|
||||||
|
-- {iteration, run_id, text, ts}
|
||||||
|
-- object to this array. The loop card can then surface a mini-timeline
|
||||||
|
-- of when the plan was adjusted and why — useful when the reviewer
|
||||||
|
-- wants to understand why P0 items landed out of order.
|
||||||
|
--
|
||||||
|
-- JSONB rather than a separate table so the loop row carries its own
|
||||||
|
-- history — reads are already fast (loops list <10ms) and a
|
||||||
|
-- per-iteration event log lives naturally alongside consumed_int_ids +
|
||||||
|
-- current_int_index. If the log grows huge we can promote to a
|
||||||
|
-- dedicated table later.
|
||||||
|
ALTER TABLE loops
|
||||||
|
ADD COLUMN reorder_events JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||||
Reference in New Issue
Block a user