diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift
index 25584f3..13f6191 100644
--- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift
+++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift
@@ -933,7 +933,7 @@ struct AccountRunSummaryView: View {
.frame(height: AccountRunChipLayout.height)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
- .accessibilityLabel("\(runs.count) running lane\(runs.count == 1 ? "" : "s")")
+ .accessibilityLabel("\(runs.count) active lane\(runs.count == 1 ? "" : "s")")
.onAppear {
placementStore.retainOnly(Set(runs.map(\.id)))
}
diff --git a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift
index 805f31b..7f5b491 100644
--- a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift
+++ b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift
@@ -14,6 +14,13 @@ struct OperatorSnapshotResponse: Decodable, Sendable {
)
}
+ var runningLaneCount: Int {
+ max(
+ activeRuns.filter(\.countsAsRunning).count,
+ projects.reduce(0) { $0 + $1.runningLaneCount }
+ )
+ }
+
var queuedCount: Int {
max(
queuedCandidates.filter { $0.isClosed == false }.count,
@@ -77,7 +84,7 @@ struct OperatorSnapshotResponse: Decodable, Sendable {
}
func runningCount(for account: CodexAccount) -> Int {
- activeRuns(for: account).count
+ activeRuns(for: account).filter(\.countsAsRunning).count
}
func mergingRunActivity(
@@ -106,12 +113,19 @@ struct OperatorSnapshotResponse: Decodable, Sendable {
let mergedRuns = mergedSnapshotRuns + newActivityRuns
let activeCountsByProject = Dictionary(grouping: mergedRuns.compactMap(\.projectID)) { $0 }
.mapValues(\.count)
+ let runningCountsByProject = Dictionary(
+ grouping: mergedRuns.filter(\.countsAsRunning).compactMap(\.projectID)
+ ) { $0 }
+ .mapValues(\.count)
let mergedProjects = projects.map { project in
guard let projectID = project.projectID else {
return project
}
- return project.withActiveRunCount(activeCountsByProject[projectID] ?? 0)
+ return project.withRunCounts(
+ active: activeCountsByProject[projectID] ?? 0,
+ running: runningCountsByProject[projectID] ?? 0
+ )
}
return OperatorSnapshotResponse(
@@ -173,6 +187,7 @@ struct OperatorProjectStatus: Decodable, Sendable {
let connectorState: String?
let warningCount: Int
let activeRunCount: Int
+ let runningLaneCount: Int
let queuedCandidateCount: Int
let postReviewLaneCount: Int
let waitingLaneCount: Int
@@ -180,13 +195,14 @@ struct OperatorProjectStatus: Decodable, Sendable {
let cleanupBlockedCount: Int
let cleanupPendingCount: Int
- func withActiveRunCount(_ count: Int) -> OperatorProjectStatus {
+ func withRunCounts(active: Int, running: Int) -> OperatorProjectStatus {
OperatorProjectStatus(
projectID: projectID,
enabled: enabled,
connectorState: connectorState,
warningCount: warningCount,
- activeRunCount: count,
+ activeRunCount: active,
+ runningLaneCount: running,
queuedCandidateCount: queuedCandidateCount,
postReviewLaneCount: postReviewLaneCount,
waitingLaneCount: waitingLaneCount,
@@ -202,6 +218,7 @@ struct OperatorProjectStatus: Decodable, Sendable {
case connectorState = "connector_state"
case warningCount = "warning_count"
case activeRunCount = "active_run_count"
+ case runningLaneCount = "running_lane_count"
case queuedCandidateCount = "queued_candidate_count"
case postReviewLaneCount = "post_review_lane_count"
case waitingLaneCount = "waiting_lane_count"
@@ -218,6 +235,8 @@ struct OperatorProjectStatus: Decodable, Sendable {
connectorState = try container.decodeIfPresent(String.self, forKey: .connectorState)
warningCount = try container.decodeIfPresent(Int.self, forKey: .warningCount) ?? 0
activeRunCount = try container.decodeIfPresent(Int.self, forKey: .activeRunCount) ?? 0
+ runningLaneCount =
+ try container.decodeIfPresent(Int.self, forKey: .runningLaneCount) ?? activeRunCount
queuedCandidateCount = try container.decodeIfPresent(Int.self, forKey: .queuedCandidateCount) ?? 0
postReviewLaneCount = try container.decodeIfPresent(Int.self, forKey: .postReviewLaneCount) ?? 0
waitingLaneCount = try container.decodeIfPresent(Int.self, forKey: .waitingLaneCount) ?? 0
@@ -232,6 +251,7 @@ struct OperatorProjectStatus: Decodable, Sendable {
connectorState: String?,
warningCount: Int,
activeRunCount: Int,
+ runningLaneCount: Int,
queuedCandidateCount: Int,
postReviewLaneCount: Int,
waitingLaneCount: Int,
@@ -244,6 +264,7 @@ struct OperatorProjectStatus: Decodable, Sendable {
self.connectorState = connectorState
self.warningCount = warningCount
self.activeRunCount = activeRunCount
+ self.runningLaneCount = runningLaneCount
self.queuedCandidateCount = queuedCandidateCount
self.postReviewLaneCount = postReviewLaneCount
self.waitingLaneCount = waitingLaneCount
@@ -373,6 +394,36 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
|| phase == "executing"
}
+ var countsAsRunning: Bool {
+ hasRunningStatus
+ && phase == "executing"
+ && processAlive != false
+ && hasAttentionTone == false
+ && hasStaleExecutionWithoutKnownProcess == false
+ }
+
+ private var hasRunningStatus: Bool {
+ guard let status else {
+ return false
+ }
+
+ return ["starting", "running"].contains(status)
+ }
+
+ private var hasStaleExecutionWithoutKnownProcess: Bool {
+ hasRunningStatus
+ && phase == "executing"
+ && waitReason == nil
+ && processAlive != true
+ && [idleForSeconds, protocolIdleForSeconds].contains { idleForSeconds in
+ guard let idleForSeconds else {
+ return false
+ }
+
+ return idleForSeconds >= 300
+ }
+ }
+
func mergingActivity(_ activity: OperatorRunStatus) -> OperatorRunStatus {
OperatorRunStatus(
projectID: activity.projectID ?? projectID,
diff --git a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift
index 050cfa4..19fce0e 100644
--- a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift
+++ b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift
@@ -188,6 +188,50 @@ final class AccountModelTests: XCTestCase {
XCTAssertTrue(snapshot.activeRuns(for: account).isEmpty)
}
+ func testOperatorProjectStatusSeparatesActiveAndRunningLaneCounts() throws {
+ let payload = """
+ {
+ "projects": [
+ {
+ "project_id": "pubfi-platform",
+ "active_run_count": 2,
+ "running_lane_count": 1,
+ "attention_count": 1
+ }
+ ]
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try JSONDecoder().decode(OperatorSnapshotResponse.self, from: payload)
+ let project = try XCTUnwrap(snapshot.projects.first)
+
+ XCTAssertEqual(project.activeRunCount, 2)
+ XCTAssertEqual(project.runningLaneCount, 1)
+ XCTAssertEqual(snapshot.activeRunCount, 2)
+ XCTAssertEqual(snapshot.runningLaneCount, 1)
+ XCTAssertEqual(snapshot.attentionCount, 1)
+ }
+
+ func testOperatorProjectStatusDefaultsRunningLaneCountToActiveRunCount() throws {
+ let payload = """
+ {
+ "projects": [
+ {
+ "project_id": "pubfi-platform",
+ "active_run_count": 2
+ }
+ ]
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try JSONDecoder().decode(OperatorSnapshotResponse.self, from: payload)
+ let project = try XCTUnwrap(snapshot.projects.first)
+
+ XCTAssertEqual(project.activeRunCount, 2)
+ XCTAssertEqual(project.runningLaneCount, 2)
+ XCTAssertEqual(snapshot.runningLaneCount, 2)
+ }
+
func testOperatorSnapshotAssignsSelectedAccountWhenPrimaryAccountIsMissing() throws {
let assignedAccount = makeAccount(
status: "available",
@@ -340,6 +384,43 @@ final class AccountModelTests: XCTestCase {
XCTAssertEqual(merged.activeRuns(for: account).map(\.runID), ["run-689", "run-690"])
}
+ func testPartialRunActivityRecomputesProjectRunningLaneCounts() throws {
+ let snapshotPayload = """
+ {
+ "projects": [
+ {
+ "project_id": "pubfi-platform",
+ "active_run_count": 1,
+ "running_lane_count": 1
+ }
+ ],
+ "active_runs": [
+ {
+ "run_id": "run-stopped",
+ "project_id": "pubfi-platform",
+ "status": "running",
+ "phase": "executing",
+ "process_alive": false
+ }
+ ]
+ }
+ """.data(using: .utf8)!
+ let snapshot = try JSONDecoder().decode(OperatorSnapshotResponse.self, from: snapshotPayload)
+ let overlay = OperatorRunActivitySnapshot(
+ activeRuns: [],
+ activeRunsComplete: false,
+ emittedAt: Date(timeIntervalSince1970: 30)
+ )
+ let merged = overlay.merging(into: snapshot)
+ let project = try XCTUnwrap(merged.projects.first)
+
+ XCTAssertEqual(merged.activeRuns.map(\.runID), ["run-stopped"])
+ XCTAssertEqual(project.activeRunCount, 1)
+ XCTAssertEqual(project.runningLaneCount, 0)
+ XCTAssertEqual(merged.activeRunCount, 1)
+ XCTAssertEqual(merged.runningLaneCount, 0)
+ }
+
func testEmptyPartialRunActivityPreservesSnapshotActiveRuns() throws {
let account = makeAccount(
status: "available",
diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs
index db60a71..e16fc20 100644
--- a/apps/decodex/src/orchestrator/entrypoints.rs
+++ b/apps/decodex/src/orchestrator/entrypoints.rs
@@ -1034,6 +1034,11 @@ fn hydrate_project_status_from_local_snapshot(
hydrate_project_status_from_registered_status(project_status, local_status);
} else {
project_status.active_run_count = project_snapshot.active_runs.len();
+ project_status.running_lane_count = project_snapshot
+ .active_runs
+ .iter()
+ .filter(|run| operator_run_counts_as_running(run))
+ .count();
}
}
@@ -1042,6 +1047,7 @@ fn hydrate_project_status_from_registered_status(
local_status: &OperatorProjectStatus,
) {
project_status.active_run_count = local_status.active_run_count;
+ project_status.running_lane_count = local_status.running_lane_count;
project_status.retained_worktree_count = local_status.retained_worktree_count;
project_status.waiting_lane_count = local_status.waiting_lane_count;
project_status.attention_count = local_status.attention_count;
@@ -1633,6 +1639,7 @@ fn operator_project_status_from_registration(
enabled: project.enabled(),
github_cli_authority: operator_github_cli_authority_from_registration(project),
active_run_count: 0,
+ running_lane_count: 0,
queued_candidate_count: 0,
post_review_lane_count: 0,
retained_worktree_count: 0,
@@ -1664,6 +1671,7 @@ fn operator_project_status_from_dev_registration(
enabled: project.enabled(),
github_cli_authority: operator_github_cli_authority_from_registration(project),
active_run_count: 0,
+ running_lane_count: 0,
queued_candidate_count: 0,
post_review_lane_count: 0,
retained_worktree_count: 0,
diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html
index 6695297..f6d465d 100644
--- a/apps/decodex/src/orchestrator/operator_dashboard.html
+++ b/apps/decodex/src/orchestrator/operator_dashboard.html
@@ -4352,6 +4352,7 @@
Run History
return (
["starting", "running"].includes(run.status) &&
run.phase === "executing" &&
+ run.process_alive !== false &&
!runNeedsAttention(run)
);
}
@@ -8903,6 +8904,10 @@ Run History
return workCount > 0;
}
+ function projectRunningLaneCount(project) {
+ return project.running_lane_count ?? project.active_run_count ?? 0;
+ }
+
function activeProjects(projects) {
return projects.filter(projectHasActiveWork);
}
@@ -8911,7 +8916,7 @@ Run History
if (!project.enabled) {
return { label: "disabled", tone: "tone-muted", title: "Disabled in registry" };
}
- if ((project.active_run_count ?? 0) > 0) {
+ if (projectRunningLaneCount(project) > 0) {
return { label: "running", tone: "tone-run", title: "Active running lanes" };
}
if ((project.attention_count ?? 0) > 0) {
@@ -8954,7 +8959,7 @@ Run History
const cleanup = (project.cleanup_blocked_count ?? 0) + (project.cleanup_pending_count ?? 0);
return [
- `${project.active_run_count ?? 0} running`,
+ `${projectRunningLaneCount(project)} running`,
`${project.waiting_lane_count ?? 0} waiting`,
`${project.attention_count ?? 0} attention`,
`${cleanup} cleanup`,
@@ -8970,7 +8975,7 @@ Run History
}
function renderProjectStats(project) {
- const running = project.active_run_count ?? 0;
+ const running = projectRunningLaneCount(project);
const waiting = project.waiting_lane_count ?? 0;
const attention = project.attention_count ?? 0;
const cleanup = (project.cleanup_blocked_count ?? 0) + (project.cleanup_pending_count ?? 0);
@@ -9173,7 +9178,7 @@ Run History
function projectWorkSortValue(project) {
return [
- projectNumber(project.active_run_count),
+ projectNumber(projectRunningLaneCount(project)),
projectNumber(project.waiting_lane_count),
projectNumber(project.attention_count),
projectNumber(project.cleanup_blocked_count),
@@ -9251,7 +9256,7 @@ Run History
if (!project.enabled) {
return 5;
}
- if ((project.active_run_count ?? 0) > 0) {
+ if (projectRunningLaneCount(project) > 0) {
return 0;
}
if ((project.attention_count ?? 0) > 0) {
@@ -10239,6 +10244,16 @@ ${escapeHtml(worktree.branch_name)}
return counts;
}, new Map());
+ const runningCountsByProject = mergedActiveRuns.reduce((counts, run) => {
+ const projectId = run.project_id || snapshot.project_id;
+ if (!projectId || !runCountsAsRunning(run)) {
+ return counts;
+ }
+
+ counts.set(projectId, (counts.get(projectId) || 0) + 1);
+
+ return counts;
+ }, new Map());
const projects = Array.isArray(snapshot.projects)
? snapshot.projects.map((project) => {
@@ -10249,6 +10264,7 @@ ${escapeHtml(worktree.branch_name)}
return {
...project,
active_run_count: activeCountsByProject.get(project.project_id) || 0,
+ running_lane_count: runningCountsByProject.get(project.project_id) || 0,
};
})
: snapshot.projects;
diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs
index 23ee9d1..61e0472 100644
--- a/apps/decodex/src/orchestrator/status.rs
+++ b/apps/decodex/src/orchestrator/status.rs
@@ -320,6 +320,7 @@ fn build_operator_status_snapshot_with_account_mode(
enabled: true,
github_cli_authority: operator_github_cli_authority(project),
active_run_count: active_runs.len(),
+ running_lane_count: active_runs.len(),
queued_candidate_count: 0,
post_review_lane_count: 0,
retained_worktree_count: 0,
@@ -902,7 +903,8 @@ fn worktree_cleanup_only_reason(
}
fn refresh_operator_project_summary(snapshot: &mut OperatorStatusSnapshot) {
- let active_run_count =
+ let active_run_count = snapshot.active_runs.len();
+ let running_lane_count =
snapshot.active_runs.iter().filter(|run| operator_run_counts_as_running(run)).count();
let queued_candidate_count = snapshot
.queued_candidates
@@ -921,6 +923,7 @@ fn refresh_operator_project_summary(snapshot: &mut OperatorStatusSnapshot) {
if let Some(project_status) = snapshot.projects.first_mut() {
project_status.active_run_count = active_run_count;
+ project_status.running_lane_count = running_lane_count;
project_status.queued_candidate_count = queued_candidate_count;
project_status.post_review_lane_count = post_review_lane_count;
project_status.retained_worktree_count = retained_worktree_count;
diff --git a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs
index 3cc8a4f..c0fc837 100644
--- a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs
+++ b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs
@@ -156,6 +156,7 @@ fn agent_evidence_project_status_with_configured_gh() -> OperatorProjectStatus {
),
},
active_run_count: 0,
+ running_lane_count: 0,
queued_candidate_count: 0,
post_review_lane_count: 0,
retained_worktree_count: 0,
diff --git a/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs b/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs
index 1dabbc6..76fcc02 100644
--- a/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs
+++ b/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs
@@ -273,6 +273,7 @@ fn control_plane_dev_snapshot_includes_local_active_runs() {
assert_eq!(project.project_id, "pubfi");
assert_eq!(project.connector_state, "dev");
assert_eq!(project.active_run_count, 1);
+ assert_eq!(project.running_lane_count, 1);
assert_eq!(snapshot.active_runs.len(), 1);
assert_eq!(snapshot.active_runs[0].run_id, "run-active");
assert_eq!(snapshot.active_runs[0].project_id, "pubfi");
@@ -281,6 +282,57 @@ fn control_plane_dev_snapshot_includes_local_active_runs() {
assert!(snapshot.warnings.contains(&String::from("automation_disabled")));
}
+#[test]
+fn control_plane_dev_snapshot_separates_visible_active_runs_from_running_lanes() {
+ let (temp_dir, config, _workflow) = temp_project_layout();
+ let _home_guard =
+ TestEnvVarGuard::set("HOME", temp_dir.path().to_str().expect("home should be utf-8"));
+ let state_store = StateStore::open_in_memory().expect("state store should open");
+ let registration = ProjectRegistration::from_config(
+ config.service_id(),
+ &service_config_path(config.repo_root()),
+ &config,
+ true,
+ "test-fingerprint",
+ );
+ let issue = sample_issue("Todo", &[]);
+ let worktree_path = config.worktree_root().join("PUB-101");
+
+ state_store.upsert_project(®istration).expect("project should register");
+ state_store
+ .record_run_attempt("run-active", &issue.id, 1, "running")
+ .expect("active run should record");
+ state_store
+ .upsert_lease(config.service_id(), &issue.id, "run-active", "In Progress")
+ .expect("active lease should record");
+ state_store
+ .upsert_worktree(
+ config.service_id(),
+ &issue.id,
+ "x/pubfi-pub-101",
+ &worktree_path.display().to_string(),
+ )
+ .expect("worktree should record");
+
+ fs::create_dir_all(&worktree_path).expect("worktree path should exist");
+ state::write_run_activity_marker_for_process(&worktree_path, "run-active", 1, u32::MAX)
+ .expect("stopped process marker should write");
+
+ let snapshot =
+ orchestrator::run_control_plane_dev_tick(&state_store).expect("dev snapshot should build");
+ let project = snapshot.projects.first().expect("enabled project should be listed");
+ let run = snapshot.active_runs.first().expect("stopped active run should stay visible");
+
+ assert_eq!(project.active_run_count, snapshot.active_runs.len());
+ assert_eq!(project.active_run_count, 1);
+ assert_eq!(project.running_lane_count, 0);
+ assert_eq!(project.attention_count, 1);
+ assert_eq!(run.run_id, "run-active");
+ assert_eq!(run.status, "running");
+ assert_eq!(run.execution_liveness, "process_stopped");
+ assert!(snapshot.warnings.contains(&String::from("automation_disabled")));
+}
+
#[test]
fn control_plane_snapshot_aggregates_top_level_lanes_for_all_registered_projects() {
let (active_temp_dir, active_config, _active_workflow) = temp_project_layout();
diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs
index 3e384b7..85591c2 100644
--- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs
+++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs
@@ -1290,14 +1290,19 @@ fn operator_dashboard_projects_show_compact_activity_work_and_location() {
assert!(!response.contains("return \"Registered project\";"));
assert!(!response.contains("Disabled registration"));
assert!(response.contains("aria-label=\"Project status summary\""));
- assert!(response.contains("const running = project.active_run_count ?? 0;"));
+ assert!(response.contains("function projectRunningLaneCount(project)"));
+ assert!(response.contains("const running = projectRunningLaneCount(project);"));
assert!(response.contains("const waiting = project.waiting_lane_count ?? 0;"));
assert!(response.contains("const attention = project.attention_count ?? 0;"));
assert!(response.contains("const cleanup = (project.cleanup_blocked_count ?? 0) + (project.cleanup_pending_count ?? 0);"));
- assert!(response.contains("`${project.active_run_count ?? 0} running`"));
+ assert!(response.contains("`${projectRunningLaneCount(project)} running`"));
assert!(response.contains("`${project.waiting_lane_count ?? 0} waiting`"));
assert!(response.contains("`${project.attention_count ?? 0} attention`"));
assert!(response.contains("`${cleanup} cleanup`"));
+ assert!(response.contains("run.process_alive !== false"));
+ assert!(response.contains("running_lane_count: runningCountsByProject.get(project.project_id) || 0"));
+ assert!(!response.contains("const running = project.active_run_count ?? 0;"));
+ assert!(!response.contains("`${project.active_run_count ?? 0} running`"));
assert!(response.contains("projectNumber(project.cleanup_blocked_count)"));
assert!(response.contains("projectNumber(project.cleanup_pending_count)"));
assert!(!response.contains("[project.post_review_lane_count ?? 0, \"review/land\"]"));
diff --git a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs
index fc3991f..1f2bc83 100644
--- a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs
+++ b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs
@@ -945,7 +945,8 @@ fn operator_status_snapshot_counts_stopped_active_process_as_attention_not_runni
assert_eq!(run.phase, "executing");
assert_eq!(run.process_alive, Some(false));
assert_eq!(run.process_liveness_reason.as_deref(), Some("process_stopped"));
- assert_eq!(project.active_run_count, 0);
+ assert_eq!(project.active_run_count, 1);
+ assert_eq!(project.running_lane_count, 0);
assert_eq!(project.attention_count, 1);
}
@@ -1011,7 +1012,8 @@ fn operator_status_snapshot_counts_zombie_active_process_as_attention_not_runnin
assert_eq!(run.phase, "executing");
assert_eq!(run.process_alive, Some(false));
assert_eq!(run.process_liveness_reason.as_deref(), Some("process_stopped"));
- assert_eq!(project.active_run_count, 0);
+ assert_eq!(project.active_run_count, 1);
+ assert_eq!(project.running_lane_count, 0);
assert_eq!(project.attention_count, 1);
}
@@ -1054,7 +1056,8 @@ fn operator_status_snapshot_counts_previous_boot_process_as_attention_not_runnin
assert_eq!(run.process_alive, Some(false));
assert_eq!(run.execution_liveness, "process_identity_mismatch");
assert_eq!(run.process_liveness_reason.as_deref(), Some("host_boot_id_mismatch"));
- assert_eq!(project.active_run_count, 0);
+ assert_eq!(project.active_run_count, 1);
+ assert_eq!(project.running_lane_count, 0);
assert_eq!(project.attention_count, 1);
}
@@ -1100,7 +1103,8 @@ fn operator_status_snapshot_counts_reused_pid_as_attention_not_running() {
run.process_liveness_reason.as_deref(),
Some("process_start_identity_mismatch")
);
- assert_eq!(project.active_run_count, 0);
+ assert_eq!(project.active_run_count, 1);
+ assert_eq!(project.running_lane_count, 0);
assert_eq!(project.attention_count, 1);
}
@@ -1142,6 +1146,7 @@ fn operator_status_snapshot_keeps_unleased_live_process_in_running_lanes() {
assert_eq!(run.process_alive, Some(true));
assert_eq!(run.process_liveness_reason.as_deref(), Some("process_alive"));
assert_eq!(project.active_run_count, 1);
+ assert_eq!(project.running_lane_count, 1);
assert_eq!(project.retained_worktree_count, 0);
}
@@ -1268,7 +1273,8 @@ fn operator_status_snapshot_counts_stale_starting_run_as_attention_not_running()
assert!(run.protocol_idle_for_seconds.is_some_and(|idle| {
u64::try_from(idle).is_ok_and(|idle| idle >= ACTIVE_RUN_IDLE_TIMEOUT.as_secs())
}));
- assert_eq!(project.active_run_count, 0);
+ assert_eq!(project.active_run_count, 1);
+ assert_eq!(project.running_lane_count, 0);
assert_eq!(project.attention_count, 1);
}
diff --git a/apps/decodex/src/orchestrator/tests/operator/status/text.rs b/apps/decodex/src/orchestrator/tests/operator/status/text.rs
index f0dc08c..ce88cee 100644
--- a/apps/decodex/src/orchestrator/tests/operator/status/text.rs
+++ b/apps/decodex/src/orchestrator/tests/operator/status/text.rs
@@ -22,6 +22,7 @@ fn operator_status_text_surfaces_github_cli_authority() {
),
},
active_run_count: 0,
+ running_lane_count: 0,
queued_candidate_count: 0,
post_review_lane_count: 0,
retained_worktree_count: 0,
diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs
index a1214ac..727b925 100644
--- a/apps/decodex/src/orchestrator/types.rs
+++ b/apps/decodex/src/orchestrator/types.rs
@@ -1040,6 +1040,7 @@ struct OperatorProjectStatus {
enabled: bool,
github_cli_authority: OperatorGitHubCliAuthority,
active_run_count: usize,
+ running_lane_count: usize,
queued_candidate_count: usize,
post_review_lane_count: usize,
retained_worktree_count: usize,
diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md
index b105e43..a10ba8b 100644
--- a/docs/reference/operator-control-plane.md
+++ b/docs/reference/operator-control-plane.md
@@ -277,6 +277,10 @@ Worktree visibility follows the owning dashboard section:
when `process_alive` is false.
`active_lease` is queue lease ownership only; `execution_liveness` explains why
the lane is still visible when the queue lease is not held.
+- In the JSON snapshot, `active_run_count` follows the same visibility boundary as
+ the top-level `active_runs` list. The `Projects` table's `running` work number uses
+ `running_lane_count`, so stopped, stale, or attention lanes can stay visible as
+ active work without being counted as currently running.
- Running lanes derive CLI and dashboard text from the same `OperatorRunStatus`
object. `protocol_activity`, when present, summarizes app-server structured
notifications for turn status, waiting reason, and recent protocol events. The