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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ status row (`ok` / `warn` / `fail`) per check. It exits non-zero when any row is
- **Wedge history** — the last brain-loop watchdog wedge that was recovered (blocking command and how long it blocked), read from the runtime dir or the brain's reported `lastWedge`. `warn` when the most recent wedge is within the last 24h.
- **Sync port** — the sync host port the brain bound. `ok` on the default port, `warn` when bound elsewhere (with the base-port holders it found), `fail` when the brain is up but reported no port.
- **Publish health** — account-directory publish state from the brain's sync route health. `ok` when a publish succeeded recently, `fail` when it has been failing for ≥2 min, otherwise `warn`, with the slowest publish leg annotated.
- **Relay** — relay route health as already computed by the brain. `ok` when the relay control is connected, the bridge is validated, and the end-to-end round-trip is verified; `fail` when the route is not fully validated; `warn` when relay is disabled or route health is unavailable.
- **Relay** — relay route health as already computed by the brain. `ok` when the relay control is connected, the bridge is validated, and the end-to-end round-trip is verified; `fail` when the route is not fully validated; `warn` when relay is disabled or route health is unavailable. When another ADE process on this machine has claimed the relay slot, the brain deliberately stops redialing and this row reports that suppression ahead of any lower-level close error, so the detail names the fix (quit the rival process) instead of the symptom. `ade sync status --text` shows the same reason on its `relay` line, plus a `relay failing since` row for how long the current outage has run.
- **Account** — whether this machine's brain is signed in to an ADE account (and the credential source), read via the brain's `account.call status`. `warn` when signed out or unavailable.

Default doctor does not call provider, GitHub, or Linear networks — it talks only
Expand Down
47 changes: 32 additions & 15 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1624,7 +1624,19 @@ export async function createAdeRuntime(args: {
? { userId, expiresAt: refreshed.expiresAt }
: null;
},
onPublicationStateChanged: () => resolvedArgs.syncRuntime?.requestAccountMachinePublish?.(),
onPublicationStateChanged: () => {
// Relay state changes are machine-level; without this nudge an idle
// machine emits no sync-status snapshot and the desktop relay banner
// never appears (or never clears).
syncService?.notifyRouteStateChanged();
resolvedArgs.syncRuntime?.requestAccountMachinePublish?.();
},
// The analytics service is machine-scoped and shared, so capturing it in
// this one-per-machine factory closure is safe (unlike the listener
// accessors above, which is why those moved to attachHostListener).
captureAnalytics: (input) => {
productAnalyticsService.captureInternal(input);
},
});
return service;
});
Expand All @@ -1639,19 +1651,21 @@ export async function createAdeRuntime(args: {
if (resolvedArgs.syncRuntime?.sharedSyncListener) {
syncTunnelClientService.attachHostListener(resolvedArgs.syncRuntime.sharedSyncListener);
}
// Only the runtime that actually hosts phone sync (owns the brain-level
// shared listener) may register the relay tunnel. The relay DO keeps ONE
// host socket per machineKey (last wins), so a headless one-shot CLI
// runtime or embedded fallback starting the tunnel would steal the relay
// from `ade serve` and then fail every phone /connect (no sync port).
const canHostRelayTunnel = resolvedArgs.syncRuntime?.sharedSyncListener != null;
if (canHostRelayTunnel) {
void syncTunnelClientService.start().catch((error) => {
logger.warn("sync.tunnel_start_failed", {
error: error instanceof Error ? error.message : String(error),
});
});
}
// Only the runtime that holds the machine-wide sync host lease may register
// the relay tunnel. See relayTunnelAuthorityGate for why the old
// "has a listener" gate let secondary brains evict the real host.
const [{ createRelayTunnelAuthorityGate }, { holdsSyncHostSingleton, onSyncHostSingletonAuthorityChanged }] =
await Promise.all([
import("./services/sync/relayTunnelAuthorityGate"),
import("./services/sync/syncHostSingleton"),
]);
const relayTunnelGate = createRelayTunnelAuthorityGate({
hostListener: resolvedArgs.syncRuntime?.sharedSyncListener ?? null,
tunnel: syncTunnelClientService,
holdsLease: holdsSyncHostSingleton,
subscribe: onSyncHostSingletonAuthorityChanged,
logger,
});

let externalSessionsService: ReturnType<typeof createExternalSessionsService> | null = null;
if (resolvedArgs.syncRuntime?.enabled && agentChatService) {
Expand Down Expand Up @@ -1882,7 +1896,10 @@ export async function createAdeRuntime(args: {
swallow(() => detachPushSources());
// The tunnel client is machine-level and shared across scopes — closing
// one project must not sever the relay for the others. The daemon's
// shutdown path (disposeServeResources) stops it.
// shutdown path (disposeServeResources) stops it. Drop only THIS scope's
// lease subscription, or a disposed scope could later stop the shared
// tunnel on a lease transition it no longer has any business observing.
swallow(() => relayTunnelGate.dispose());
swallow(() => automationIngressService?.dispose());
swallow(() => linearIngressService?.stop());
swallow(() => automationService?.dispose());
Expand Down
Loading