Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit a7bda14

Browse files
z23ccclaude
andcommitted
feat: intelligent orchestration engine — service layer, adaptive scheduler, DAG viz, introspection, cross-model review
6-phase upgrade implementing CEO-reviewed plan: Phase 1: flowctl-service crate — extracted lifecycle business logic from CLI, unified CLI/daemon/MCP execution paths. ServiceError enum, connection management. Phase 2: CPM critical path scheduling + domain-based adaptive parallelism. Cold start falls back to fixed parallelism until 5+ completions per domain. Phase 3: Real-time DAG visualization — SVG rendering with Sugiyama layout, WebSocket-driven status updates, interactive drag-to-edit dependencies with optimistic locking (409 on conflict). Phase 4: AI introspection engine — token tracking queries, execution replay timeline page, token consumption bar charts in web dashboard. Phase 5: Cross-model adversarial review protocol — ReviewProtocol types, consensus/conflict detection, flowctl codex cross-model command, MCP tool. Phase 6: Delight pack — ASCII DAG (flowctl status --dag), task time estimates, sound notifications, webhook integration, replay/diff commands, smart recovery. 250 tests pass, clippy clean, zero warnings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 500ecf3 commit a7bda14

38 files changed

Lines changed: 5000 additions & 1035 deletions

File tree

flowctl/Cargo.lock

Lines changed: 385 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

flowctl/crates/flowctl-cli/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ daemon = ["dep:flowctl-daemon", "dep:flowctl-web", "dep:tokio", "dep:leptos", "d
1717
[dependencies]
1818
flowctl-core = { workspace = true }
1919
flowctl-db = { workspace = true }
20+
flowctl-service = { workspace = true }
2021
rusqlite = { workspace = true }
2122
flowctl-scheduler = { workspace = true }
2223
flowctl-daemon = { path = "../flowctl-daemon", features = ["daemon"], optional = true }
@@ -44,3 +45,7 @@ which = { workspace = true }
4445
trycmd = { workspace = true }
4546
tempfile = "3"
4647
serde_json = { workspace = true }
48+
flowctl-core = { workspace = true }
49+
flowctl-db = { workspace = true }
50+
flowctl-service = { workspace = true }
51+
rusqlite = { workspace = true }

flowctl/crates/flowctl-cli/src/commands/admin/status.rs

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,13 @@ pub fn cmd_status(json: bool, interrupted: bool) {
3333
return;
3434
}
3535

36+
let daemon_alive = is_daemon_heartbeat_alive(&flow_dir);
3637
let interrupted_epics = find_interrupted_epics(&flow_dir);
3738
if json {
38-
json_output(json!({"interrupted": interrupted_epics}));
39+
json_output(json!({
40+
"interrupted": interrupted_epics,
41+
"daemon_running": daemon_alive,
42+
}));
3943
} else if interrupted_epics.is_empty() {
4044
println!("No interrupted work found.");
4145
} else {
@@ -68,10 +72,32 @@ pub fn cmd_status(json: bool, interrupted: bool) {
6872
total,
6973
remaining.join(", ")
7074
);
71-
println!(
72-
" Resume: {}",
73-
ep["suggested"].as_str().unwrap_or("")
74-
);
75+
76+
// Smart recovery: if tasks are in_progress but no daemon is running,
77+
// output specific restart commands for stale tasks.
78+
if in_prog > 0 && !daemon_alive {
79+
let stale_tasks = ep.get("stale_task_ids")
80+
.and_then(|v| v.as_array())
81+
.cloned()
82+
.unwrap_or_default();
83+
if !stale_tasks.is_empty() {
84+
println!(" Recovery (no daemon heartbeat):");
85+
for tid in &stale_tasks {
86+
if let Some(id) = tid.as_str() {
87+
println!(" Run: flowctl restart {id}");
88+
}
89+
}
90+
println!(
91+
" Then: /flow-code:work {}",
92+
ep["id"].as_str().unwrap_or("")
93+
);
94+
}
95+
} else {
96+
println!(
97+
" Resume: {}",
98+
ep["suggested"].as_str().unwrap_or("")
99+
);
100+
}
75101
println!();
76102
}
77103
}
@@ -216,6 +242,29 @@ fn status_from_db() -> Option<(serde_json::Value, serde_json::Value)> {
216242
))
217243
}
218244

245+
/// Check if the daemon is running by reading `.flow/.state/flowctl.pid`
246+
/// and verifying the process is alive. Returns false if no PID file,
247+
/// PID is invalid, or the process is dead.
248+
fn is_daemon_heartbeat_alive(flow_dir: &Path) -> bool {
249+
let pid_file = flow_dir.join(".state").join("flowctl.pid");
250+
let content = match fs::read_to_string(&pid_file) {
251+
Ok(c) => c,
252+
Err(_) => return false,
253+
};
254+
let pid_str = content.trim();
255+
if pid_str.parse::<u32>().is_err() {
256+
return false;
257+
}
258+
// Use `kill -0 <pid>` to check process existence without sending a signal.
259+
Command::new("kill")
260+
.args(["-0", pid_str])
261+
.stdout(std::process::Stdio::null())
262+
.stderr(std::process::Stdio::null())
263+
.status()
264+
.map(|s| s.success())
265+
.unwrap_or(false)
266+
}
267+
219268
/// Find open epics with undone tasks (interrupted work).
220269
fn find_interrupted_epics(flow_dir: &Path) -> Vec<serde_json::Value> {
221270
let mut interrupted = Vec::new();
@@ -257,13 +306,14 @@ fn find_interrupted_epics(flow_dir: &Path) -> Vec<serde_json::Value> {
257306
continue;
258307
}
259308

260-
// Count tasks for this epic
309+
// Count tasks for this epic and collect in_progress task IDs
261310
let mut counts = std::collections::HashMap::new();
262311
counts.insert("todo", 0u64);
263312
counts.insert("in_progress", 0u64);
264313
counts.insert("done", 0u64);
265314
counts.insert("blocked", 0u64);
266315
counts.insert("skipped", 0u64);
316+
let mut stale_task_ids: Vec<String> = Vec::new();
267317

268318
if tasks_dir.is_dir() {
269319
if let Ok(task_entries) = fs::read_dir(&tasks_dir) {
@@ -284,6 +334,9 @@ fn find_interrupted_epics(flow_dir: &Path) -> Vec<serde_json::Value> {
284334
continue;
285335
}
286336
let status_key = task.status.to_string();
337+
if status_key == "in_progress" {
338+
stale_task_ids.push(task.id.clone());
339+
}
287340
if let Some(count) = counts.get_mut(status_key.as_str()) {
288341
*count += 1;
289342
}
@@ -292,6 +345,7 @@ fn find_interrupted_epics(flow_dir: &Path) -> Vec<serde_json::Value> {
292345
}
293346
}
294347
}
348+
stale_task_ids.sort();
295349

296350
let total: u64 = counts.values().sum();
297351
if total == 0 {
@@ -314,6 +368,7 @@ fn find_interrupted_epics(flow_dir: &Path) -> Vec<serde_json::Value> {
314368
"in_progress": in_progress,
315369
"blocked": blocked,
316370
"skipped": skipped,
371+
"stale_task_ids": stale_task_ids,
317372
"reason": if done == 0 && in_progress == 0 { "planned_not_started" } else { "partially_complete" },
318373
"suggested": format!("/flow-code:work {}", epic.id),
319374
}));
@@ -670,7 +725,7 @@ pub fn cmd_doctor(json_mode: bool) {
670725
checks.push(json!({"name": "config", "status": "fail", "message": "config.json is not a JSON object"}));
671726
} else {
672727
let known_keys: std::collections::HashSet<&str> =
673-
["memory", "planSync", "review", "scouts", "stack"]
728+
["memory", "notifications", "planSync", "review", "scouts", "stack"]
674729
.iter()
675730
.copied()
676731
.collect();

0 commit comments

Comments
 (0)