slice 5: task-card parser + background worker
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:
co-authored by
Claude Opus 4.7
parent
565f6cae65
commit
f40ec075a5
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user