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