diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift
index d7548daa0..476e40f56 100644
--- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift
+++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift
@@ -2954,7 +2954,7 @@ struct OperatorLanePopoverView: View {
}
private var currentSummary: String {
- if run.processAlive == false {
+ if run.processAlive == false, run.hasFreshExecution == false {
if let idle = formatActivityDuration(run.inactiveDurationSeconds) {
return "Stopped ยท idle \(idle)"
}
diff --git a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift
index 3959159e1..6ef2fc41b 100644
--- a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift
+++ b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift
@@ -1,5 +1,7 @@
import Foundation
+private let operatorActiveRunIdleTimeoutSeconds = 300
+
struct OperatorSnapshotResponse: Decodable, Sendable {
let warnings: [String]
let projects: [OperatorProjectStatus]
@@ -30,13 +32,13 @@ struct OperatorSnapshotResponse: Decodable, Sendable {
var reviewCount: Int {
max(
- postReviewLanes.count,
+ postReviewLanes.filter { $0.shadowedByActiveRun == false }.count,
projects.reduce(0) { $0 + $1.postReviewLaneCount }
)
}
var landingCount: Int {
- postReviewLanes.filter { $0.isReadyToLand }.count
+ postReviewLanes.filter { $0.isReadyToLand && $0.shadowedByActiveRun == false }.count
}
var waitingCount: Int {
@@ -298,13 +300,21 @@ struct OperatorQueuedIssueStatus: Decodable, Sendable {
struct OperatorPostReviewLaneStatus: Decodable, Sendable {
let classification: String?
+ let shadowedByActiveRun: Bool
var isReadyToLand: Bool {
- classification == "ready_to_land"
+ classification == "ready_to_land" && shadowedByActiveRun == false
}
enum CodingKeys: String, CodingKey {
case classification
+ case shadowedByActiveRun = "shadowed_by_active_run"
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ classification = try container.decodeIfPresent(String.self, forKey: .classification)
+ shadowedByActiveRun = try container.decodeIfPresent(Bool.self, forKey: .shadowedByActiveRun) ?? false
}
}
@@ -420,6 +430,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
let waitReason: String?
let currentOperation: String?
let threadStatus: String?
+ let threadActiveFlags: [String]
let idleForSeconds: Int?
let protocolIdleForSeconds: Int?
let updatedAt: String?
@@ -427,7 +438,12 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
let nextRetryAt: String?
let lastEventType: String?
let eventCount: Int?
+ let executionLiveness: String?
+ let hasFreshExecutionSnapshot: Bool?
+ let countsAsRunningSnapshot: Bool?
+ let needsAttentionSnapshot: Bool?
let processAlive: Bool?
+ let processLivenessReason: String?
let activeLease: Bool?
let branchName: String?
let worktreePath: String?
@@ -511,11 +527,15 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
}
var hasAttentionTone: Bool {
- suspectedStall
+ if let needsAttentionSnapshot {
+ return needsAttentionSnapshot
+ }
+
+ return suspectedStall
|| attemptStatus == "waiting_for_review"
|| status == "manual_attention"
|| status == "blocked"
- || processAlive == false
+ || stoppedProcessNeedsAttention
}
var isWaiting: Bool {
@@ -542,18 +562,39 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
var shouldRetainDuringPartialRunActivity: Bool {
activeLease == true
|| processAlive == true
+ || hasRecentAppServerExecution
|| status == "running"
|| phase == "executing"
}
var countsAsRunning: Bool {
- hasRunningStatus
+ if let countsAsRunningSnapshot {
+ return countsAsRunningSnapshot
+ }
+
+ return hasRunningStatus
&& phase == "executing"
- && processAlive != false
+ && (processAlive != false || hasFreshExecution)
&& hasAttentionTone == false
&& hasStaleExecutionWithoutKnownProcess == false
}
+ var hasFreshExecution: Bool {
+ if let hasFreshExecutionSnapshot {
+ return hasFreshExecutionSnapshot
+ }
+
+ return hasRunningStatus
+ && (processAlive == true || hasRecentAppServerExecution)
+ }
+
+ private var hasRecentAppServerExecution: Bool {
+ threadStatus == "active"
+ || threadActiveFlags.isEmpty == false
+ || ["thread_active", "protocol_observed"].contains(executionLiveness ?? "")
+ || protocolIdleForSeconds.isSomeAndLessThan(operatorActiveRunIdleTimeoutSeconds)
+ }
+
private var hasRunningStatus: Bool {
guard let status else {
return false
@@ -562,6 +603,13 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
return ["starting", "running"].contains(status)
}
+ private var stoppedProcessNeedsAttention: Bool {
+ processAlive == false
+ && hasRunningStatus
+ && waitReason == nil
+ && hasFreshExecution == false
+ }
+
private var hasStaleExecutionWithoutKnownProcess: Bool {
hasRunningStatus
&& phase == "executing"
@@ -591,6 +639,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
waitReason: activity.waitReason ?? waitReason,
currentOperation: activity.currentOperation ?? currentOperation,
threadStatus: activity.threadStatus ?? threadStatus,
+ threadActiveFlags: activity.threadActiveFlags.isEmpty ? threadActiveFlags : activity.threadActiveFlags,
idleForSeconds: activity.idleForSeconds ?? idleForSeconds,
protocolIdleForSeconds: activity.protocolIdleForSeconds ?? protocolIdleForSeconds,
updatedAt: activity.updatedAt ?? updatedAt,
@@ -598,7 +647,12 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
nextRetryAt: activity.nextRetryAt ?? nextRetryAt,
lastEventType: activity.lastEventType ?? lastEventType,
eventCount: activity.eventCount ?? eventCount,
+ executionLiveness: activity.executionLiveness ?? executionLiveness,
+ hasFreshExecutionSnapshot: activity.hasFreshExecutionSnapshot ?? hasFreshExecutionSnapshot,
+ countsAsRunningSnapshot: activity.countsAsRunningSnapshot ?? countsAsRunningSnapshot,
+ needsAttentionSnapshot: activity.needsAttentionSnapshot ?? needsAttentionSnapshot,
processAlive: activity.processAlive ?? processAlive,
+ processLivenessReason: activity.processLivenessReason ?? processLivenessReason,
activeLease: activity.activeLease ?? activeLease,
branchName: activity.branchName ?? branchName,
worktreePath: activity.worktreePath ?? worktreePath,
@@ -640,6 +694,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
case waitReason = "wait_reason"
case currentOperation = "current_operation"
case threadStatus = "thread_status"
+ case threadActiveFlags = "thread_active_flags"
case idleForSeconds = "idle_for_seconds"
case protocolIdleForSeconds = "protocol_idle_for_seconds"
case updatedAt = "updated_at"
@@ -647,7 +702,12 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
case nextRetryAt = "next_retry_at"
case lastEventType = "last_event_type"
case eventCount = "event_count"
+ case executionLiveness = "execution_liveness"
+ case hasFreshExecutionSnapshot = "has_fresh_execution"
+ case countsAsRunningSnapshot = "counts_as_running"
+ case needsAttentionSnapshot = "needs_attention"
case processAlive = "process_alive"
+ case processLivenessReason = "process_liveness_reason"
case activeLease = "active_lease"
case branchName = "branch_name"
case worktreePath = "worktree_path"
@@ -676,6 +736,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
waitReason = try container.decodeIfPresent(String.self, forKey: .waitReason)
currentOperation = try container.decodeIfPresent(String.self, forKey: .currentOperation)
threadStatus = try container.decodeIfPresent(String.self, forKey: .threadStatus)
+ threadActiveFlags = try container.decodeIfPresent([String].self, forKey: .threadActiveFlags) ?? []
idleForSeconds = try container.decodeIfPresent(Int.self, forKey: .idleForSeconds)
protocolIdleForSeconds = try container.decodeIfPresent(Int.self, forKey: .protocolIdleForSeconds)
updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt)
@@ -683,7 +744,12 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
nextRetryAt = try container.decodeIfPresent(String.self, forKey: .nextRetryAt)
lastEventType = try container.decodeIfPresent(String.self, forKey: .lastEventType)
eventCount = try container.decodeIfPresent(Int.self, forKey: .eventCount)
+ executionLiveness = try container.decodeIfPresent(String.self, forKey: .executionLiveness)
+ hasFreshExecutionSnapshot = try container.decodeIfPresent(Bool.self, forKey: .hasFreshExecutionSnapshot)
+ countsAsRunningSnapshot = try container.decodeIfPresent(Bool.self, forKey: .countsAsRunningSnapshot)
+ needsAttentionSnapshot = try container.decodeIfPresent(Bool.self, forKey: .needsAttentionSnapshot)
processAlive = try container.decodeIfPresent(Bool.self, forKey: .processAlive)
+ processLivenessReason = try container.decodeIfPresent(String.self, forKey: .processLivenessReason)
activeLease = try container.decodeIfPresent(Bool.self, forKey: .activeLease)
branchName = try container.decodeIfPresent(String.self, forKey: .branchName)
worktreePath = try container.decodeIfPresent(String.self, forKey: .worktreePath)
@@ -714,6 +780,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
waitReason: String?,
currentOperation: String?,
threadStatus: String?,
+ threadActiveFlags: [String],
idleForSeconds: Int?,
protocolIdleForSeconds: Int?,
updatedAt: String?,
@@ -721,7 +788,12 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
nextRetryAt: String?,
lastEventType: String?,
eventCount: Int?,
+ executionLiveness: String?,
+ hasFreshExecutionSnapshot: Bool?,
+ countsAsRunningSnapshot: Bool?,
+ needsAttentionSnapshot: Bool?,
processAlive: Bool?,
+ processLivenessReason: String?,
activeLease: Bool?,
branchName: String?,
worktreePath: String?,
@@ -744,6 +816,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
self.waitReason = waitReason
self.currentOperation = currentOperation
self.threadStatus = threadStatus
+ self.threadActiveFlags = threadActiveFlags
self.idleForSeconds = idleForSeconds
self.protocolIdleForSeconds = protocolIdleForSeconds
self.updatedAt = updatedAt
@@ -751,7 +824,12 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
self.nextRetryAt = nextRetryAt
self.lastEventType = lastEventType
self.eventCount = eventCount
+ self.executionLiveness = executionLiveness
+ self.hasFreshExecutionSnapshot = hasFreshExecutionSnapshot
+ self.countsAsRunningSnapshot = countsAsRunningSnapshot
+ self.needsAttentionSnapshot = needsAttentionSnapshot
self.processAlive = processAlive
+ self.processLivenessReason = processLivenessReason
self.activeLease = activeLease
self.branchName = branchName
self.worktreePath = worktreePath
@@ -763,6 +841,16 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable {
}
}
+private extension Optional where Wrapped == Int {
+ func isSomeAndLessThan(_ threshold: Int) -> Bool {
+ guard let value = self else {
+ return false
+ }
+
+ return value < threshold
+ }
+}
+
struct OperatorDashboardSocketEvent: Decodable, Sendable {
let type: String
let payload: OperatorDashboardSocketPayload?
diff --git a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift
index f3549821d..985357edd 100644
--- a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift
+++ b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift
@@ -283,6 +283,36 @@ final class AccountModelTests: XCTestCase {
XCTAssertEqual(run.inactiveDurationSeconds, 20840)
}
+ func testFreshProtocolExecutionOverridesStaleProcessMarker() throws {
+ let payload = """
+ {
+ "run_id": "xy-957-attempt-3",
+ "issue_identifier": "XY-957",
+ "status": "running",
+ "phase": "executing",
+ "wait_reason": "model_execution",
+ "execution_liveness": "process_identity_mismatch",
+ "has_fresh_execution": true,
+ "counts_as_running": true,
+ "needs_attention": false,
+ "process_alive": false,
+ "process_liveness_reason": "host_boot_id_mismatch",
+ "thread_status": "active",
+ "idle_for_seconds": 1,
+ "protocol_idle_for_seconds": 1,
+ "last_progress_at": "2026-06-16T03:27:31Z",
+ "last_event_type": "turn/diff/updated"
+ }
+ """.data(using: .utf8)!
+
+ let run = try JSONDecoder().decode(OperatorRunStatus.self, from: payload)
+
+ XCTAssertTrue(run.hasFreshExecution)
+ XCTAssertTrue(run.countsAsRunning)
+ XCTAssertFalse(run.hasAttentionTone)
+ XCTAssertEqual(run.inactiveDurationSeconds, 1)
+ }
+
func testOperatorSnapshotAssignsCodexAccountRunsToAccountRows() throws {
let assignedAccount = makeAccount(
status: "available",
@@ -423,6 +453,31 @@ final class AccountModelTests: XCTestCase {
XCTAssertEqual(snapshot.runningLaneCount, 2)
}
+ func testShadowedPostReviewLaneDoesNotInflateReviewOrLandingCounts() throws {
+ let payload = """
+ {
+ "projects": [
+ {
+ "project_id": "pubfi-platform",
+ "post_review_lane_count": 0
+ }
+ ],
+ "post_review_lanes": [
+ {
+ "classification": "ready_to_land",
+ "shadowed_by_active_run": true
+ }
+ ]
+ }
+ """.data(using: .utf8)!
+
+ let snapshot = try JSONDecoder().decode(OperatorSnapshotResponse.self, from: payload)
+
+ XCTAssertEqual(snapshot.reviewCount, 0)
+ XCTAssertEqual(snapshot.landingCount, 0)
+ XCTAssertEqual(snapshot.postReviewLanes.first?.shadowedByActiveRun, true)
+ }
+
func testOperatorSnapshotAssignsSelectedAccountWhenPrimaryAccountIsMissing() throws {
let assignedAccount = makeAccount(
status: "available",
diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html
index cf56ac393..05e476341 100644
--- a/apps/decodex/src/orchestrator/operator_dashboard.html
+++ b/apps/decodex/src/orchestrator/operator_dashboard.html
@@ -4449,8 +4449,28 @@
Run History
return runTelemetryMissing(run) && numericSeconds(run.idle_for_seconds) >= RUN_ATTENTION_IDLE_SECONDS;
}
+ function runHasFreshExecution(run) {
+ if (typeof run?.has_fresh_execution === "boolean") {
+ return run.has_fresh_execution;
+ }
+
+ return (
+ run?.thread_status === "active" ||
+ (run?.thread_active_flags?.length ?? 0) > 0 ||
+ ["thread_active", "protocol_observed"].includes(run?.execution_liveness) ||
+ run?.process_alive === true ||
+ (run?.protocol_idle_for_seconds != null &&
+ numericSeconds(run.protocol_idle_for_seconds) < RUN_STALE_NO_PROCESS_SECONDS)
+ );
+ }
+
function runProcessStoppedWhileActive(run) {
- return run.status === "running" && run.process_alive === false && !run.wait_reason;
+ return (
+ run.status === "running" &&
+ run.process_alive === false &&
+ !run.wait_reason &&
+ !runHasFreshExecution(run)
+ );
}
function runOperationRequiresLiveAgent(run) {
@@ -4492,11 +4512,16 @@ Run History
run.phase === "executing" &&
!run.wait_reason &&
run.process_alive !== true &&
+ !runHasFreshExecution(run) &&
runStaleWithoutKnownProcessAgeSeconds(run) >= RUN_STALE_NO_PROCESS_SECONDS
);
}
function runNeedsAttention(run) {
+ if (typeof run?.needs_attention === "boolean") {
+ return run.needs_attention;
+ }
+
return (
run.suspected_stall ||
run.phase === "stalled" ||
@@ -4506,10 +4531,14 @@ Run History
}
function runCountsAsRunning(run) {
+ if (typeof run?.counts_as_running === "boolean") {
+ return run.counts_as_running;
+ }
+
return (
["starting", "running"].includes(run.status) &&
run.phase === "executing" &&
- run.process_alive !== false &&
+ (run.process_alive !== false || runHasFreshExecution(run)) &&
!runNeedsAttention(run)
);
}
@@ -4574,6 +4603,10 @@ Run History
}
function isPostReviewBlocker(lane) {
+ if (lane?.shadowed_by_active_run === true) {
+ return false;
+ }
+
return ["blocked", "needs_review_repair", "closeout_blocked", "cleanup_blocked"].includes(
lane.classification,
);
@@ -9528,20 +9561,36 @@ Run History
for (const lane of postReviewLanes) {
const tone = toneForLane(lane);
- const activeRun = activeRunByIssue.get(lane.issue_id);
+ const activeRun = issueIdentityKeys(lane)
+ .map((key) => activeRunByIssue.get(key))
+ .find(Boolean);
+ const shadowedByActiveRun =
+ lane.shadowed_by_active_run === true ||
+ (typeof lane.shadowed_by_active_run !== "boolean" &&
+ activeRun &&
+ lane.classification === "needs_review_repair");
const issueKey = issueDisplayKey(lane);
- if (activeRun && lane.classification === "needs_review_repair") {
+ if (shadowedByActiveRun) {
+ const activeRunFacts = activeRun
+ ? [
+ ["Run", activeRun.run_id],
+ [
+ "Operation",
+ displayToken(activeRun.current_operation || activeRun.phase),
+ ],
+ ]
+ : [["Run", "active"]];
+
waitingItems.push({
tone: "tone-run",
scope: "Review",
issue: issueKey,
title: "Repair running",
summary: "",
- status: `run ${displayToken(activeRun.phase)}`,
+ status: activeRun ? `run ${displayToken(activeRun.phase)}` : "active run",
facts: [
- ["Run", activeRun.run_id],
- ["Operation", displayToken(activeRun.current_operation || activeRun.phase)],
+ ...activeRunFacts,
["Checks", compactStateToken(lane.check_state)],
["Threads", reviewThreadToken(lane.unresolved_review_threads)],
["PR", optionalCardToken(lane.pr_url)],
@@ -11199,7 +11248,12 @@ ${escapeHtml(worktree.branch_name)}
}
function dashboardRunShouldRetainDuringPartialActivity(run) {
- return run?.active_lease === true || run?.process_alive === true || runCountsAsRunning(run);
+ return (
+ run?.active_lease === true ||
+ run?.process_alive === true ||
+ runHasFreshExecution(run) ||
+ runCountsAsRunning(run)
+ );
}
function mergeDashboardActiveRuns(snapshot, activeRunRows, activeRunsComplete = true) {
diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs
index a6605b2ee..769c68ae5 100644
--- a/apps/decodex/src/orchestrator/status.rs
+++ b/apps/decodex/src/orchestrator/status.rs
@@ -665,6 +665,7 @@ where
)?;
apply_terminal_history_ledger_outcomes(&mut snapshot);
suppress_terminal_attention_queue_echoes(&mut snapshot);
+ hydrate_post_review_lane_active_run_shadowing(&mut snapshot);
refresh_worktree_ownership(
&mut snapshot,
Some(workflow.frontmatter().tracker().resolved_completed_state()),
@@ -1376,7 +1377,11 @@ fn refresh_operator_project_summary(
.iter()
.filter(|candidate| queued_candidate_counts_as_waiting_intake(candidate))
.count();
- let post_review_lane_count = snapshot.post_review_lanes.len();
+ let post_review_lane_count = snapshot
+ .post_review_lanes
+ .iter()
+ .filter(|lane| !lane.shadowed_by_active_run)
+ .count();
let retained_worktree_count = rendered_recovery_worktrees(snapshot).len();
let waiting_lane_count = project_waiting_lane_count(snapshot);
let attention_count = project_attention_count(snapshot, completed_state);
@@ -1417,7 +1422,7 @@ fn project_waiting_lane_count(snapshot: &OperatorStatusSnapshot) -> usize {
let review_waiting = snapshot
.post_review_lanes
.iter()
- .filter(|lane| lane.classification == "wait_for_review")
+ .filter(|lane| !lane.shadowed_by_active_run && lane.classification == "wait_for_review")
.count();
waiting_run_count + queued_waiting + review_waiting
@@ -1496,6 +1501,10 @@ fn queued_candidate_counts_as_attention(candidate: &OperatorQueuedIssueStatus) -
}
fn post_review_lane_counts_as_attention(lane: &OperatorPostReviewLaneStatus) -> bool {
+ if lane.shadowed_by_active_run {
+ return false;
+ }
+
matches!(
lane.classification.as_str(),
"blocked" | "needs_review_repair" | "closeout_blocked"
@@ -1624,7 +1633,7 @@ fn project_cleanup_blocked_count(snapshot: &OperatorStatusSnapshot) -> usize {
for lane in snapshot
.post_review_lanes
.iter()
- .filter(|lane| lane.classification == "cleanup_blocked")
+ .filter(|lane| !lane.shadowed_by_active_run && lane.classification == "cleanup_blocked")
{
cleanup_keys.insert(post_review_lane_cleanup_key(lane));
}
@@ -1661,6 +1670,22 @@ fn post_review_lane_cleanup_key(lane: &OperatorPostReviewLaneStatus) -> String {
lane.issue_identifier.clone()
}
+fn hydrate_post_review_lane_active_run_shadowing(snapshot: &mut OperatorStatusSnapshot) {
+ let active_issue_keys = snapshot
+ .active_runs
+ .iter()
+ .filter(|run| run.counts_as_running || run.has_fresh_execution)
+ .map(|run| operator_run_group_key(run).to_ascii_uppercase())
+ .collect::>();
+
+ for lane in &mut snapshot.post_review_lanes {
+ lane.shadowed_by_active_run = active_issue_keys.contains(&operator_issue_attention_key(
+ &lane.issue_id,
+ Some(&lane.issue_identifier),
+ ));
+ }
+}
+
fn worktree_cleanup_key(worktree: &OperatorWorktreeStatus) -> String {
worktree
.issue_identifier
@@ -1684,7 +1709,7 @@ fn operator_run_has_live_execution(run: &OperatorRunStatus) -> bool {
fn operator_run_counts_as_running(run: &OperatorRunStatus) -> bool {
matches!(run.status.as_str(), "starting" | "running")
&& run.phase == "executing"
- && (run.process_alive != Some(false) || operator_run_has_recent_app_server_execution(run))
+ && (run.process_alive != Some(false) || run.has_fresh_execution)
&& !operator_run_needs_attention(run)
}
@@ -1696,10 +1721,15 @@ fn operator_run_needs_attention(run: &OperatorRunStatus) -> bool {
|| run.process_alive == Some(false)
&& matches!(run.status.as_str(), "starting" | "running")
&& run.wait_reason.is_none()
- && !operator_run_has_recent_app_server_execution(run)
+ && !run.has_fresh_execution
|| operator_run_has_stale_execution_without_known_process(run)
}
+fn operator_run_has_fresh_execution(run: &OperatorRunStatus) -> bool {
+ matches!(run.status.as_str(), "starting" | "running")
+ && (run.process_alive == Some(true) || operator_run_has_recent_app_server_execution(run))
+}
+
fn operator_run_has_recent_app_server_execution(run: &OperatorRunStatus) -> bool {
matches!(run.thread_status.as_deref(), Some("active"))
|| !run.thread_active_flags.is_empty()
@@ -1714,6 +1744,7 @@ fn operator_run_has_stale_execution_without_known_process(run: &OperatorRunStatu
&& run.phase == "executing"
&& run.wait_reason.is_none()
&& run.process_alive != Some(true)
+ && !run.has_fresh_execution
&& [run.idle_for_seconds, run.protocol_idle_for_seconds].iter().any(|idle_for| {
idle_for.is_some_and(|idle_for| {
u64::try_from(idle_for).is_ok_and(|idle_for| idle_for >= ACTIVE_RUN_IDLE_TIMEOUT.as_secs())
@@ -3467,6 +3498,7 @@ fn degraded_post_review_lane_status_from_classification(
mergeable: classification.mergeable,
check_state: classification.check_state,
unresolved_review_threads: classification.unresolved_review_threads,
+ shadowed_by_active_run: false,
readback_warning: classification.readback_warning,
readback_root_cause: classification.readback_root_cause,
loop_status: Some(loop_status),
@@ -3679,6 +3711,7 @@ fn post_review_lane_status_from_classification(
mergeable: classification.mergeable,
check_state: classification.check_state,
unresolved_review_threads: classification.unresolved_review_threads,
+ shadowed_by_active_run: false,
readback_warning: classification.readback_warning,
readback_root_cause: classification.readback_root_cause,
loop_status,
@@ -4752,6 +4785,7 @@ fn blocked_post_review_lane_status(
mergeable: None,
check_state: None,
unresolved_review_threads: None,
+ shadowed_by_active_run: false,
readback_warning: None,
readback_root_cause: post_review_readback_root_cause_for_reason(reason)
.map(|root_cause| root_cause.as_str().to_owned()),
@@ -5491,11 +5525,10 @@ fn operator_run_status(
&run,
&lifecycle.status,
&lifecycle.phase,
- &lifecycle.current_operation,
- )?;
- let control_capability = operator_run_control_capability(&run, &app_server_state);
+ &lifecycle.current_operation,
+ )?;
- Ok(OperatorRunStatus {
+ Ok(hydrate_operator_run_derived_status(OperatorRunStatus {
project_id: project.service_id().to_owned(),
project_display_name: project_display_name.to_owned(),
run_id: run.run_id().to_owned(),
@@ -5510,6 +5543,7 @@ fn operator_run_status(
phase: lifecycle.phase,
wait_reason,
current_operation: lifecycle.current_operation,
+ control_capability: operator_run_control_capability(&run, &app_server_state),
thread_id: app_server_state.thread_id,
turn_id: app_server_state.turn_id,
thread_status: app_server_state.thread_status,
@@ -5519,6 +5553,9 @@ fn operator_run_status(
active_lease: lifecycle.active_lease,
queue_lease_state: operator_run_queue_lease_state(lifecycle.active_lease),
execution_liveness: lifecycle.execution_liveness,
+ has_fresh_execution: false,
+ counts_as_running: false,
+ needs_attention: false,
updated_at: run.updated_at().to_owned(),
last_run_activity_at: format_optional_unix_timestamp(timing.last_run_activity_unix_epoch),
last_protocol_activity_at: format_optional_unix_timestamp(
@@ -5534,7 +5571,6 @@ fn operator_run_status(
event_count: protocol_summary.event_count,
private_evidence,
loop_status: Some(loop_status),
- control_capability,
process_id: timing.process_id,
process_alive: timing.process_alive,
process_liveness_reason: timing.process_liveness_reason,
@@ -5545,15 +5581,23 @@ fn operator_run_status(
effective_cwd: app_server_state.effective_cwd,
effective_approval_policy: app_server_state.effective_approval_policy,
effective_approvals_reviewer: app_server_state.effective_approvals_reviewer,
- effective_sandbox_mode: app_server_state.effective_sandbox_mode,
- child_agent_activity,
- protocol_activity,
- lifecycle_metrics: OperatorLaneLifecycleMetrics::default(),
- account,
- accounts,
- branch_name,
- worktree_path,
- })
+ effective_sandbox_mode: app_server_state.effective_sandbox_mode,
+ child_agent_activity,
+ protocol_activity,
+ lifecycle_metrics: OperatorLaneLifecycleMetrics::default(),
+ account,
+ accounts,
+ branch_name,
+ worktree_path,
+ }))
+}
+
+fn hydrate_operator_run_derived_status(mut status: OperatorRunStatus) -> OperatorRunStatus {
+ status.has_fresh_execution = operator_run_has_fresh_execution(&status);
+ status.needs_attention = operator_run_needs_attention(&status);
+ status.counts_as_running = operator_run_counts_as_running(&status);
+
+ status
}
fn operator_run_lifecycle_projection(
@@ -7032,12 +7076,13 @@ fn append_rendered_post_review_lanes(output: &mut String, snapshot: &OperatorSta
let loop_boundary = render_loop_boundary_summary(lane.loop_status.as_ref());
output.push_str(&format!(
- "- issue_id: {}\n issue: {}\n state: {}\n classification: {}\n reason: {}\n branch: {}\n worktree_path: {}\n pr_url: {}\n pr_head_sha: {}\n pr_state: {}\n review_decision: {}\n mergeable: {}\n check_state: {}\n unresolved_review_threads: {}\n readback_warning: {}\n readback_root_cause: {}\n loop_status: {}\n loop_review: {}\n loop_architecture_recovery: {}\n loop_boundary: {}\n",
+ "- issue_id: {}\n issue: {}\n state: {}\n classification: {}\n reason: {}\n shadowed_by_active_run: {}\n branch: {}\n worktree_path: {}\n pr_url: {}\n pr_head_sha: {}\n pr_state: {}\n review_decision: {}\n mergeable: {}\n check_state: {}\n unresolved_review_threads: {}\n readback_warning: {}\n readback_root_cause: {}\n loop_status: {}\n loop_review: {}\n loop_architecture_recovery: {}\n loop_boundary: {}\n",
lane.issue_id,
lane.issue_identifier,
lane.issue_state,
lane.classification,
lane.reason,
+ if lane.shadowed_by_active_run { "yes" } else { "no" },
lane.branch_name,
lane.worktree_path,
lane.pr_url.as_deref().unwrap_or("none"),
@@ -8278,7 +8323,7 @@ fn append_rendered_run(output: &mut String, run: &OperatorRunStatus) {
let control_capability = render_control_capability_summary(run.control_capability.as_ref());
output.push_str(&format!(
- "- run_id: {}\n project_id: {}\n issue_id: {}\n issue_identifier: {}\n title: {}\n attempt: {}\n status: {}\n attempt_status: {}\n status_projection_reason: {}\n phase: {}\n wait_reason: {}\n current_operation: {}\n active_lease: {}\n queue_lease_state: {}\n queue_lease: {}\n execution_liveness: {}\n freshness_at: {}\n freshness_source: {}\n timing: run_idle={} protocol_idle={} last_progress={} protocol_event={} events={}\n account: {}\n accounts: {}\n child_agent_activity: {}\n protocol_activity: {}\n context_pressure: {}\n private_evidence: {}\n loop_status: {}\n loop_review: {}\n loop_architecture_recovery: {}\n loop_boundary: {}\n control_capability: {}\n thread_id: {}\n turn_id: {}\n thread_status: {}\n thread_active_flags: {}\n interactive_requested: {}\n continuation_pending: {}\n branch: {}\n worktree_path: {}\n updated_at: {}\n last_run_activity_at: {}\n last_protocol_activity_at: {}\n last_progress_at: {}\n idle_for_seconds: {}\n protocol_idle_for_seconds: {}\n suspected_stall: {}\n progress_diagnostic: {}\n process_id: {}\n process_alive: {}\n process_liveness_reason: {}\n retry_kind: {}\n next_retry_at: {}\n effective_model: {}\n effective_model_provider: {}\n effective_cwd: {}\n effective_approval_policy: {}\n effective_approvals_reviewer: {}\n effective_sandbox_mode: {}\n protocol_event: {}\n event_count: {}\n",
+ "- run_id: {}\n project_id: {}\n issue_id: {}\n issue_identifier: {}\n title: {}\n attempt: {}\n status: {}\n attempt_status: {}\n status_projection_reason: {}\n phase: {}\n wait_reason: {}\n current_operation: {}\n active_lease: {}\n queue_lease_state: {}\n queue_lease: {}\n execution_liveness: {}\n has_fresh_execution: {}\n counts_as_running: {}\n needs_attention: {}\n freshness_at: {}\n freshness_source: {}\n timing: run_idle={} protocol_idle={} last_progress={} protocol_event={} events={}\n account: {}\n accounts: {}\n child_agent_activity: {}\n protocol_activity: {}\n context_pressure: {}\n private_evidence: {}\n loop_status: {}\n loop_review: {}\n loop_architecture_recovery: {}\n loop_boundary: {}\n control_capability: {}\n thread_id: {}\n turn_id: {}\n thread_status: {}\n thread_active_flags: {}\n interactive_requested: {}\n continuation_pending: {}\n branch: {}\n worktree_path: {}\n updated_at: {}\n last_run_activity_at: {}\n last_protocol_activity_at: {}\n last_progress_at: {}\n idle_for_seconds: {}\n protocol_idle_for_seconds: {}\n suspected_stall: {}\n progress_diagnostic: {}\n process_id: {}\n process_alive: {}\n process_liveness_reason: {}\n retry_kind: {}\n next_retry_at: {}\n effective_model: {}\n effective_model_provider: {}\n effective_cwd: {}\n effective_approval_policy: {}\n effective_approvals_reviewer: {}\n effective_sandbox_mode: {}\n protocol_event: {}\n event_count: {}\n",
run.run_id,
run.project_id,
run.issue_id,
@@ -8295,6 +8340,9 @@ fn append_rendered_run(output: &mut String, run: &OperatorRunStatus) {
run.queue_lease_state,
queue_lease,
run.execution_liveness,
+ if run.has_fresh_execution { "yes" } else { "no" },
+ if run.counts_as_running { "yes" } else { "no" },
+ if run.needs_attention { "yes" } else { "no" },
freshness_at,
freshness_source,
idle_for_seconds,
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 7287244cd..e82115002 100644
--- a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs
+++ b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs
@@ -10,6 +10,8 @@ fn agent_evidence_snapshot_writes_index_blockers_capsules_and_event_stream() {
active_run.suspected_stall = true;
active_run.phase = String::from("stalled");
+ active_run.counts_as_running = false;
+ active_run.needs_attention = true;
let snapshot = OperatorStatusSnapshot {
project_id: String::from(TEST_SERVICE_ID),
diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs
index 8ad4f4f68..dc5f5e20b 100644
--- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs
+++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs
@@ -500,6 +500,10 @@ fn assert_liveness_and_cleanup_contract(response: &str) {
assert_contains_all(
response,
&[
+ "runHasFreshExecution",
+ "typeof run?.has_fresh_execution === \"boolean\"",
+ "typeof run?.needs_attention === \"boolean\"",
+ "typeof run?.counts_as_running === \"boolean\"",
"runStaleWithoutKnownProcessNeedsAttention",
"runExecutionLivenessSummary",
"runQueueLeaseSummary",
@@ -578,6 +582,9 @@ fn operator_dashboard_active_run_status_copy_stays_concise() {
assert!(response.contains("runNeedsAttention"));
assert!(response.contains("runCountsAsRunning"));
+ assert!(response.contains("return run.counts_as_running;"));
+ assert!(response.contains("return run.needs_attention;"));
+ assert!(response.contains("return run.has_fresh_execution;"));
assert!(response.contains("runWaitReasonShowsExecutionProgress"));
assert!(response.contains(
"[\"model_execution\", \"tool_execution\", \"protocol_activity\"].includes(run.wait_reason)"
@@ -1537,7 +1544,8 @@ fn operator_dashboard_normalizes_review_state_tokens() {
fn operator_dashboard_review_cards_omit_static_summary_copy() {
let response = dashboard_response();
- assert!(response.contains("summary: \"\",\n\t\t\t\t\t\t\tstatus: `run ${displayToken(activeRun.phase)}`"));
+ assert!(response.contains("const shadowedByActiveRun ="));
+ assert!(response.contains("status: activeRun ? `run ${displayToken(activeRun.phase)}` : \"active run\""));
assert!(response.contains("function postReviewBlockerStatus(lane, blockerScope)"));
assert!(response.contains("status: postReviewBlockerStatus(lane, blockerScope)"));
assert!(response.contains("summary: \"\",\n\t\t\t\t\t\t\tstatus: lane.check_state"));
diff --git a/apps/decodex/src/orchestrator/tests/operator/status/history.rs b/apps/decodex/src/orchestrator/tests/operator/status/history.rs
index c00e37210..95eb4e03d 100644
--- a/apps/decodex/src/orchestrator/tests/operator/status/history.rs
+++ b/apps/decodex/src/orchestrator/tests/operator/status/history.rs
@@ -845,6 +845,7 @@ fn live_status_treats_adopted_ready_to_land_history_attention_as_history_only()
mergeable: Some(String::from("MERGEABLE")),
check_state: Some(String::from("SUCCESS")),
unresolved_review_threads: Some(0),
+ shadowed_by_active_run: false,
readback_warning: None,
readback_root_cause: None,
loop_status: None,
@@ -861,7 +862,6 @@ fn live_status_treats_adopted_ready_to_land_history_attention_as_history_only()
assert_eq!(snapshot.projects[0].post_review_lane_count, 1);
assert_eq!(snapshot.projects[0].retained_worktree_count, 1);
assert_eq!(snapshot.worktrees[0].ownership, "post_review_lane");
- assert_eq!(snapshot.post_review_lanes[0].classification, "ready_to_land");
assert!(rendered.contains("Current attention: 0"));
assert!(rendered.contains("History-only terminal attention: 1"));
assert!(rendered.contains("classification: ready_to_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 76f37ca87..ca834e554 100644
--- a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs
+++ b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs
@@ -1322,6 +1322,9 @@ 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!(!run.has_fresh_execution);
+ assert!(!run.counts_as_running);
+ assert!(run.needs_attention);
assert_eq!(project.active_run_count, 1);
assert_eq!(project.running_lane_count, 0);
assert_eq!(project.attention_count, 1);
@@ -1375,12 +1378,91 @@ fn operator_status_snapshot_keeps_unleased_app_server_active_run_with_stale_proc
assert_eq!(run.process_alive, Some(false));
assert_eq!(run.process_liveness_reason.as_deref(), Some("host_boot_id_mismatch"));
assert_eq!(run.thread_status.as_deref(), Some("active"));
+ assert!(run.has_fresh_execution);
+ assert!(run.counts_as_running);
+ assert!(!run.needs_attention);
assert_eq!(project.active_run_count, 1);
assert_eq!(project.running_lane_count, 1);
assert_eq!(project.attention_count, 0);
assert_eq!(snapshot.worktrees[0].ownership, "active_lane");
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+#[test]
+fn operator_status_snapshot_shadows_post_review_lane_when_active_run_is_fresh() {
+ let (_temp_dir, config, _workflow) = temp_project_layout();
+ let state_store = StateStore::open_in_memory().expect("state store should open");
+ let issue = sample_issue("Todo", &[]);
+ let worktree_path = config.worktree_root().join("PUB-101");
+
+ state_store
+ .record_run_attempt("run-1", &issue.id, 1, "running")
+ .expect("run attempt should record");
+ state_store
+ .upsert_worktree(
+ "pubfi",
+ &issue.id,
+ "x/pubfi-pub-101",
+ &worktree_path.display().to_string(),
+ )
+ .expect("worktree should record");
+
+ state::write_run_thread_status_marker(
+ &worktree_path,
+ "run-1",
+ 1,
+ Some("thread-1"),
+ Some("turn-1"),
+ "active",
+ &[],
+ )
+ .expect("thread status should write");
+
+ rewrite_run_activity_marker_host_boot_id(&worktree_path, "previous-boot");
+
+ let mut snapshot = orchestrator::build_operator_status_snapshot(&config, &state_store, 10)
+ .expect("snapshot should build");
+
+ snapshot.post_review_lanes = vec![orchestrator::OperatorPostReviewLaneStatus {
+ project_id: String::from("pubfi"),
+ issue_id: issue.id.clone(),
+ issue_identifier: issue.identifier.clone(),
+ issue_state: String::from("In Review"),
+ branch_name: String::from("x/pubfi-pub-101"),
+ worktree_path: String::from(".worktrees/PUB-101"),
+ classification: String::from("blocked"),
+ reason: String::from("review_handoff_lineage_mismatch"),
+ pr_url: Some(String::from("https://github.com/hack-ink/pubfi-mono-v2/pull/101")),
+ pr_head_sha: Some(String::from("1111111111111111111111111111111111111111")),
+ pr_state: Some(String::from("OPEN")),
+ review_decision: Some(String::from("CHANGES_REQUESTED")),
+ mergeable: Some(String::from("UNKNOWN")),
+ check_state: Some(String::from("SUCCESS")),
+ unresolved_review_threads: Some(1),
+ shadowed_by_active_run: false,
+ readback_warning: None,
+ readback_root_cause: Some(String::from("lineage_validation_failed")),
+ loop_status: None,
+ }];
+
+ orchestrator::hydrate_post_review_lane_active_run_shadowing(&mut snapshot);
+ orchestrator::refresh_operator_project_summary(&mut snapshot, None);
+
+ let project = snapshot.projects.first().expect("project summary should exist");
+ let lane = snapshot.post_review_lanes.first().expect("post-review lane should remain visible");
+ let rendered = orchestrator::render_operator_status(&snapshot);
+
+ assert!(snapshot.active_runs[0].has_fresh_execution);
+ assert!(snapshot.active_runs[0].counts_as_running);
+ assert!(lane.shadowed_by_active_run);
+ assert_eq!(project.running_lane_count, 1);
+ assert_eq!(project.post_review_lane_count, 0);
+ assert_eq!(project.waiting_lane_count, 0);
+ assert_eq!(project.attention_count, 0);
+ assert!(rendered.contains("shadowed_by_active_run: yes"));
+ assert!(rendered.contains("readback_root_cause: lineage_validation_failed"));
+}
+
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn operator_status_snapshot_counts_reused_pid_as_attention_not_running() {
@@ -1465,6 +1547,9 @@ fn operator_status_snapshot_keeps_unleased_live_process_in_running_lanes() {
assert_eq!(run.execution_liveness, "process_alive");
assert_eq!(run.process_alive, Some(true));
assert_eq!(run.process_liveness_reason.as_deref(), Some("process_alive"));
+ assert!(run.has_fresh_execution);
+ assert!(run.counts_as_running);
+ assert!(!run.needs_attention);
assert_eq!(project.active_run_count, 1);
assert_eq!(project.running_lane_count, 1);
assert_eq!(project.retained_worktree_count, 0);
diff --git a/apps/decodex/src/orchestrator/tests/operator/status/text.rs b/apps/decodex/src/orchestrator/tests/operator/status/text.rs
index 8b2873925..e3a1d8c74 100644
--- a/apps/decodex/src/orchestrator/tests/operator/status/text.rs
+++ b/apps/decodex/src/orchestrator/tests/operator/status/text.rs
@@ -105,6 +105,9 @@ fn operator_status_text_renders_human_readable_sections() {
assert!(rendered.contains("queue_lease_state: held"));
assert!(rendered.contains("queue_lease: held"));
assert!(rendered.contains("execution_liveness: process_alive"));
+ assert!(rendered.contains("has_fresh_execution: yes"));
+ assert!(rendered.contains("counts_as_running: yes"));
+ assert!(rendered.contains("needs_attention: no"));
assert!(rendered.contains(
"timing: run_idle=1 protocol_idle=1 last_progress=2026-03-14 10:00:01Z protocol_event=turn/completed @ 2026-03-14 10:00:01 events=4"
));
@@ -966,6 +969,7 @@ fn operator_status_text_surfaces_cleanup_blocker_pr_url() {
mergeable: Some(String::from("MERGEABLE")),
check_state: Some(String::from("SUCCESS")),
unresolved_review_threads: Some(0),
+ shadowed_by_active_run: false,
readback_warning: None,
readback_root_cause: Some(String::from("lineage_validation_failed")),
loop_status: None,
diff --git a/apps/decodex/src/orchestrator/tests/operator/status_support.rs b/apps/decodex/src/orchestrator/tests/operator/status_support.rs
index 53803b55e..d1acdee1a 100644
--- a/apps/decodex/src/orchestrator/tests/operator/status_support.rs
+++ b/apps/decodex/src/orchestrator/tests/operator/status_support.rs
@@ -353,10 +353,13 @@ fn operator_status_text_active_run() -> OperatorRunStatus {
thread_active_flags: vec![String::from("waitingOnApproval")],
interactive_requested: true,
continuation_pending: false,
- active_lease: true,
- queue_lease_state: String::from("held"),
- execution_liveness: String::from("process_alive"),
- updated_at: String::from("2026-03-14 09:00:00"),
+ active_lease: true,
+ queue_lease_state: String::from("held"),
+ execution_liveness: String::from("process_alive"),
+ has_fresh_execution: true,
+ counts_as_running: true,
+ needs_attention: false,
+ updated_at: String::from("2026-03-14 09:00:00"),
last_run_activity_at: Some(String::from("2026-03-14 10:00:00Z")),
last_protocol_activity_at: Some(String::from("2026-03-14 10:00:01Z")),
last_progress_at: Some(String::from("2026-03-14 10:00:01Z")),
@@ -519,6 +522,7 @@ fn operator_status_text_post_review_lanes() -> Vec
mergeable: Some(String::from("MERGEABLE")),
check_state: Some(String::from("SUCCESS")),
unresolved_review_threads: Some(0),
+ shadowed_by_active_run: false,
readback_warning: None,
readback_root_cause: None,
loop_status: None,
diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs
index e6497f03c..7988ea6d2 100644
--- a/apps/decodex/src/orchestrator/types.rs
+++ b/apps/decodex/src/orchestrator/types.rs
@@ -1534,6 +1534,9 @@ struct OperatorRunStatus {
active_lease: bool,
queue_lease_state: String,
execution_liveness: String,
+ has_fresh_execution: bool,
+ counts_as_running: bool,
+ needs_attention: bool,
updated_at: String,
last_run_activity_at: Option,
last_protocol_activity_at: Option,
@@ -1741,6 +1744,7 @@ struct OperatorPostReviewLaneStatus {
mergeable: Option,
check_state: Option,
unresolved_review_threads: Option,
+ shadowed_by_active_run: bool,
readback_warning: Option,
readback_root_cause: Option,
#[serde(skip_serializing_if = "Option::is_none")]
diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md
index 0baaf5603..dae2b12d6 100644
--- a/docs/spec/runtime.md
+++ b/docs/spec/runtime.md
@@ -678,7 +678,7 @@ or `stalled` while current marker, active thread, or active work-protocol eviden
still identifies the same `run_id` and `attempt_number` as live, operator status must
keep the lane visible with the process/protocol liveness details instead of hiding it
as only terminal history or cleanup work.
-Post-review ownership is stored in the runtime database. Retained handoff rows record the authoritative PR URL, branch lineage, validated PR head OID, run id, and attempt number that completed the `In Review` handoff. Retained orchestration rows record the current post-review phase for that exact handoff identity. If the matching database row is missing, post-review ownership must block as unresolved instead of rebinding from branch-name, current-head, Linear comments, or other heuristics. An explicit operator manual takeover command may adopt a human-owned PR into this same retained database shape only after validating the managed clean worktree, PR repository, default-branch target, exact branch/head match, and green landable PR gates. If the active service label is missing but exists on the issue team, live adopt may restore it after all other invariants pass and must roll that restoration back if the audit write fails. If a retained review marker exists but a stored handoff or orchestration head no longer matches a clean retained worktree and matching PR head, operator status must keep the marker PR URL visible when known and recovery diagnosis must report the concrete mismatched field before any explicit rebind refresh. When the retained handoff still matches the same branch and PR, and PR readback plus local worktree lineage have already accepted the current head as the current PR head, a stale retained orchestration `head_sha` may be rebound to that current head by resetting the orchestration phase to `request_pending`, clearing prior GitHub Review request metadata, and preserving round-count history. Branch, PR, handoff-lineage, or rewritten-history mismatches must continue to block or report for operator recovery instead of being silently rebound. When retained PR readback degrades but the handoff identity is still safe to preserve, operator-local status may expose a typed `readback_root_cause` diagnostic such as missing GitHub CLI, missing GitHub token, GitHub auth failure, API/read failure, parse/shape failure, or lineage validation failure while keeping public-safe warning reasons such as `pull_request_state_read_failed` stable. Retained orchestration must preserve the post-review classification decision when it converts status readback into runtime action: only a `Block` classification may write passive retained manual attention or add `decodex:needs-attention`; degraded readback classified as `WaitForReview` must remain a wait/retry status row and must not be promoted to manual attention by the run-cycle path.
+Post-review ownership is stored in the runtime database. Retained handoff rows record the authoritative PR URL, branch lineage, validated PR head OID, run id, and attempt number that completed the `In Review` handoff. Retained orchestration rows record the current post-review phase for that exact handoff identity. If the matching database row is missing, post-review ownership must block as unresolved instead of rebinding from branch-name, current-head, Linear comments, or other heuristics. An explicit operator manual takeover command may adopt a human-owned PR into this same retained database shape only after validating the managed clean worktree, PR repository, default-branch target, exact branch/head match, and green landable PR gates. If the active service label is missing but exists on the issue team, live adopt may restore it after all other invariants pass and must roll that restoration back if the audit write fails. If a retained review marker exists but a stored handoff or orchestration head no longer matches a clean retained worktree and matching PR head, operator status must keep the marker PR URL visible when known and recovery diagnosis must report the concrete mismatched field before any explicit rebind refresh. When the retained handoff still matches the same branch and PR, and PR readback plus local worktree lineage have already accepted the current head as the current PR head, a stale retained orchestration `head_sha` may be rebound to that current head by resetting the orchestration phase to `request_pending`, clearing prior GitHub Review request metadata, and preserving round-count history. Branch, PR, handoff-lineage, or rewritten-history mismatches must continue to block or report for operator recovery instead of being silently rebound. When a fresh active run owns the same issue, operator status must project that active execution as the current lane state and mark the retained post-review lane as shadowed instead of letting stale PR readback drive current project counts. When retained PR readback degrades but the handoff identity is still safe to preserve, operator-local status may expose a typed `readback_root_cause` diagnostic such as missing GitHub CLI, missing GitHub token, GitHub auth failure, API/read failure, parse/shape failure, or lineage validation failure while keeping public-safe warning reasons such as `pull_request_state_read_failed` stable. Retained orchestration must preserve the post-review classification decision when it converts status readback into runtime action: only a non-shadowed `Block` classification may write passive retained manual attention or add `decodex:needs-attention`; degraded readback classified as `WaitForReview` must remain a wait/retry status row and must not be promoted to manual attention by the run-cycle path.
The only source-tree runtime artifacts that clean-source checks may ignore are the untracked top-level `.decodex-run-activity` heartbeat marker and `.decodex-run-control/` local control-channel directory. Durable review handoff, orchestration, review-policy checkpoints, retry, phase timing, and retained PR state belong in the Decodex runtime database, not in root-level or worktree-local review marker files. If the heartbeat marker carries similarly named fields for compatibility or operator diagnostics, those breadcrumb values cannot override runtime-store rows.
### Dispatch-slot handoff invariant
@@ -812,7 +812,7 @@ After a process restart, recent-run history, active lease ownership, retained po
and `terminal_path = "retained_partial_progress"`.
- If stalled reconciliation finds no tracked changes in the retained worktree, it must classify the lane as structured retryable recovery with `error_class = "stalled_run_detected"` while retry budget remains. The retry must keep active ownership, write a failure retry schedule for the same worktree, and must not add `decodex:needs-attention` until retry budget exhaustion or another terminal boundary applies.
- If the supervised child already exited before the next control-plane tick, stalled reconciliation must still inspect the just-finished lane using recorded protocol activity and retained worktree state rather than skipping directly to generic failure handling.
-- Operator status snapshots must expose structured liveness and wait-state fields derived from runtime records plus marker breadcrumbs, including current phase, optional wait reason, current operation, last run/protocol/progress times, idle age, a soft `suspected_stall` signal, optional progress diagnostics, and any queued retry kind plus due time, so operators can distinguish active execution from continuation waits, retry backoff, early stall suspicion, and genuine hard stalls without inferring progress from filesystem churn. `last_progress_at` is meaningful-work progress only: tool calls, file or diff changes, plan/model output, repo validation, PR/review/terminal lifecycle, or other explicit work events may refresh it, but account, rate-limit, phase-goal, passive status, warning, token-usage, heartbeat, or similar non-work protocol traffic must only refresh protocol liveness. When a lane remains in `model_execution` with fresh protocol activity but stale or missing work progress and the recent protocol events are only non-work traffic, status should expose `progress_diagnostic = "protocol_only_activity"` while preserving process and protocol liveness separately.
+- Operator status snapshots must expose structured liveness and wait-state fields derived from runtime records plus marker breadcrumbs, including current phase, optional wait reason, current operation, last run/protocol/progress times, idle age, a soft `suspected_stall` signal, optional progress diagnostics, and any queued retry kind plus due time, so operators can distinguish active execution from continuation waits, retry backoff, early stall suspicion, and genuine hard stalls without inferring progress from filesystem churn. The snapshot producer owns the derived operator booleans `has_fresh_execution`, `counts_as_running`, and `needs_attention`; dashboard, App, and other UI consumers must use those fields when present instead of reinterpreting raw `process_alive`, thread, protocol, or idle fields independently. The snapshot producer also owns `shadowed_by_active_run` on retained post-review lanes, and current project review, waiting, attention, landing, and cleanup counts must exclude lanes shadowed by fresh active execution for the same issue. `process_alive = false` is only process-marker evidence and must not be displayed as stopped when `has_fresh_execution = true`. `last_progress_at` is meaningful-work progress only: tool calls, file or diff changes, plan/model output, repo validation, PR/review/terminal lifecycle, or other explicit work events may refresh it, but account, rate-limit, phase-goal, passive status, warning, token-usage, heartbeat, or similar non-work protocol traffic must only refresh protocol liveness. When a lane remains in `model_execution` with fresh protocol activity but stale or missing work progress and the recent protocol events are only non-work traffic, status should expose `progress_diagnostic = "protocol_only_activity"` while preserving process and protocol liveness separately.
- Operator status snapshots may expose an additive `child_agent_activity` object when app-server protocol events have produced one for the current run. The object must stay machine-readable and dashboard/CLI shared, and should describe dynamic observed buckets rather than a fixed workflow: current child bucket and elapsed time, bucket wall/event/tool counts, current/max/cumulative input tokens, cumulative output tokens, largest tool output, and warnings for repeated large outputs. Missing `child_agent_activity` means no child breakdown was captured; existing JSON consumers must continue to work without it.
- If the agent Git credential preflight fails, operator status must report the retained lane as a credential failure requiring operator recovery, not as a still-running lane.
- If retry budget or needs-attention recovery finds tracked changes in the retained worktree after active phase-goal recovery has no applicable continuation path, operator status must report retained partial progress rather than only a generic retry-budget hold. Retained progress is the recovery disposition; later runtime, app-server, credential, transport, or repo-gate failure classes must be preserved as source evidence instead of overriding the retained-progress lifecycle path. The failure class may be `partial_progress_retained` when no more specific runtime error class is available. Operators should then inspect the patch, finish validation and PR handoff if it is useful, or reset the retained worktree explicitly.