Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
Expand Down
59 changes: 55 additions & 4 deletions apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -173,20 +187,22 @@ 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
let attentionCount: Int
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,
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -232,6 +251,7 @@ struct OperatorProjectStatus: Decodable, Sendable {
connectorState: String?,
warningCount: Int,
activeRunCount: Int,
runningLaneCount: Int,
queuedCandidateCount: Int,
postReviewLaneCount: Int,
waitingLaneCount: Int,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
81 changes: 81 additions & 0 deletions apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions apps/decodex/src/orchestrator/entrypoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 21 additions & 5 deletions apps/decodex/src/orchestrator/operator_dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -4352,6 +4352,7 @@ <h2 id="recent-title">Run History</h2>
return (
["starting", "running"].includes(run.status) &&
run.phase === "executing" &&
run.process_alive !== false &&
!runNeedsAttention(run)
);
}
Expand Down Expand Up @@ -8903,6 +8904,10 @@ <h2 id="recent-title">Run History</h2>
return workCount > 0;
}

function projectRunningLaneCount(project) {
return project.running_lane_count ?? project.active_run_count ?? 0;
}

function activeProjects(projects) {
return projects.filter(projectHasActiveWork);
}
Expand All @@ -8911,7 +8916,7 @@ <h2 id="recent-title">Run History</h2>
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) {
Expand Down Expand Up @@ -8954,7 +8959,7 @@ <h2 id="recent-title">Run History</h2>
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`,
Expand All @@ -8970,7 +8975,7 @@ <h2 id="recent-title">Run History</h2>
}

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);
Expand Down Expand Up @@ -9173,7 +9178,7 @@ <h2 id="recent-title">Run History</h2>

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),
Expand Down Expand Up @@ -9251,7 +9256,7 @@ <h2 id="recent-title">Run History</h2>
if (!project.enabled) {
return 5;
}
if ((project.active_run_count ?? 0) > 0) {
if (projectRunningLaneCount(project) > 0) {
return 0;
}
if ((project.attention_count ?? 0) > 0) {
Expand Down Expand Up @@ -10239,6 +10244,16 @@ <h4>${escapeHtml(worktree.branch_name)}</h4>

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) => {
Expand All @@ -10249,6 +10264,7 @@ <h4>${escapeHtml(worktree.branch_name)}</h4>
return {
...project,
active_run_count: activeCountsByProject.get(project.project_id) || 0,
running_lane_count: runningCountsByProject.get(project.project_id) || 0,
};
})
: snapshot.projects;
Expand Down
5 changes: 4 additions & 1 deletion apps/decodex/src/orchestrator/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
Loading