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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop/src/main/services/github/githubService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ function jsonResponse(
describe("githubService.apiRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
// Tests assume no ambient token; CI/agents often inject GITHUB_TOKEN globally.
delete process.env.GITHUB_TOKEN;
delete process.env.ADE_GITHUB_TOKEN;
});

it("returns data and response on success (HTTP 200)", async () => {
Expand Down
84 changes: 81 additions & 3 deletions apps/desktop/src/main/services/orchestrator/workerTracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,73 @@ function resolvePlannerPlanMissingIntervention(args: {
};
}

function resolvePlannerPlanMissingInterventionsAfterPlanningSuccess(args: {
ctx: OrchestratorContext;
deps: UpdateWorkerStateDeps;
missionId: string;
attempt: OrchestratorRunGraph["attempts"][number];
step: OrchestratorRunGraph["steps"][number];
planArtifactPersisted: boolean;
}): void {
const stepMeta = isRecord(args.step.metadata) ? args.step.metadata : {};
const phaseKey = typeof stepMeta.phaseKey === "string" ? stepMeta.phaseKey.trim().toLowerCase() : "";
const stepType = typeof stepMeta.stepType === "string" ? stepMeta.stepType.trim().toLowerCase() : "";
// Match `extractAndRegisterArtifacts` planning detection: read-only implementation steps must not
// auto-resolve `planner_plan_missing` unless ADE would persist the canonical plan artifact.
const isPlanningStep = stepType === "planning" || stepType === "analysis" || phaseKey === "planning";
if (!isPlanningStep) return;

// Only resolve when the canonical plan artifact was actually written and registered in this attempt.
// `report_result.plan.markdown` alone is insufficient: fs.writeFileSync or registerArtifact may have
// failed inside extractAndRegisterArtifacts, and we'd otherwise clear the intervention without a plan.
if (!args.planArtifactPersisted) return;

const mission = args.ctx.missionService.get(args.missionId);
if (!mission) return;

const resolvedAt = nowIso();
for (const intervention of mission.interventions) {
if (intervention.status !== "open" || intervention.interventionType !== "failed_step") continue;
const meta = isRecord(intervention.metadata) ? intervention.metadata : {};
const reasonCode = typeof meta.reasonCode === "string" ? meta.reasonCode.trim() : "";
if (reasonCode !== "planner_plan_missing") continue;
// Allow cross-run recovery: any successful planning attempt on this mission can clear a
// stale planner_plan_missing intervention, regardless of which run originally recorded it.

try {
args.ctx.missionService.resolveIntervention({
missionId: args.missionId,
interventionId: intervention.id,
status: "resolved",
note: `Auto-resolved after planner returned report_result.plan for step "${stepTitleForMessage(args.step)}".`,
});
args.deps.recordRuntimeEvent({
runId: args.attempt.runId,
stepId: args.step.id,
attemptId: args.attempt.id,
sessionId: args.attempt.executorSessionId,
eventType: "intervention_resolved",
eventKey: `intervention_resolved:${intervention.id}:planner_plan_recovered`,
payload: {
interventionId: intervention.id,
reason: "planner_plan_recovered",
recoveredByStepId: args.step.id,
recoveredByStepKey: args.step.stepKey,
resolvedAt,
},
});
} catch (error) {
args.ctx.logger.debug("ai_orchestrator.planner_plan_missing_resolve_failed", {
missionId: args.missionId,
runId: args.attempt.runId,
stepId: args.step.id,
interventionId: intervention.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
}

function resolveRecoveredFailedStepInterventions(args: {
ctx: OrchestratorContext;
deps: UpdateWorkerStateDeps;
Expand Down Expand Up @@ -772,11 +839,12 @@ export function extractAndRegisterArtifacts(
graph: OrchestratorRunGraph;
attempt: OrchestratorRunGraph["attempts"][number];
}
): void {
): { planArtifactPersisted: boolean } {
let planArtifactPersisted = false;
try {
const { graph, attempt } = args;
const envelope = attempt.resultEnvelope;
if (!envelope) return;
if (!envelope) return { planArtifactPersisted };
const outputs = isRecord(envelope.outputs) ? envelope.outputs : {};

const step = graph.steps.find((s) => s.id === attempt.stepId);
Expand Down Expand Up @@ -1018,6 +1086,7 @@ export function extractAndRegisterArtifacts(
absolutePath: absolutePlanPath,
},
});
planArtifactPersisted = true;
} else {
const missingPlanDetail = "Planning worker completed without returning a usable plan payload in report_result.plan.markdown.";
ctx.logger.warn("ai_orchestrator.plan_artifact_missing", {
Expand Down Expand Up @@ -1107,6 +1176,7 @@ export function extractAndRegisterArtifacts(
error: error instanceof Error ? error.message : String(error)
});
}
return { planArtifactPersisted };
}

// ── updateWorkerStateFromEvent ───────────────────────────────────
Expand Down Expand Up @@ -1263,7 +1333,7 @@ export function updateWorkerStateFromEventCtx(
}

// Extract and register artifacts from the worker result envelope.
extractAndRegisterArtifacts(ctx, { graph, attempt });
const artifactExtraction = extractAndRegisterArtifacts(ctx, { graph, attempt });

// Evaluation loop: evaluate step based on active runtime profile.
const step = graph.steps.find((s) => s.id === attempt.stepId);
Expand All @@ -1275,6 +1345,14 @@ export function updateWorkerStateFromEventCtx(
attempt,
step,
});
resolvePlannerPlanMissingInterventionsAfterPlanningSuccess({
ctx,
deps,
missionId: graph.run.missionId,
attempt,
step,
planArtifactPersisted: artifactExtraction.planArtifactPersisted,
});
}
if (step && ctx.aiIntegrationService) {
const runtimeProfile = ctx.runRuntimeProfiles.get(attempt.runId) ?? resolveActiveRuntimeProfile(ctx, graph.run.missionId);
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/renderer/components/app/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ export function CommandPalette({
const requestId = ++detailRequestRef.current;
setDetailLoading(true);
setDetailPath(detailTarget);
const timeout = window.setTimeout(() => {
const timeout = globalThis.setTimeout(() => {
void window.ade.project
.getDetail(detailTarget)
.then((result) => {
Expand All @@ -451,7 +451,7 @@ export function CommandPalette({
});
}, 140);
return () => {
window.clearTimeout(timeout);
globalThis.clearTimeout(timeout);
};
}, [detail, detailTarget, mode, open]);

Expand Down
25 changes: 25 additions & 0 deletions apps/ios/ADE/Models/RemoteModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,31 @@ struct SyncDomainStatus: Equatable {
static let disconnected = SyncDomainStatus(phase: .disconnected)
}

extension SyncDomainStatus {
/// Inline notice when the domain is in `.failed` but cached rows may still render (no empty-state card).
func inlineHydrationFailureNotice(for domain: SyncDomain) -> (title: String, message: String)? {
guard phase == .failed else { return nil }
let raw = lastError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let normalized = raw.split(whereSeparator: \.isWhitespace).joined(separator: " ")
let message =
normalized.isEmpty
? "Fresh data could not be loaded from the host. Cached content may be outdated until you retry or reconnect."
: normalized
let title: String
switch domain {
case .lanes:
title = "Lane hydration failed"
case .files:
title = "Files hydration failed"
case .work:
title = "Work hydration failed"
case .prs:
title = "PR hydration failed"
}
return (title, message)
}
}

struct LaneStatus: Codable, Equatable {
var dirty: Bool
var ahead: Int
Expand Down
104 changes: 54 additions & 50 deletions apps/ios/ADE/Views/Components/ADEDesignSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -421,61 +421,34 @@ struct ADEStatusPill: View {
}
}

struct ADEConnectionPill: View {
struct ADEConnectionDot: View {
@EnvironmentObject private var syncService: SyncService

private var tint: Color {
switch syncService.connectionState {
case .connected, .syncing: return ADEColor.success
case .connecting: return ADEColor.accent
case .disconnected, .error: return ADEColor.danger
case .connected: return ADEColor.success
case .syncing: return ADEColor.warning
case .connecting: return ADEColor.warning
case .error, .disconnected: return ADEColor.danger
}
}

private var label: String {
private var statusText: String {
switch syncService.connectionState {
case .connected, .syncing: return "Connected"
case .connected: return "Connected"
case .syncing: return "Syncing"
case .connecting: return "Connecting"
case .disconnected: return "Not connected"
case .error: return "Offline"
case .error: return "Error"
case .disconnected: return "Disconnected"
}
}

var body: some View {
Button {
syncService.settingsPresented = true
} label: {
HStack(spacing: 6) {
Circle()
.fill(tint)
.frame(width: 8, height: 8)
Text(label)
.font(.caption.weight(.semibold))
.foregroundStyle(ADEColor.textPrimary)
}
}
.buttonStyle(.plain)
.accessibilityLabel("Connection: \(label). Tap to open settings.")
private var showsHostSuffix: Bool {
syncService.connectionState == .connected
}
}

struct ADEConnectionDot: View {
@EnvironmentObject private var syncService: SyncService

private var tint: Color {
switch syncService.connectionState {
case .connected, .syncing: return ADEColor.success
case .connecting: return ADEColor.warning
case .error: return ADEColor.danger
case .disconnected: return ADEColor.textMuted
}
}

private var showsHostName: Bool {
switch syncService.connectionState {
case .connected, .syncing: return true
default: return false
}
private var showsConnectedGlow: Bool {
syncService.connectionState == .connected
}

private var truncatedHostName: String? {
Expand All @@ -490,15 +463,32 @@ struct ADEConnectionDot: View {
}

private var accessibilityLabel: String {
let errorSuffix: String = {
guard syncService.connectionState == .error,
let raw = syncService.lastError?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty
else {
return ""
}
let normalized = raw.split(whereSeparator: \.isWhitespace).joined(separator: " ")
let clipped = normalized.count > 120 ? String(normalized.prefix(117)) + "…" : normalized
return ". \(clipped)"
}()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

switch syncService.connectionState {
case .connected, .syncing:
if let name = syncService.hostName, !name.isEmpty {
return "Connected to \(name). Tap to open settings."
case .connected:
if let name = truncatedHostName {
return "Connected to \(name)"
}
return "Connected. Tap to open settings."
case .connecting: return "Connecting. Tap to open settings."
case .error: return "Connection error. Tap to open settings."
case .disconnected: return "Not connected. Tap to open settings."
return "Connected"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case .syncing:
return "Syncing with host"
case .connecting:
return "Connecting to host"
case .error:
return "Connection error\(errorSuffix)"
case .disconnected:
return "Disconnected from host"
}
}

Expand All @@ -510,17 +500,31 @@ struct ADEConnectionDot: View {
Circle()
.fill(tint)
.frame(width: 10, height: 10)
.shadow(color: tint.opacity(showsHostName ? 0.5 : 0), radius: showsHostName ? 4 : 0)
if showsHostName, let name = truncatedHostName {
.shadow(color: tint.opacity(showsConnectedGlow ? 0.5 : 0), radius: showsConnectedGlow ? 4 : 0)
Text(statusText)
.font(.caption.weight(.semibold))
.foregroundStyle(ADEColor.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.75)
if showsHostSuffix, let name = truncatedHostName {
Text("·")
.font(.caption.weight(.medium))
.foregroundStyle(ADEColor.textMuted)
.minimumScaleFactor(0.75)
Text(name)
.font(.caption.weight(.medium))
.foregroundStyle(ADEColor.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
}
.frame(minHeight: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
.accessibilityHint("Opens settings to pair or reconnect.")
.accessibilityShowsLargeContentViewer()
}
}

Expand Down
22 changes: 19 additions & 3 deletions apps/ios/ADE/Views/Files/FilesDetailComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,28 @@ import SwiftUI
import UIKit

struct FilesHeaderStrip: View {
@EnvironmentObject private var syncService: SyncService

let relativePath: String
let language: FilesLanguage
let fileSize: Int
let isFilesLive: Bool
let transitionNamespace: Namespace.ID?

private var filesBrowserStatusSuffix: String? {
let phase = syncService.status(for: .files).phase
let connection = syncService.connectionState
if phase == .ready && (connection == .connected || connection == .syncing) {
return nil
}
if phase == .hydrating || phase == .syncingInitialData || connection == .syncing {
return "Syncing"
}
if connection == .connecting {
return "Connecting"
}
return "Offline"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new fallback branch maps every non-ready/non-hydrating/non-connecting state to "Offline", but the files domain also enters .failed while the socket is still live (refreshLaneSnapshots() sets [.lanes, .files] to .failed unless connectionState is already .disconnected/.error). That means a hydration/decode failure now renders Read only · Offline in the file detail header even though the host is still connected and the inline retry notice is the real problem, which gives users contradictory status cues. Handle .failed separately and reserve Offline for actual disconnected/error connection states.

// apps/ios/ADE/Views/Files/FilesDetailComponents.swift
if connection == .connecting {
  return "Connecting"
}
return "Offline"

}

var body: some View {
HStack(alignment: .center, spacing: 12) {
Image(systemName: fileIcon(for: relativePath))
Expand Down Expand Up @@ -38,9 +54,9 @@ struct FilesHeaderStrip: View {
Text("Read only")
.font(.caption2.weight(.medium))
.foregroundStyle(ADEColor.textSecondary)
if !isFilesLive {
if let filesBrowserStatusSuffix {
Text("·").foregroundStyle(ADEColor.textMuted)
Text("Offline")
Text(filesBrowserStatusSuffix)
.font(.caption2.weight(.semibold))
.foregroundStyle(ADEColor.warning)
}
Expand Down
Loading
Loading