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:
@@ -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.
|
||||
let (_topic, already, _idx) = ctx;
|
||||
completed.retain(|id| !already.contains(id));
|
||||
if completed.is_empty() {
|
||||
return;
|
||||
if !completed.is_empty() {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
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}");
|
||||
|
||||
// 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
|
||||
/// `COMPLETED: INT-01`, `COMPLETED: INT-01, INT-02`, or `- COMPLETED: `INT-01``.
|
||||
/// De-duplicates within a single output.
|
||||
|
||||
Reference in New Issue
Block a user