From 7cc5d48dcd3a8f334dc7f9dde2434c694a8db6bb Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 17 Jun 2026 00:30:38 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"XY-970: clarify operator stage readbacks","authority":"XY-970"} --- .../Sources/DecodexApp/AccountPanelView.swift | 61 +++++++-- .../DecodexApp/OperatorSnapshotModels.swift | 22 +++- apps/decodex/src/execution_program.rs | 7 ++ .../src/orchestrator/harness_improvement.rs | 4 +- .../src/orchestrator/operator_dashboard.html | 119 ++++++++++-------- apps/decodex/src/orchestrator/status.rs | 79 ++++++++++-- .../tests/operator/status/agent_evidence.rs | 1 + .../tests/operator/status/dashboard.rs | 44 ++++--- .../tests/operator/status/history.rs | 16 ++- .../tests/operator/status/text.rs | 17 ++- .../tests/operator/status_support.rs | 3 + apps/decodex/src/orchestrator/types.rs | 10 ++ docs/spec/agent-evidence.md | 3 +- docs/spec/loop-runtime.md | 1 + docs/spec/runtime.md | 4 +- 15 files changed, 290 insertions(+), 101 deletions(-) diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 0f0c83712..0a82d7105 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -2878,6 +2878,12 @@ struct OperatorLanePopoverView: View { ) } + if statusReadoutItems.isEmpty == false { + measuredReadout { + OperatorLaneReadoutRow(title: "Status", items: statusReadoutItems) + } + } + if let modelProgress { measuredReadout { OperatorLaneProgressReadoutRow( @@ -3007,12 +3013,49 @@ struct OperatorLanePopoverView: View { private var hasReadoutContent: Bool { modelProgress != nil + || statusReadoutItems.isEmpty == false || totalOverviewMetrics.isEmpty == false || detailBuckets.isEmpty == false || lifecycleTableRows.isEmpty == false || fallbackRunReadoutItems.isEmpty == false } + private var statusReadoutItems: [OperatorLaneReadoutItem] { + var items = [OperatorLaneReadoutItem]() + + if let runPhase = panelTrimmed(run.runPhase ?? run.phase) { + items.append( + OperatorLaneReadoutItem(label: "run phase", value: rawPanelToken(runPhase)) + ) + } + if let currentOperation = panelTrimmed(run.currentOperation) { + items.append( + OperatorLaneReadoutItem( + label: "current operation", + value: rawPanelToken(currentOperation) + ) + ) + } + if let activeGoalPhase = panelTrimmed(run.activeGoalPhase) { + items.append( + OperatorLaneReadoutItem( + label: "active goal phase", + value: rawPanelToken(activeGoalPhase) + ) + ) + } + if let publicProgressPhase = panelTrimmed(run.publicProgressPhase) { + items.append( + OperatorLaneReadoutItem( + label: "public progress phase", + value: rawPanelToken(publicProgressPhase) + ) + ) + } + + return items + } + private var fallbackRunReadoutItems: [OperatorLaneReadoutItem] { guard let activity else { return [] @@ -3187,9 +3230,9 @@ struct OperatorLanePopoverView: View { return lifecycleMetrics.phases.map { phase in lifecycleTableRow( - stage: panelTrimmed(phase.label) + lifecycleBucket: panelTrimmed(phase.label) ?? panelTrimmed(phase.phase).map(rawPanelToken) - ?? "Phase", + ?? "Lifecycle bucket", attemptCount: phase.attemptCount, wallSeconds: phase.wallSeconds, buckets: phase.buckets, @@ -3202,7 +3245,7 @@ struct OperatorLanePopoverView: View { } private func lifecycleTableRow( - stage: String, + lifecycleBucket: String, attemptCount: Int, wallSeconds: Int, buckets: [OperatorLifecycleMetricBucket], @@ -3223,7 +3266,7 @@ struct OperatorLanePopoverView: View { let largestOutput = formatLargestOutput(bytes: largestOutputBytes) return OperatorLifecycleTableRow( - stage: stage, + lifecycleBucket: lifecycleBucket, attempts: attemptCount > 0 ? formatCompactCount(attemptCount) : "-", runtime: runtime.text, inputTokens: inputTokens > 0 ? formatCompactCount(inputTokens) : "-", @@ -3560,7 +3603,7 @@ private enum OperatorTotalMetricGridCellRole { } struct OperatorLifecycleTableRow: Identifiable { - let stage: String + let lifecycleBucket: String let attempts: String let runtime: String let inputTokens: String @@ -3569,7 +3612,7 @@ struct OperatorLifecycleTableRow: Identifiable { let largestOutput: String var id: String { - stage + lifecycleBucket } } @@ -3584,7 +3627,7 @@ struct OperatorLifecycleTableView: View { verticalSpacing: OperatorLaneReadoutLayout.itemRowSpacing ) { GridRow(alignment: .firstTextBaseline) { - headerCell("Stage", alignment: .leading) + headerCell("Lifecycle bucket", alignment: .leading) headerCell("attempts", alignment: .trailing) headerCell("inference", alignment: .trailing) headerCell("input", alignment: .trailing) @@ -3594,7 +3637,7 @@ struct OperatorLifecycleTableView: View { } ForEach(rows) { row in GridRow(alignment: .firstTextBaseline) { - tableCell(row.stage, alignment: .leading) + tableCell(row.lifecycleBucket, alignment: .leading) tableCell(row.attempts, alignment: .trailing) tableCell(row.runtime, alignment: .trailing) tableCell(row.inputTokens, alignment: .trailing) @@ -3611,7 +3654,7 @@ struct OperatorLifecycleTableView: View { private var accessibilityText: String { rows.map { row in - "\(row.stage), attempts \(row.attempts), inference \(row.runtime), input \(row.inputTokens), output \(row.outputTokens), tools \(row.toolCalls), max output \(row.largestOutput)" + "\(row.lifecycleBucket), attempts \(row.attempts), inference \(row.runtime), input \(row.inputTokens), output \(row.outputTokens), tools \(row.toolCalls), max output \(row.largestOutput)" } .joined(separator: "; ") } diff --git a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift index 502c70e74..d37a77a12 100644 --- a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift +++ b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift @@ -427,8 +427,11 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { let attemptStatus: String? let attemptNumber: Int? let phase: String? + let runPhase: String? let waitReason: String? let currentOperation: String? + let activeGoalPhase: String? + let publicProgressPhase: String? let threadStatus: String? let threadActiveFlags: [String] let idleForSeconds: Int? @@ -482,8 +485,8 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { if let operation = trimmed(currentOperation), operation.isEmpty == false, operation != "idle" { return rawDisplayToken(operation) } - if let phase = trimmed(phase), phase.isEmpty == false { - return rawDisplayToken(phase) + if let runPhase = trimmed(runPhase ?? phase), runPhase.isEmpty == false { + return rawDisplayToken(runPhase) } if let threadStatus = trimmed(threadStatus), threadStatus.isEmpty == false { return rawDisplayToken(threadStatus) @@ -637,8 +640,11 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { attemptStatus: activity.attemptStatus ?? attemptStatus, attemptNumber: activity.attemptNumber ?? attemptNumber, phase: activity.phase ?? phase, + runPhase: activity.runPhase ?? runPhase, waitReason: activity.waitReason ?? waitReason, currentOperation: activity.currentOperation ?? currentOperation, + activeGoalPhase: activity.activeGoalPhase ?? activeGoalPhase, + publicProgressPhase: activity.publicProgressPhase ?? publicProgressPhase, threadStatus: activity.threadStatus ?? threadStatus, threadActiveFlags: activity.threadActiveFlags.isEmpty ? threadActiveFlags : activity.threadActiveFlags, idleForSeconds: activity.idleForSeconds ?? idleForSeconds, @@ -693,8 +699,11 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { case attemptStatus = "attempt_status" case attemptNumber = "attempt_number" case phase + case runPhase = "run_phase" case waitReason = "wait_reason" case currentOperation = "current_operation" + case activeGoalPhase = "active_goal_phase" + case publicProgressPhase = "public_progress_phase" case threadStatus = "thread_status" case threadActiveFlags = "thread_active_flags" case idleForSeconds = "idle_for_seconds" @@ -736,8 +745,11 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { attemptStatus = try container.decodeIfPresent(String.self, forKey: .attemptStatus) attemptNumber = try container.decodeIfPresent(Int.self, forKey: .attemptNumber) phase = try container.decodeIfPresent(String.self, forKey: .phase) + runPhase = try container.decodeIfPresent(String.self, forKey: .runPhase) waitReason = try container.decodeIfPresent(String.self, forKey: .waitReason) currentOperation = try container.decodeIfPresent(String.self, forKey: .currentOperation) + activeGoalPhase = try container.decodeIfPresent(String.self, forKey: .activeGoalPhase) + publicProgressPhase = try container.decodeIfPresent(String.self, forKey: .publicProgressPhase) threadStatus = try container.decodeIfPresent(String.self, forKey: .threadStatus) threadActiveFlags = try container.decodeIfPresent([String].self, forKey: .threadActiveFlags) ?? [] idleForSeconds = try container.decodeIfPresent(Int.self, forKey: .idleForSeconds) @@ -784,8 +796,11 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { attemptStatus: String?, attemptNumber: Int?, phase: String?, + runPhase: String?, waitReason: String?, currentOperation: String?, + activeGoalPhase: String?, + publicProgressPhase: String?, threadStatus: String?, threadActiveFlags: [String], idleForSeconds: Int?, @@ -821,8 +836,11 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { self.attemptStatus = attemptStatus self.attemptNumber = attemptNumber self.phase = phase + self.runPhase = runPhase self.waitReason = waitReason self.currentOperation = currentOperation + self.activeGoalPhase = activeGoalPhase + self.publicProgressPhase = publicProgressPhase self.threadStatus = threadStatus self.threadActiveFlags = threadActiveFlags self.idleForSeconds = idleForSeconds diff --git a/apps/decodex/src/execution_program.rs b/apps/decodex/src/execution_program.rs index 4aee65ecb..187a9b531 100644 --- a/apps/decodex/src/execution_program.rs +++ b/apps/decodex/src/execution_program.rs @@ -1253,6 +1253,7 @@ impl ExecutionProgramReadinessContext { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ExecutionNodeEvaluation { node_id: String, + stage: ExecutionProgramNodeStage, state: ExecutionReadinessState, lifecycle_state: ExecutionProgramNodeLifecycleState, reasons: Vec, @@ -1265,6 +1266,11 @@ impl ExecutionNodeEvaluation { &self.node_id } + /// Program node stage. + pub(crate) fn stage(&self) -> ExecutionProgramNodeStage { + self.stage + } + /// Normalized readiness state. pub(crate) fn state(&self) -> ExecutionReadinessState { self.state @@ -1524,6 +1530,7 @@ fn evaluate_node(input: EvaluateNodeInput<'_>) -> Result, linear_issue_identifier: Option, @@ -481,7 +481,7 @@ fn harness_outcome_program(record: &ExecutionProgramRecord) -> HarnessOutcomePro HarnessOutcomeProgramNode { node_id: node.node_id().to_owned(), - stage: node.stage().as_str().to_owned(), + program_stage: node.stage().as_str().to_owned(), queue_intent: node.queue_intent().as_str().to_owned(), linear_issue_id: linear_issue.map(|issue| issue.issue_id().to_owned()), linear_issue_identifier: linear_issue diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index 1c5e8420a..437843f86 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -636,7 +636,7 @@ pointer-events: none; } - .flow-stage span { + .flow-step span { font-family: var(--mono); font-size: var(--type-label); font-weight: var(--weight-label); @@ -644,7 +644,7 @@ color: var(--muted-strong); } - .flow-stage-labels { + .flow-step-labels { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: baseline; @@ -652,7 +652,7 @@ min-width: 0; } - .flow-stage { + .flow-step { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: baseline; @@ -663,15 +663,15 @@ color var(--fast) var(--ease); } - .flow-stage:first-child { + .flow-step:first-child { padding-left: 0; } - .flow-stage + .flow-stage { + .flow-step + .flow-step { border-left: 1px solid var(--line); } - .flow-stage strong { + .flow-step strong { color: var(--text); font-family: var(--mono); font-size: var(--type-label); @@ -685,40 +685,40 @@ white-space: nowrap; } - .flow-stage.active span { + .flow-step.active span { color: var(--accent); } - .flow-stage.active strong { + .flow-step.active strong { color: var(--accent); } - .flow-stage strong .metric-number { + .flow-step strong .metric-number { color: var(--text); } - .flow-stage strong .metric-label { + .flow-step strong .metric-label { color: var(--muted); } - .flow-stage.active strong .metric-number, - .flow-stage.active strong .metric-label { + .flow-step.active strong .metric-number, + .flow-step.active strong .metric-label { color: var(--accent); } - .flow-stage[data-flow-stage="queue"] { + .flow-step[data-flow-step="queue"] { --accent: var(--tone-queue); } - .flow-stage[data-flow-stage="run"] { + .flow-step[data-flow-step="run"] { --accent: var(--tone-run); } - .flow-stage[data-flow-stage="review"] { + .flow-step[data-flow-step="review"] { --accent: var(--tone-review); } - .flow-stage[data-flow-stage="land"] { + .flow-step[data-flow-step="land"] { --accent: var(--tone-land); } @@ -3386,21 +3386,21 @@ grid-template-columns: 1fr; } - .flow-stage-labels { + .flow-step-labels { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 0; } - .flow-stage { + .flow-step { padding-top: 8px; } - .flow-stage:nth-child(odd) { + .flow-step:nth-child(odd) { border-left: 0; padding-left: 0; } - .flow-stage:nth-child(n + 3) { + .flow-step:nth-child(n + 3) { border-top: 1px solid var(--line); } @@ -3535,15 +3535,15 @@ padding-top: 10px; } - .flow-stage-labels { + .flow-step-labels { grid-template-columns: 1fr; } - .flow-stage { + .flow-step { padding: 8px 0 0; } - .flow-stage + .flow-stage { + .flow-step + .flow-step { border-top: 1px solid var(--line); border-left: 0; } @@ -3669,20 +3669,20 @@

Decodex

-
-
+
+
Intake 0 issues
-
+
Running 0 lanes
-
+
Review 0 PRs
-
+
Landing 0 PRs
@@ -3920,7 +3920,7 @@

Run History

const nodes = { projectTitle: document.getElementById("project-title"), - flowStageLabels: [...document.querySelectorAll("[data-flow-stage]")], + flowStepLabels: [...document.querySelectorAll("[data-flow-step]")], flowCounts: { queue: document.getElementById("flow-queue"), run: document.getElementById("flow-run"), @@ -4485,18 +4485,18 @@

Run History

return runProcessStoppedWhileActive(run) && !runStoppedProcessNeedsAttention(run); } - function runStageLabel(run) { + function runPhaseLabel(run) { if (runStoppedProcessNeedsAttention(run)) { return run.process_liveness_reason || "process_stopped"; } if (runProcessStoppedWhileActive(run)) { - return run.current_operation || run.phase || "process_stopped"; + return run.current_operation || run.run_phase || run.phase || "process_stopped"; } - if (run.phase === "executing") { - return displayToken(run.current_operation || run.phase); + if ((run.run_phase || run.phase) === "executing") { + return displayToken(run.current_operation || run.run_phase || run.phase); } - return displayToken(run.phase || run.status); + return displayToken(run.run_phase || run.phase || run.status); } function runStaleWithoutKnownProcessAgeSeconds(run) { @@ -6364,6 +6364,16 @@

Run History

const freshness = currentLaneFreshness(run); const focus = protocolActivityFocus(run); + facts.push(["run phase", displayToken(run.run_phase || run.phase || run.status)]); + if (run.current_operation) { + facts.push(["current operation", displayToken(run.current_operation)]); + } + if (run.active_goal_phase) { + facts.push(["active goal phase", displayToken(run.active_goal_phase)]); + } + if (run.public_progress_phase) { + facts.push(["public progress phase", displayToken(run.public_progress_phase)]); + } if (freshness.timestamp) { facts.push([freshness.sourceLabel, formatRelativeTimestamp(freshness.timestamp)]); } @@ -8738,7 +8748,7 @@

Run History

return ""; } - const header = ["Stage", "attempts", "inference", "input", "output", "tools", "max output"]; + const header = ["Lifecycle bucket", "attempts", "inference", "input", "output", "tools", "max output"]; const alignRight = new Set([1, 2, 3, 4, 5, 6]); const cells = [ ...header.map( @@ -8753,7 +8763,7 @@

Run History

), ].join(""); - return `
${cells}
`; + return `
${cells}
`; } function renderChildLifecyclePhaseCell(value, index, alignRight) { @@ -8919,12 +8929,12 @@

Run History

if (run.current_operation && run.current_operation !== "idle") { return displayToken(run.current_operation); } - return displayToken(run.phase || run.status); + return displayToken(run.run_phase || run.phase || run.status); } function recentRunSummary(run, lane = null) { if ((lane?.attempt_count ?? 1) > 1) { - return `Latest run ${displayToken(run.status || run.phase)}; lifecycle cost is grouped by phase.`; + return `Latest run ${displayToken(run.status || run.run_phase || run.phase)}; lifecycle cost is grouped by lifecycle bucket.`; } if (isSuccessfulTerminalRun(run)) { return "Finished; no current lane."; @@ -9071,7 +9081,7 @@

Run History

return ""; } if (runProcessStoppedWhileActive(run)) { - return `Agent done; finalizing ${displayToken(run.current_operation || run.phase)}.`; + return `Agent done; finalizing ${displayToken(run.current_operation || run.run_phase || run.phase)}.`; } if (run.suspected_stall) { return `No progress ${formatDuration(run.protocol_idle_for_seconds ?? run.idle_for_seconds)}.`; @@ -9109,7 +9119,7 @@

Run History

function runIssueTitle(run, derived) { const issueRecord = runIssueRecord(run, derived); - const operationFallback = displayToken(run.current_operation || run.phase); + const operationFallback = displayToken(run.current_operation || run.run_phase || run.phase); const fallback = issueDisplayKey(run); for (const value of [run.title, run.issue_title, issueRecord?.title]) { @@ -9139,7 +9149,7 @@

Run History

return "needs_attention"; } if (runProcessStoppedWhileActive(run)) { - return runStageLabel(run); + return runPhaseLabel(run); } if (run.suspected_stall) { return "needs_attention"; @@ -9162,7 +9172,7 @@

Run History

if (run.thread_status) { return displayToken(run.thread_status); } - return displayToken(run.status || run.phase); + return displayToken(run.status || run.run_phase || run.phase); } function snapshotIsIdle(snapshot) { @@ -9525,7 +9535,7 @@

Run History

issue: issueKey, title: runStoppedProcessNeedsAttention(run) ? "Stopped agent process" - : displayToken(run.current_operation || run.phase), + : displayToken(run.current_operation || run.run_phase || run.phase), summary: runStoppedProcessNeedsAttention(run) ? `Agent process stopped while lane remains active. ${COPY.protocolEvent} ${run.last_event_type || "none"}.` : runStaleWithoutKnownProcessNeedsAttention(run) @@ -9533,7 +9543,7 @@

Run History

: `No confirmed progress for ${formatDuration(run.idle_for_seconds)}. ${COPY.protocolEvent} ${run.last_event_type || "none"}.`, status: runStoppedProcessNeedsAttention(run) ? "attention stopped" - : `phase ${displayToken(run.phase)}`, + : `run phase ${displayToken(run.run_phase || run.phase)}`, facts: [ ["Codex thread", runThreadSummary(run)], ["Thread flags", runThreadFlagSummary(run)], @@ -9559,10 +9569,10 @@

Run History

tone, scope: "Running", issue: issueKey, - title: displayToken(run.current_operation || run.phase), + title: displayToken(run.current_operation || run.run_phase || run.phase), summary: run.next_retry_at ? `Retry scheduled for ${formatTimestamp(run.next_retry_at)}.` - : `Waiting on ${displayToken(run.wait_reason || run.phase)}.`, + : `Waiting on ${displayToken(run.wait_reason || run.run_phase || run.phase)}.`, status: "waiting", facts: [ ["Codex thread", runThreadSummary(run)], @@ -9592,7 +9602,7 @@

Run History

["Run", currentLane.run_id], [ "Operation", - displayToken(currentLane.current_operation || currentLane.phase), + displayToken(currentLane.current_operation || currentLane.run_phase || currentLane.phase), ], ] : [["Run", "active"]]; @@ -9603,7 +9613,9 @@

Run History

issue: issueKey, title: "Repair running", summary: "", - status: currentLane ? `run ${displayToken(currentLane.phase)}` : "current lane", + status: currentLane + ? `run phase ${displayToken(currentLane.run_phase || currentLane.phase)}` + : "current lane", facts: [ ...currentLaneFacts, ["Checks", compactStateToken(lane.check_state)], @@ -10274,8 +10286,8 @@

Run History

} function setFlowActivity(activity) { - for (const label of nodes.flowStageLabels) { - label.classList.toggle("active", Boolean(activity[label.dataset.flowStage])); + for (const label of nodes.flowStepLabels) { + label.classList.toggle("active", Boolean(activity[label.dataset.flowStep])); } } @@ -10450,6 +10462,7 @@

Run History

const reasonCodes = (node.reason_codes ?? []).map(displayToken).join(", ") || "none"; return ` ${field("Issue", programNodeIssue(node))} + ${field("Program stage", displayToken(node.program_stage || "unknown"))} ${field("Lifecycle", displayToken(node.lifecycle_state || "unknown"))} ${field("Readiness", displayToken(node.readiness_state || "unknown"))} ${field("Issue state", node.issue_state || "none")} @@ -10628,7 +10641,7 @@

${escapeHtml(item.title)}

runs .map((run) => { const tone = toneForRun(run); - const statusBits = [statusLabel(runStageLabel(run), tone)]; + const statusBits = [statusLabel(runPhaseLabel(run), tone)]; const detailKey = runDetailKey(run); const renderKey = currentLaneRenderKey(run); const issueKey = issueDisplayKey(run); @@ -10747,7 +10760,7 @@

${escapeHtml(issueTitle)}

return `
- ${escapeHtml(pluralize(phases.length, "phase"))} + ${escapeHtml(pluralize(phases.length, "lifecycle bucket"))}
${phases .map( @@ -11248,7 +11261,7 @@

${escapeHtml(worktree.branch_name)}

return false; } - return title === displayToken(run?.current_operation || run?.phase); + return title === displayToken(run?.current_operation || run?.run_phase || run?.phase); } function dashboardRunIdentityKeys(run) { diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs index 6313e09db..6430614db 100644 --- a/apps/decodex/src/orchestrator/status.rs +++ b/apps/decodex/src/orchestrator/status.rs @@ -1371,6 +1371,7 @@ fn apply_terminal_history_ledger_outcome_to_latest_run(lane: &mut OperatorHistor lane.latest_run.attempt_status = final_outcome; lane.latest_run.status_projection_reason = None; lane.latest_run.phase = String::from(if requires_attention { "needs_attention" } else { "completed" }); + lane.latest_run.run_phase = lane.latest_run.phase.clone(); lane.latest_run.wait_reason = None; lane.latest_run.current_operation = String::from("ledger_outcome"); lane.latest_run.continuation_pending = false; @@ -5839,6 +5840,10 @@ fn operator_run_status( ); let private_evidence = operator_run_private_evidence(project, &run, issue_identifier.as_deref()); let continuation_recovery = operator_run_continuation_recovery_status(loop_evidence, &run); + let private_events = + loop_evidence.private_events(run.issue_id(), run.run_id(), run.attempt_number()); + let active_goal_phase = operator_run_active_goal_phase(private_events); + let public_progress_phase = operator_run_public_progress_phase(private_events); let loop_status = operator_run_loop_status( project, @@ -5846,7 +5851,7 @@ fn operator_run_status( &run, &lifecycle.status, &lifecycle.phase, - &lifecycle.current_operation, + &lifecycle.current_operation, )?; Ok(hydrate_operator_run_derived_status(operator_run_status_from_parts( @@ -5868,6 +5873,8 @@ fn operator_run_status( issue_identifier, private_evidence, continuation_recovery, + active_goal_phase, + public_progress_phase, loop_status, ))) } @@ -5892,8 +5899,12 @@ fn operator_run_status_from_parts( issue_identifier: Option, private_evidence: AgentPrivateEvidenceRef, continuation_recovery: Option, + active_goal_phase: Option, + public_progress_phase: Option, loop_status: OperatorLoopStatus, ) -> OperatorRunStatus { + let run_phase = lifecycle.phase.clone(); + OperatorRunStatus { project_id: project.service_id().to_owned(), project_display_name: project_display_name.to_owned(), @@ -5916,8 +5927,11 @@ fn operator_run_status_from_parts( lane_control_next_action: String::new(), lane_control_conditions: Vec::new(), phase: lifecycle.phase, + run_phase, wait_reason, current_operation: lifecycle.current_operation, + active_goal_phase, + public_progress_phase, control_capability: operator_run_control_capability(run, &app_server_state), thread_id: app_server_state.thread_id, turn_id: app_server_state.turn_id, @@ -5968,6 +5982,46 @@ fn operator_run_status_from_parts( } } +fn operator_run_active_goal_phase(events: &[PrivateExecutionEvent]) -> Option { + for event in events.iter().rev() { + if matches!(event.event_type(), "phase_goal_completed" | "phase_goal_transition") { + return None; + } + if !matches!(event.event_type(), "phase_goal_set" | "phase_goal_status") { + continue; + } + + let payload = event.payload(); + let nested = payload.get("payload").unwrap_or(payload); + let status = nested + .get("status") + .or_else(|| payload.get("status")) + .and_then(Value::as_str); + + if status.is_some_and(|value| matches!(value, "complete" | "completed" | "blocked")) { + return None; + } + + return nested + .get("phase") + .or_else(|| payload.get("phase")) + .and_then(Value::as_str) + .map(str::to_owned); + } + + None +} + +fn operator_run_public_progress_phase(events: &[PrivateExecutionEvent]) -> Option { + events.iter().rev().find_map(|event| { + (event.event_type() == "progress_checkpoint") + .then_some(event.payload()) + .and_then(|payload| payload.get("phase")) + .and_then(Value::as_str) + .map(str::to_owned) + }) +} + 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); @@ -7723,9 +7777,10 @@ fn append_rendered_execution_programs(output: &mut String, snapshot: &OperatorSt }; output.push_str(&format!( - " - node: issue={} issue_state={} lifecycle={} readiness={} dispatch_action={} reason_codes={} reasons=\"{}\" next_action=\"{}\"\n", + " - node: issue={} issue_state={} program_stage={} lifecycle={} readiness={} dispatch_action={} reason_codes={} reasons=\"{}\" next_action=\"{}\"\n", issue_identifier, issue_state, + node.program_stage, node.lifecycle_state, node.readiness_state, dispatch_action, @@ -8895,13 +8950,13 @@ fn append_rendered_history_lane(output: &mut String, lane: &OperatorHistoryLaneS return; } - output.push_str(" phase_breakdown:\n"); + output.push_str(" lifecycle_bucket_breakdown:\n"); for phase in &lane.lifecycle_metrics.phases { output.push_str(&format!( - " - phase: {} label: {} attempts: {} captured: {}/{} protocol_events: {} child_events: {} wall: {} tool_calls: {} input_tokens: {} output_tokens: {}\n", - phase.phase, + " - lifecycle_bucket: {} lifecycle_bucket_key: {} attempts: {} captured: {}/{} protocol_events: {} child_events: {} wall: {} tool_calls: {} input_tokens: {} output_tokens: {}\n", phase.label, + phase.phase, phase.attempt_count, phase.captured_attempt_count, phase.attempt_count, @@ -8975,6 +9030,14 @@ fn history_ledger_outcome_has_records(outcome: &OperatorHistoryLedgerOutcome) -> matches!(outcome.ledger_status.as_str(), "present" | "partial") } +fn operator_run_phase_readback(run: &OperatorRunStatus) -> &str { + if run.run_phase.trim().is_empty() { + &run.phase + } else { + &run.run_phase + } +} + fn append_rendered_run(output: &mut String, run: &OperatorRunStatus) { let (freshness_source, freshness_at) = operator_run_freshness(run); let protocol_event = render_run_protocol_event(run); @@ -9012,7 +9075,7 @@ fn append_rendered_run(output: &mut String, run: &OperatorRunStatus) { render_continuation_recovery_summary(run.continuation_recovery.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 ownership_state: {}\n liveness_state: {}\n policy_state: {}\n terminalization_state: {}\n lane_control_next_action: {}\n lane_control_conditions: {}\n phase: {}\n wait_reason: {}\n current_operation: {}\n run_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 continuation_recovery: {}\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 ownership_state: {}\n liveness_state: {}\n policy_state: {}\n terminalization_state: {}\n lane_control_next_action: {}\n lane_control_conditions: {}\n run_phase: {}\n wait_reason: {}\n current_operation: {}\n active_goal_phase: {}\n public_progress_phase: {}\n run_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 continuation_recovery: {}\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, @@ -9028,9 +9091,11 @@ fn append_rendered_run(output: &mut String, run: &OperatorRunStatus) { run.terminalization_state, run.lane_control_next_action, lane_control_conditions, - run.phase, + operator_run_phase_readback(run), run.wait_reason.as_deref().unwrap_or("none"), run.current_operation, + run.active_goal_phase.as_deref().unwrap_or("none"), + run.public_progress_phase.as_deref().unwrap_or("none"), if run.run_lease { "yes" } else { "no" }, run.queue_lease_state, queue_lease, 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 3568a6590..0c6cd8b00 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,7 @@ fn agent_evidence_snapshot_writes_index_blockers_capsules_and_event_stream() { current_lane.suspected_stall = true; current_lane.phase = String::from("stalled"); + current_lane.run_phase = String::from("stalled"); current_lane.counts_as_running = false; current_lane.needs_attention = true; diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs index 7c62b988b..873683b9e 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs @@ -58,6 +58,7 @@ fn operator_dashboard_surfaces_program_intake_panel() { assert!(response.contains("function renderProgramNodeReadbacks(program)")); assert!(response.contains("program.node_readbacks ?? []")); assert!(response.contains("program.dispatchable_count")); + assert!(response.contains("field(\"Program stage\", displayToken(node.program_stage || \"unknown\"))")); assert!(response.contains("node.dispatch_action")); assert!(response.contains("renderExecutionPrograms(snapshot, derived);")); assert!(response.contains("primary: [\"accountPool\", \"projects\", \"currentLanes\", \"programs\", \"queue\", \"review\", \"worktrees\", \"recent\"]")); @@ -107,13 +108,13 @@ fn operator_dashboard_uses_shared_type_scale_for_operator_rows() { .split(".section-marker-control") .next() .expect("section marker bar style should end before meta rule"); - let flow_stage_label = response - .split(".flow-stage span {") + let flow_step_label = response + .split(".flow-step span {") .nth(1) - .expect("flow stage label style should exist") - .split(".flow-stage-labels") + .expect("flow step label style should exist") + .split(".flow-step-labels") .next() - .expect("flow stage label style should end before grid rule"); + .expect("flow step label style should end before grid rule"); let table_meta = response .split(".table-meta {") .nth(1) @@ -154,11 +155,11 @@ fn operator_dashboard_uses_shared_type_scale_for_operator_rows() { assert!(!section_marker_title.contains("text-transform: uppercase;")); assert!(section_marker_bar.contains("height: 14px;")); assert!(!response.contains(".section-marker > .table-meta {")); - assert!(flow_stage_label.contains("font-family: var(--mono);")); - assert!(flow_stage_label.contains("font-size: var(--type-label);")); - assert!(flow_stage_label.contains("font-weight: var(--weight-label);")); - assert!(!flow_stage_label.contains("text-transform: uppercase;")); - assert!(flow_stage_label.contains("color: var(--muted-strong);")); + assert!(flow_step_label.contains("font-family: var(--mono);")); + assert!(flow_step_label.contains("font-size: var(--type-label);")); + assert!(flow_step_label.contains("font-weight: var(--weight-label);")); + assert!(!flow_step_label.contains("text-transform: uppercase;")); + assert!(flow_step_label.contains("color: var(--muted-strong);")); assert!(panel_title.contains("font-size: var(--type-section-title);")); assert!(panel_title.contains("font-weight: var(--weight-label);")); assert!(panel_title.contains("font-family: var(--sans);")); @@ -432,14 +433,15 @@ fn assert_child_lifecycle_contract(response: &str) { "", "repeat(4, max-content max-content);", ".child-total-segment {\n\t\t\t\tdisplay: contents;", - "
", + "
", ".child-phase-table {\n\t\t\t\tdisplay: inline-grid;\n\t\t\t\tgrid-template-columns:\n\t\t\t\t\tmax-content", "gap: 4px clamp(24px, 2vw, 34px);", "overflow: hidden;", "text-overflow: ellipsis;", "function formatLargestOutputValue(bytes)", "return formatCompactBytes(bytes);", - "const header = [\"Stage\", \"attempts\", \"inference\", \"input\", \"output\", \"tools\", \"max output\"];", + "const header = [\"Lifecycle bucket\", \"attempts\", \"inference\", \"input\", \"output\", \"tools\", \"max output\"];", + "pluralize(phases.length, \"lifecycle bucket\")", "const alignRight = new Set([1, 2, 3, 4, 5, 6]);", "width: fit-content;\n\t\t\t\tmax-width: 100%;", "\"tools\"", @@ -555,7 +557,7 @@ fn assert_liveness_and_cleanup_contract(response: &str) { } #[test] -fn operator_dashboard_history_lifecycle_metrics_are_grouped_by_phase() { +fn operator_dashboard_history_lifecycle_metrics_are_grouped_by_lifecycle_bucket() { let response = dashboard_response(); assert!(response.contains("function historyLaneLifecycleMetrics(lane)")); @@ -607,9 +609,11 @@ fn operator_dashboard_current_lane_status_copy_stays_concise() { assert!(response.contains("run.wait_reason && !runWaitReasonShowsExecutionProgress(run)")); assert!(response.contains("runOperationRequiresLiveAgent")); assert!(response.contains("runProcessStoppedWithoutAttention")); - assert!(response.contains("runStageLabel")); + assert!(response.contains("runPhaseLabel")); assert!(response.contains("return run.process_liveness_reason || \"process_stopped\";")); - assert!(response.contains("return run.current_operation || run.phase || \"process_stopped\";")); + assert!( + response.contains("return run.current_operation || run.run_phase || run.phase || \"process_stopped\";") + ); assert!(response.contains("Operator input needed.")); assert!(response.contains("Protocol idle.")); assert!(response.contains("Stopped agent process")); @@ -1560,7 +1564,7 @@ fn operator_dashboard_review_cards_omit_static_summary_copy() { let response = dashboard_response(); assert!(response.contains("const shadowedByCurrentLane =")); - assert!(response.contains("status: currentLane ? `run ${displayToken(currentLane.phase)}` : \"current lane\"")); + assert!(response.contains("`run phase ${displayToken(currentLane.run_phase || currentLane.phase)}`")); 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")); @@ -1780,6 +1784,10 @@ fn operator_dashboard_active_freshness_prefers_live_activity_source() { assert!(response.contains("facts.push([\"focus\", detailLabel(focus)]);")); assert!(response.contains("function currentLaneLifecycleMetrics(run, summary = childAgentActivity(run))")); assert!(response.contains("function lifecycleMetricFacts(metrics, { includeAttempts = false } = {})")); + assert!(response.contains("facts.push([\"run phase\", displayToken(run.run_phase || run.phase || run.status)]);")); + assert!(response.contains("facts.push([\"current operation\", displayToken(run.current_operation)]);")); + assert!(response.contains("facts.push([\"active goal phase\", displayToken(run.active_goal_phase)]);")); + assert!(response.contains("facts.push([\"public progress phase\", displayToken(run.public_progress_phase)]);")); assert!(response.contains("facts.push([\"tokens\", tokenSummary]);")); assert!(response.contains("facts.push([\"tools\", formatCompactCount(metrics.tool_call_count)]);")); assert!(response.contains("\"max output\",")); @@ -1849,7 +1857,9 @@ fn operator_dashboard_run_activity_preserves_snapshot_detail_fields() { response.contains("function mergeDashboardCurrentLanes(snapshot, currentLaneRows, currentLanesComplete = true)") ); assert!(response.contains("function dashboardRunTitleIsOperationFallback(run)")); - assert!(response.contains("const operationFallback = displayToken(run.current_operation || run.phase);")); + assert!( + response.contains("const operationFallback = displayToken(run.current_operation || run.run_phase || run.phase);") + ); assert!(response.contains("!(fallback !== \"unknown\" && title === operationFallback)")); assert!(response.contains("return fallback !== \"unknown\" ? fallback : operationFallback;")); assert!(response.contains("let dashboardLiveCurrentLanes = [];")); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/history.rs b/apps/decodex/src/orchestrator/tests/operator/status/history.rs index a15ab88a3..0e4ac3cfa 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/history.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/history.rs @@ -246,9 +246,13 @@ fn operator_status_history_lanes_group_attempts_by_issue() { assert!(rendered.contains("issue: XY-323")); assert!(rendered.contains("attempts: 2")); assert!(rendered.contains("lifecycle_metrics: attempts=2; captured=2/2; missing=0; protocol_events=2")); - assert!(rendered.contains("phase_breakdown")); - assert!(rendered.contains("phase: development label: Development attempts: 1")); - assert!(rendered.contains("phase: review label: Review attempts: 1")); + assert!(rendered.contains("lifecycle_bucket_breakdown")); + assert!(rendered.contains( + "lifecycle_bucket: Development lifecycle_bucket_key: development attempts: 1" + )); + assert!(rendered.contains( + "lifecycle_bucket: Review lifecycle_bucket_key: review attempts: 1" + )); } #[test] @@ -429,8 +433,10 @@ fn live_operator_history_lanes_prefer_linear_ledger_outcome() { assert!(rendered.contains("closeout_status: Done")); assert!(rendered.contains("lifecycle_elapsed_seconds: 600")); assert!(rendered.contains("local_attempts: 2")); - assert!(rendered.contains("phase_breakdown")); - assert!(rendered.contains("phase: development label: Development attempts: 2")); + assert!(rendered.contains("lifecycle_bucket_breakdown")); + assert!(rendered.contains( + "lifecycle_bucket: Development lifecycle_bucket_key: development attempts: 2" + )); assert!(!rendered.contains("pr_url: none")); assert_eq!( snapshot_json["history_lanes"][0]["latest_run"]["status"], diff --git a/apps/decodex/src/orchestrator/tests/operator/status/text.rs b/apps/decodex/src/orchestrator/tests/operator/status/text.rs index 18d77af31..45dae3447 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/text.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/text.rs @@ -129,6 +129,8 @@ fn operator_status_text_renders_human_readable_sections() { post_review_lanes: operator_status_text_post_review_lanes(), }; let rendered = orchestrator::render_operator_status(&snapshot); + let snapshot_json = serde_json::to_value(&snapshot).expect("snapshot should serialize"); + let current_lane_json = &snapshot_json["current_lanes"][0]; assert!(rendered.contains("Project: pubfi")); assert!(rendered.contains("Warnings: 0")); @@ -141,8 +143,13 @@ fn operator_status_text_renders_human_readable_sections() { assert!(rendered.contains("run_id: run-1")); assert_eq!(rendered.matches("run_id: run-1").count(), 1); assert!(rendered.contains("attempt_status: running")); - assert!(rendered.contains("phase: executing")); + assert!(rendered.contains("run_phase: executing")); assert!(rendered.contains("current_operation: agent_run")); + assert!(rendered.contains("active_goal_phase: implement_to_validation_ready")); + assert!(rendered.contains("public_progress_phase: implementing")); + assert_eq!(current_lane_json["run_phase"], "executing"); + assert_eq!(current_lane_json["active_goal_phase"], "implement_to_validation_ready"); + assert_eq!(current_lane_json["public_progress_phase"], "implementing"); assert!(rendered.contains("queue_lease_state: held")); assert!(rendered.contains("queue_lease: held")); assert!(rendered.contains("execution_liveness: process_alive")); @@ -248,6 +255,7 @@ fn operator_status_text_surfaces_execution_program_summary() { dispatchable_count: 0, mapped_issue_identifiers: vec![String::from("XY-853")], node_readbacks: vec![OperatorExecutionProgramNodeStatus { + program_stage: String::from("runtime"), lifecycle_state: String::from("blocked"), readiness_state: String::from("blocked"), issue_identifier: Some(String::from("XY-853")), @@ -274,7 +282,7 @@ fn operator_status_text_surfaces_execution_program_summary() { "program_id: program-853 status=blocked source_contract_id: contract-852 intake_kind=goal_intake summary=\"Resolve promoted program work.\" nodes=3 planned=0 mapped=0 ready=1 queued=0 blocked=1 held=0 active=0 attention=0 completed=1 stale=0 superseded=0 dispatchable=0 mapped_issues=XY-853" )); assert!(rendered.contains( - "node: issue=XY-853 issue_state=Todo lifecycle=blocked readiness=blocked dispatch_action=none reason_codes=dependency_not_terminal reasons=\"a dependency has not reached a required terminal state\" next_action=\"Complete the dependency issue or refresh the Execution Program dependency plan if this remains stale.\"" + "node: issue=XY-853 issue_state=Todo program_stage=runtime lifecycle=blocked readiness=blocked dispatch_action=none reason_codes=dependency_not_terminal reasons=\"a dependency has not reached a required terminal state\" next_action=\"Complete the dependency issue or refresh the Execution Program dependency plan if this remains stale.\"" )); } @@ -507,6 +515,7 @@ fn assert_program_node_readbacks( assert_eq!(node_json["lifecycle_state"], "active"); assert_eq!(node_json["readiness_state"], "blocked"); + assert_eq!(node_json["program_stage"], "runtime"); assert_eq!(node_json["dispatch_action"], serde_json::Value::Null); } @@ -569,6 +578,7 @@ fn operator_status_json_surfaces_missing_contract_program_recovery() { assert_eq!(program_json["status"], "stale"); assert_eq!(program_json["readback_warning"], "source_decision_contract_missing"); + assert_eq!(program_json["node_readbacks"][0]["program_stage"], "runtime"); assert_eq!(program_json["node_readbacks"][0]["reason_codes"][0], "source_decision_contract_missing"); assert_eq!( program_json["node_readbacks"][0]["next_action"], @@ -1031,6 +1041,7 @@ fn operator_status_text_terminal_run_freshness_uses_terminal_update() { terminal_run.status = String::from("succeeded"); terminal_run.phase = String::from("completed"); + terminal_run.run_phase = String::from("completed"); terminal_run.run_lease = true; terminal_run.updated_at = String::from("2026-03-14 10:05:00"); terminal_run.last_run_activity_at = Some(String::from("2026-03-14 10:10:00Z")); @@ -1061,7 +1072,7 @@ fn operator_status_text_terminal_run_freshness_uses_terminal_update() { let rendered = orchestrator::render_operator_status(&snapshot); assert!(rendered.contains("run_id: run-1")); - assert!(rendered.contains("phase: completed")); + assert!(rendered.contains("run_phase: completed")); assert!(rendered.contains("run_lease: yes")); assert!(rendered.contains("freshness_at: 2026-03-14 10:05:00")); assert!(rendered.contains("freshness_source: updated_at")); diff --git a/apps/decodex/src/orchestrator/tests/operator/status_support.rs b/apps/decodex/src/orchestrator/tests/operator/status_support.rs index c049447b5..21435a4bb 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status_support.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status_support.rs @@ -354,8 +354,11 @@ fn operator_status_text_current_lane() -> OperatorRunStatus { lane_control_next_action: String::from("continue_owned_attempt"), lane_control_conditions: Vec::new(), phase: String::from("executing"), + run_phase: String::from("executing"), wait_reason: None, current_operation: String::from(RUN_OPERATION_AGENT_RUN), + active_goal_phase: Some(String::from("implement_to_validation_ready")), + public_progress_phase: Some(String::from("implementing")), thread_id: Some(String::from("thread-1")), turn_id: Some(String::from("turn-1")), thread_status: Some(String::from("active")), diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index d32cb0e5d..36c02899a 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -1412,6 +1412,8 @@ impl OperatorExecutionProgramStatus { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct OperatorExecutionProgramNodeStatus { + #[serde(default = "operator_execution_program_unknown_status")] + program_stage: String, lifecycle_state: String, readiness_state: String, issue_identifier: Option, @@ -1548,8 +1550,14 @@ struct OperatorRunStatus { #[serde(default, skip_serializing_if = "Vec::is_empty")] lane_control_conditions: Vec, phase: String, + #[serde(default)] + run_phase: String, wait_reason: Option, current_operation: String, + #[serde(skip_serializing_if = "Option::is_none")] + active_goal_phase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + public_progress_phase: Option, thread_id: Option, turn_id: Option, thread_status: Option, @@ -2376,6 +2384,7 @@ fn operator_execution_program_node_readback( let issue = node.linear_issue(); OperatorExecutionProgramNodeStatus { + program_stage: node.stage().as_str().to_owned(), lifecycle_state: node.lifecycle_state().as_str().to_owned(), readiness_state: node.state().as_str().to_owned(), issue_identifier: issue.map(|issue| issue.issue_identifier().to_owned()), @@ -2398,6 +2407,7 @@ fn operator_execution_program_missing_contract_nodes( let issue = node.linear_issue(); OperatorExecutionProgramNodeStatus { + program_stage: node.stage().as_str().to_owned(), lifecycle_state: String::from("stale"), readiness_state: String::from("stale"), issue_identifier: issue.map(|issue| issue.issue_identifier().to_owned()), diff --git a/docs/spec/agent-evidence.md b/docs/spec/agent-evidence.md index a07d46b27..08c3c3291 100644 --- a/docs/spec/agent-evidence.md +++ b/docs/spec/agent-evidence.md @@ -129,7 +129,8 @@ The capsule captures the compact runtime state an agent needs before opening a worktree: - issue id, issue identifier, title, run id, attempt number -- status, raw attempt status, phase, wait reason, current operation +- status, raw attempt status, run phase, wait reason, current operation, active goal + phase, and public progress phase when present - queue lease state and execution liveness - thread, turn, process, protocol event, idle, and progress fields - effective model/provider/cwd/approval/sandbox fields when known diff --git a/docs/spec/loop-runtime.md b/docs/spec/loop-runtime.md index faf83a4b4..77292ce1c 100644 --- a/docs/spec/loop-runtime.md +++ b/docs/spec/loop-runtime.md @@ -419,6 +419,7 @@ Each program node carries: - objective lineage back to the accepted Decision Contract or issue-batch authority - executable stage: `research`, `design`, `spec`, `schema`, `runtime`, `plugin`, `eval`, or `handoff` +- operator readbacks must surface that concept as `program_stage` - explicit dependencies with optional terminal-state requirements; when omitted, the registered `WORKFLOW.md` terminal states satisfy the dependency - conflict domains for `file`, `module`, `state`, `credentials`, diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index c9e484866..ce75f5b2e 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -829,8 +829,8 @@ After a process restart, recent-run history, run lease ownership, retained post- 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. 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_current_lane` 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. +- Operator status snapshots must expose structured liveness and wait-state fields derived from runtime records plus marker breadcrumbs, including explicit `run_phase`, optional wait reason, `current_operation`, optional `active_goal_phase`, optional `public_progress_phase`, 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_current_lane` 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. Lifecycle metrics that group attempts by run phase must be presented in operator UI/readback as lifecycle buckets, not as generic stages. 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. - A retryable runtime or app-server failure that leaves tracked worktree changes must