slice 5: task-card parser + background worker
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 25s
ci / rust (push) Successful in 4m26s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m42s

Watches topology_runs' event stream for the INT-XX marker protocol
(see skills/foundation/int-xx-marker-protocol.md) and materializes
mission_tasks rows with typed status so the canvas Tasks tab renders
a live timeline instead of raw agent chatter.

Migration 0051 adds mission_id + mission_phase_id columns to
topology_runs (nullable) so runs enqueued by a mission phase can be
attributed. Populated by future phase executors; NULL for legacy
research/loops runs (parser skips them cleanly).

New Rust surface:
  - task_card_parser::parse(text) — line-scanner over TASK/WORK/
    HANDOFF/TEST_PASS/TEST_FAIL/REVIEW_APPROVE/REVIEW_BLOCK/COMPLETED
    markers. Strict: exact kind + colon + INT- prefix, no in-prose
    matches, no bold/code-fence wrappers.
  - task_card_parser::apply_for_run(pool, run_id) — reads the run's
    mission binding, walks its event payloads, extracts text/output/
    content/message string fields (matching every ZeroClaw event
    shape we see), parses markers, UPSERTs mission_tasks via the
    (phase_id, external_id) unique key from Slice 1.
  - task_card_worker::spawn — 15s poller over runs updated in the
    last 5 minutes. Idempotent + generous window survives server
    restarts + task-scheduling jitter.

Boot wires the worker after the content loaders. Silent no-op when
mission wiring isn't populated yet.

MarkerKind → status mapping (monotonic-forward):
  TASK           → created
  WORK           → working
  HANDOFF        → validating
  TEST_PASS      → validating
  TEST_FAIL      → failed
  REVIEW_APPROVE → validating
  REVIEW_BLOCK   → failed
  COMPLETED      → complete

Follow-ups:
  - Wire phase executor to populate topology_runs.mission_id +
    mission_phase_id (Slice 6/7/8 work)
  - Assign assigned_agent_id via the event's producing agent alias
    (currently always None)
  - SSE stream on /api/missions/{id}/tasks for live canvas updates
    (currently the canvas polls via mission GET)

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 15:04:57 -07:00
co-authored by Claude Opus 4.7
parent 565f6cae65
commit f40ec075a5
5 changed files with 336 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
//! Background worker that reruns `task_card_parser::apply_for_run`
//! on recently-active topology_runs. Slice 5.
//!
//! Cadence: every 15s, scan runs updated in the last 5 minutes that
//! have mission_id set. Deliberately generous — the parser is
//! idempotent, and paying a tiny bit of extra CPU beats missing a
//! marker because a run completed between polls.
use sqlx::PgPool;
use sqlx::Row;
use std::time::Duration;
use uuid::Uuid;
const POLL_INTERVAL: Duration = Duration::from_secs(15);
/// Look back this far when picking candidate runs. Wider than the
/// poll interval to survive server restarts + task-scheduling jitter.
const LOOKBACK_SECS: i64 = 300;
/// Spawn the polling task. Silent no-op when the mission wiring
/// isn't populated (no runs with mission_id set — the pre-Slice-9
/// legacy paths keep working untouched).
pub fn spawn(pool: PgPool) {
tokio::spawn(async move {
// Small initial delay so we don't fight the migration on boot.
tokio::time::sleep(Duration::from_secs(5)).await;
let mut ticker = tokio::time::interval(POLL_INTERVAL);
// Drop the first tick — interval fires immediately by default.
ticker.tick().await;
loop {
ticker.tick().await;
if let Err(e) = sweep_once(&pool).await {
eprintln!("task_card_worker: sweep failed: {e}");
}
}
});
}
async fn sweep_once(pool: &PgPool) -> Result<(), String> {
let rows = sqlx::query(
"SELECT id
FROM topology_runs
WHERE mission_id IS NOT NULL
AND created_at > now() - make_interval(secs => $1::float)
ORDER BY created_at DESC
LIMIT 200",
)
.bind(LOOKBACK_SECS as f64)
.fetch_all(pool)
.await
.map_err(|e| format!("query candidates: {e}"))?;
for r in rows {
let id: Uuid = r.get("id");
if let Err(e) = crate::task_card_parser::apply_for_run(pool, id).await {
// Per-run failure shouldn't abort the whole sweep.
eprintln!("task_card_worker: apply_for_run({id}) failed: {e}");
}
}
Ok(())
}